Hop3's Plugin Architecture: Extensibility Without Complexity¶
When building a PaaS, you face a fundamental tension: supporting many languages, databases, and deployment patterns while keeping the codebase maintainable. Hop3 resolves this with a plugin architecture built on Pluggy.
Why Plugins?¶
A PaaS needs to handle many concerns:
- Build: How do you compile Go? Install Python dependencies? Bundle JavaScript?
- Deploy: uWSGI for Python? PM2 for Node? Static files for SPAs?
- Services: PostgreSQL? MySQL? Redis? Each with its own setup and credentials
- Proxy: Nginx? Caddy? Traefik? Each with different configuration formats
- OS: Debian uses
apt, Fedora usesdnf, package names differ
Each concern stays isolated, testable, and replaceable.
Plugin Types¶
Hop3 defines six plugin types, each with a specific responsibility:
1. Builders¶
Builders orchestrate the build process. They decide where and how to build.
@dataclass(frozen=True)
class LocalBuilder:
"""Build directly on the server."""
name: str = "local"
def build(self, app: App, toolchain: LanguageToolchain) -> BuildArtifact:
return toolchain.build()
@dataclass(frozen=True)
class DockerBuilder:
"""Build inside a Docker container."""
name: str = "docker"
def build(self, app: App, toolchain: LanguageToolchain) -> BuildArtifact:
# Build in isolated container
...
2. Language Toolchains¶
Toolchains handle language-specific build logic:
class PythonToolchain(LanguageToolchain):
name = "Python"
def accept(self) -> bool:
# Detect Python projects
return self.check_exists(["requirements.txt", "pyproject.toml"])
def build(self) -> BuildArtifact:
self.make_virtual_env()
self.install_dependencies()
return self._make_build_artifact(kind="python")
Current toolchains: Python, Node.js, Ruby, Go, Rust, PHP, Java, Clojure, Static, Generic.
3. Deployers¶
Deployers run built applications:
@dataclass(frozen=True)
class UWSGIDeployer:
"""Deploy Python/Ruby apps via uWSGI."""
name: str = "uwsgi"
def accept(self, artifact: BuildArtifact) -> bool:
return artifact.kind in ("python", "ruby")
def deploy(self, app: App, artifact: BuildArtifact) -> None:
# Generate uWSGI config, start workers
...
Current deployers: uWSGI, Static, Docker Compose.
4. Addons¶
Addons provision backing services:
@dataclass(frozen=True)
class PostgreSQLAddon:
name: str = "postgres"
def create(self, instance_name: str) -> AddonCredential:
# Create database, user, return credentials
...
def attach(self, app: App, credential: AddonCredential) -> dict[str, str]:
# Return env vars to inject
return {"DATABASE_URL": credential.connection_url}
Current addons: PostgreSQL, MySQL, Redis.
5. Proxy Plugins¶
Proxy plugins configure reverse proxies:
@dataclass(frozen=True)
class NginxProxy:
name: str = "nginx"
def setup(self, app: App, config: ProxyConfig) -> None:
# Generate nginx server block
# Handle SSL certificates
# Reload nginx
...
Current proxies: Nginx, Caddy, Traefik.
6. OS Implementations¶
OS plugins handle system-level operations:
@dataclass(frozen=True)
class DebianOS:
name: str = "debian"
def install_packages(self, packages: list[str]) -> None:
subprocess.run(["apt-get", "install", "-y", *packages])
def get_service_manager(self) -> str:
return "systemd"
Current: Debian family (Ubuntu, Debian), Red Hat family (Fedora, Rocky, AlmaLinux), Arch, BSD, macOS.
Hook Specifications¶
Plugins register themselves via hook specifications defined in core/hookspecs.py:
class Hop3Spec:
@hookspec
def get_builders(self) -> list[type[Builder]]:
"""Return available builders."""
@hookspec
def get_language_toolchains(self) -> list[type[LanguageToolchain]]:
"""Return available language toolchains."""
@hookspec
def get_deployers(self) -> list[type[Deployer]]:
"""Return available deployers."""
@hookspec
def get_addons(self) -> list[type[Addon]]:
"""Return available addons."""
@hookspec
def get_proxies(self) -> list[type[ProxyPlugin]]:
"""Return available proxy plugins."""
@hookspec
def get_os_implementations(self) -> list[type[OSImplementation]]:
"""Return available OS implementations."""
Plugin Discovery¶
At startup, Hop3 discovers plugins via Pluggy's plugin manager:
pm = pluggy.PluginManager("hop3")
pm.add_hookspecs(Hop3Spec)
# Register built-in plugins
pm.register(BuiltinPlugins())
# Discover external plugins via entry points
pm.load_setuptools_entrypoints("hop3.plugins")
External plugins can register via pyproject.toml:
Dependency Injection with Dishka¶
Plugins often need access to shared services (database sessions, configuration, etc.). We use Dishka for dependency injection:
class RepositoryProvider(Provider):
@provide(scope=Scope.REQUEST)
def get_app_repository(self, session: AsyncSession) -> AppRepository:
return AppRepository(session=session)
# In a plugin
class MyAddon:
def create(self, app_repo: AppRepository) -> AddonCredential:
app = app_repo.get_by_name(self.app_name)
...
This keeps plugins decoupled from implementation details.
Writing Your Own Plugin¶
Here's a minimal example of a custom addon:
# my_addon/plugin.py
from dataclasses import dataclass
from hop3.core.protocols import Addon, AddonCredential
@dataclass(frozen=True)
class MyCustomAddon:
name: str = "my-service"
def create(self, instance_name: str) -> AddonCredential:
# Your provisioning logic here
return AddonCredential(
addon_type=self.name,
instance_name=instance_name,
credentials={"API_KEY": "..."},
)
def destroy(self, credential: AddonCredential) -> None:
# Cleanup logic
pass
# Register via hookimpl
import pluggy
hookimpl = pluggy.HookimplMarker("hop3")
class MyPlugin:
@hookimpl
def get_addons(self):
return [MyCustomAddon]
Benefits of This Architecture¶
- Isolation: Each plugin is self-contained and testable
- Extensibility: Add new languages/services without touching core code
- Flexibility: Swap implementations (Nginx → Caddy) via configuration
- Maintainability: Clear boundaries between concerns
- Community: External plugins without forking
Learn More¶
Related posts: Package Architecture explains how we split Hop3 into focused packages. The BuildArtifact Pattern shows how plugins produce portable build artifacts. For plugin development, see the Plugin Development Guide.