Plugin Development Guide¶
This guide covers how to create plugins for Hop3: builders, language toolchains, deployers, addons, proxies, and operating system support.
Overview¶
Hop3 uses a plugin-based architecture built on pluggy, the same plugin framework used by pytest. Plugins allow you to extend Hop3 with:
- Builders: Orchestrate builds (local vs containerized), e.g., LocalBuilder, DockerBuilder
- Language Toolchains: Language-specific build tooling, e.g., Python, Node, Ruby, Go, Rust
- Deployers: Run build artifacts, e.g., uWSGI, Docker Compose, Static
- Addons: Backing services (databases, caches), e.g., PostgreSQL, Redis, MySQL
- Proxies: Configure reverse proxies, e.g., Nginx, Caddy, Traefik
- OS Implementations: Support Linux distributions, e.g., Debian family, Red Hat family
When to Create a Plugin¶
Create a plugin when you want to:
- Add support for a new programming language (language toolchain)
- Support a new build orchestration method (builder)
- Support a new deployment runtime (deployer)
- Integrate a new backing service like MongoDB, Elasticsearch (addon)
- Add support for a new reverse proxy (proxy)
- Support a new Linux distribution (OS implementation)
Modify core code when you need to:
- Change the plugin system itself
- Modify the deployment pipeline orchestration
- Change database models or API contracts
- Update the CLI or web UI
Plugin Types¶
1. Builders¶
Builders orchestrate the build process. There are two levels: - Level 1 (Orchestrators): Decide how to build (local vs containerized) - Level 2 (Language Toolchains): Language-specific build logic (used by LocalBuilder)
Protocol: Builder (from hop3.core.protocols)
Required attributes:
- name (str): Unique identifier (e.g., "python", "docker")
- context (DeploymentContext): Deployment context with app info
Required methods:
- accept() -> bool: Return True if this strategy can build the app
- build() -> BuildArtifact: Execute build and return artifact info
Example (simplified Python builder):
from pathlib import Path
from hop3.core.protocols import Builder, BuildArtifact, DeploymentContext
class PythonBuildStrategy:
"""Build Python applications using virtualenv."""
name = "python"
def __init__(self, context: DeploymentContext):
self.context = context
def accept(self) -> bool:
"""Accept if requirements.txt or pyproject.toml exists."""
src_path = self.context.source_path
return (src_path / "requirements.txt").exists() or
(src_path / "pyproject.toml").exists()
def build(self) -> BuildArtifact:
"""Create virtualenv and install dependencies."""
app_name = self.context.app_name
venv_path = Path(f"/home/hop3/apps/{app_name}/venv")
# Create virtualenv
subprocess.run(["python3", "-m", "venv", str(venv_path)], check=True)
# Install dependencies
pip = venv_path / "bin" / "pip"
subprocess.run([str(pip), "install", "-r", "requirements.txt"],
cwd=self.context.source_path, check=True)
return BuildArtifact(
kind="virtualenv",
location=str(venv_path),
metadata={"python_version": "3.11"}
)
2. Language Toolchains¶
Language toolchains provide language-specific build logic. They are used by LocalBuilder to build applications for specific programming languages.
Protocol: LanguageToolchain (from hop3.core.protocols)
Required methods:
- accept() -> bool: Return True if this toolchain can build the app
- build() -> BuildArtifact: Execute build and return artifact info
Hook: get_language_toolchains()
Location: hop3/plugins/build/language_toolchains/
Available toolchains: Python, Node.js, Ruby, Go, Rust, Java, PHP, Clojure, .NET, Elixir, Static
3. Deployers¶
Deployers run build artifacts and manage their lifecycle.
Protocol: Deployer (from hop3.core.protocols)
Required attributes:
- name (str): Unique identifier (e.g., "uwsgi", "docker-compose")
- context (DeploymentContext): Deployment context
- artifact (BuildArtifact): Build artifact to deploy
Required methods:
- accept() -> bool: Return True if this strategy can deploy the artifact
- deploy(deltas: dict | None = None) -> DeploymentInfo: Deploy the artifact
- stop() -> None: Stop the running application
- check_status() -> bool: Check if app is actually running
- scale(deltas: dict[str, int] | None = None) -> None: Scale workers
Example (simplified Docker Compose deployer):
import subprocess
from hop3.core.protocols import Deployer, DeploymentInfo
class DockerComposeDeployer:
"""Deploy applications using Docker Compose."""
name = "docker-compose"
def __init__(self, context, artifact):
self.context = context
self.artifact = artifact
def accept(self) -> bool:
"""Accept if artifact is a docker-image."""
return self.artifact.kind == "docker-image"
def deploy(self, deltas=None) -> DeploymentInfo:
"""Run docker-compose up."""
src_path = self.context.source_path
env = {"HOP3_IMAGE_TAG": self.artifact.location}
subprocess.run(
["docker", "compose", "up", "-d", "--remove-orphans"],
cwd=src_path,
check=True,
env=env
)
return DeploymentInfo(
protocol="http",
address="127.0.0.1",
port=8080
)
def stop(self):
"""Run docker-compose down."""
src_path = self.context.source_path
subprocess.run(["docker", "compose", "down"], cwd=src_path)
def check_status(self) -> bool:
"""Check if containers are running."""
result = subprocess.run(
["docker", "compose", "ps", "--format", "{{.State}}"],
cwd=self.context.source_path,
capture_output=True,
text=True
)
return "running" in result.stdout.lower()
def scale(self, deltas=None):
"""Scale services."""
if not deltas:
return
scale_args = []
for service, count in deltas.items():
scale_args.extend(["--scale", f"{service}={count}"])
cmd = ["docker", "compose", "up", "-d"] + scale_args
subprocess.run(cmd, cwd=self.context.source_path, check=True)
4. Addons¶
Addons manage backing services (databases, caches, etc.). They are independent resources that can be shared across applications.
Protocol: Addon (from hop3.core.protocols)
Hook: get_addons()
Location: hop3/plugins/{postgresql,mysql,redis}/
Required attributes:
- name (str): Addon type (e.g., "postgres", "redis")
- addon_name (str): Specific instance name
Required methods:
- create() -> None: Create the service instance
- destroy() -> None: Destroy the service instance
- get_connection_details() -> dict[str, str]: Return environment variables for connection
- backup() -> Path: Create a backup
- restore(backup_path: Path) -> None: Restore from backup
- info() -> dict: Get service information
Example (simplified Redis service):
from pathlib import Path
from hop3.core.protocols import Addon
class RedisService:
"""Manage Redis service instances."""
name = "redis"
def __init__(self, *, addon_name: str):
self.addon_name = addon_name
def create(self):
"""Create Redis instance."""
port = self._allocate_port()
config_path = Path(f"/home/hop3/services/redis/{self.addon_name}.conf")
# Write Redis config
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(f"port {port}\nbind 127.0.0.1\n")
# Start Redis with systemd
subprocess.run([
"systemctl", "start", f"redis-{self.addon_name}"
], check=True)
def destroy(self):
"""Destroy Redis instance."""
subprocess.run([
"systemctl", "stop", f"redis-{self.addon_name}"
])
def get_connection_details(self) -> dict[str, str]:
"""Return connection environment variables."""
port = self._get_port()
return {
"REDIS_URL": f"redis://127.0.0.1:{port}/0"
}
def backup(self) -> Path:
"""Backup Redis data."""
# Trigger BGSAVE and copy RDB file
subprocess.run(["redis-cli", "BGSAVE"])
# ... implementation details
return Path(f"/backups/redis-{self.addon_name}.rdb")
def restore(self, backup_path: Path):
"""Restore from backup."""
# Stop Redis, copy RDB file, restart
# ... implementation details
pass
def info(self) -> dict:
"""Get service info."""
return {
"status": "running",
"version": "7.0",
"port": self._get_port()
}
5. Proxies¶
Proxies configure reverse proxies for applications.
Base Class: BaseProxy (from hop3.core.protocols)
Hook: get_proxies()
Location: hop3/plugins/proxy/{nginx,caddy,traefik}/
Required attributes:
- app (App): Application instance
- env (Env): Environment variables
- workers (dict[str, str]): Worker configurations
Required methods (abstract):
- get_proxy_name() -> str: Return proxy name ("nginx", "caddy", "traefik")
- setup_backend() -> None: Configure backend connection
- setup_certificates() -> None: Setup SSL certificates
- setup_cache() -> None: Configure caching
- setup_static() -> None: Configure static file serving
- extra_setup() -> None: Additional proxy-specific setup
- generate_config() -> None: Generate proxy config file
- check_config() -> None: Validate config
- reload_proxy() -> None: Reload proxy to apply changes
Capabilities (class attributes; override the BaseProxy defaults to match what the proxy can do):
- backend_protocols: frozenset[str]: what it can speak to an app's port (default {"http"}; add "fastcgi" for php-fpm pools)
- serves_files: bool: whether it serves files from disk, including the ACME challenge webroot (default True)
- fronts_waf: bool: whether it can route an app through its WAF (default False)
- hosts_certbot: bool: whether Hop3's certbot issuance and renewal work behind it (default False)
A deploy refuses an app that needs a capability the proxy does not declare, so declare only what the proxy does.
The setup() method is already implemented in BaseProxy and orchestrates the setup process.
Example (simplified Nginx proxy):
from dataclasses import dataclass
from hop3.core.protocols import BaseProxy
@dataclass(frozen=True)
class NginxVirtualHost(BaseProxy):
"""Nginx reverse proxy configuration."""
def get_proxy_name(self) -> str:
return "nginx"
def setup_backend(self):
"""Configure backend connection."""
# Use Unix socket or TCP
if self.env.get("NGINX_SOCKET"):
socket_path = f"/tmp/{self.app_name}.sock"
self.update_env("NGINX_SOCKET", socket_path)
else:
port = self.app.port
self.update_env("NGINX_BACKEND", f"127.0.0.1:{port}")
def setup_certificates(self):
"""Setup SSL certificates."""
# Generate self-signed cert or use Let's Encrypt
pass
def setup_cache(self):
"""Configure caching."""
if self.env.get("NGINX_CACHE_ENABLE"):
self.update_env("NGINX_CACHE_PATH", f"/var/cache/nginx/{self.app_name}")
def setup_static(self):
"""Configure static files."""
# Use BaseProxy's get_static_paths() helper
static_paths = self.get_static_paths()
# ... generate location blocks
def extra_setup(self):
"""Additional Nginx-specific setup."""
pass
def generate_config(self):
"""Generate Nginx config file."""
template = self._load_template("nginx.conf.j2")
config = template.render(env=self.env, app=self.app)
config_path = Path(f"/etc/nginx/sites-available/{self.app_name}.conf")
config_path.write_text(config)
def check_config(self):
"""Validate Nginx config."""
subprocess.run(["nginx", "-t"], check=True)
def reload_proxy(self):
"""Reload Nginx."""
subprocess.run(["systemctl", "reload", "nginx"], check=True)
6. OS Implementations¶
OS implementations handle operating system-specific configuration.
Protocol: OS (from hop3.core.protocols)
Hook: get_os_implementations()
Location: hop3/plugins/oses/
Required attributes:
- name (str): OS identifier (e.g., "debian12", "ubuntu2204")
- display_name (str): Human-readable name
- packages (list[str]): Required system packages
Required methods:
- detect() -> bool: Check if this OS matches the current system
- setup_server() -> None: Install dependencies and configure system
- ensure_packages(packages, update=True) -> None: Install system packages
- ensure_user(user, home, shell, group) -> None: Create system user
Example (simplified Debian family support):
from pathlib import Path
from hop3.core.protocols import OS
class DebianFamilyOS:
"""Support for Debian-based distributions."""
name = "debian"
display_name = "Debian Family (Debian, Ubuntu, etc.)"
packages = [
"python3", "python3-pip", "python3-venv",
"nginx", "git", "build-essential"
]
def detect(self) -> bool:
"""Check if this is a Debian-based OS."""
os_release = Path("/etc/os-release").read_text()
return "debian" in os_release.lower() or "ubuntu" in os_release.lower()
def setup_server(self):
"""Setup server for hop3."""
# Update package lists
subprocess.run(["apt-get", "update"], check=True)
# Create hop3 user
self.ensure_user("hop3", "/home/hop3", "/bin/bash", "hop3")
# Install required packages
self.ensure_packages(self.packages)
def ensure_packages(self, packages, update=True):
"""Install packages with apt."""
if update:
subprocess.run(["apt-get", "update"])
subprocess.run(
["apt-get", "install", "-y"] + packages,
check=True
)
def ensure_user(self, user, home, shell, group):
"""Create user if not exists."""
try:
subprocess.run(["id", user], check=True, capture_output=True)
except subprocess.CalledProcessError:
# User doesn't exist, create it
subprocess.run([
"useradd", "-m",
"-d", home,
"-s", shell,
"-U", user # Create group with same name
], check=True)
Registering Plugins¶
Internal Plugins (in hop3.plugins)¶
Internal plugins are automatically discovered by scanning the hop3.plugins package.
Plugin structure:
hop3/plugins/
├── my_plugin/
│ ├── __init__.py # Must import plugin
│ ├── plugin.py # Plugin class with hooks
│ ├── builder.py # Strategy implementations
│ └── deployer.py
Plugin class (plugin.py):
from hop3.core.hooks import hookimpl
from .toolchain import MyLanguageToolchain
from .deployer import MyDeployer
class MyPlugin:
"""My custom plugin."""
name = "my_plugin"
@hookimpl
def get_language_toolchains(self) -> list:
"""Return language toolchain classes."""
return [MyLanguageToolchain]
@hookimpl
def get_deployers(self) -> list:
"""Return deployer classes."""
return [MyDeployer]
# Auto-register when module is imported
plugin = MyPlugin()
Available hooks:
- get_builders() - Return Builder classes
- get_language_toolchains() - Return LanguageToolchain classes
- get_deployers() - Return Deployer classes
- get_addons() - Return Addon classes
- get_proxies() - Return Proxy classes
- get_os_implementations() - Return OS classes
- get_di_providers() - Return Dishka Provider instances
- cli_commands() - Register CLI commands
Important: The __init__.py must import the plugin:
External Plugins (via setuptools entry points)¶
External plugins are distributed as separate Python packages and registered via setuptools entry points.
Entry point in setup.py or pyproject.toml:
Or in setup.py:
setup(
name="my-hop3-plugin",
entry_points={
"hop3.plugins": [
"my_plugin = my_hop3_plugin.plugin:plugin"
]
}
)
Package structure:
my-hop3-plugin/
├── pyproject.toml
├── src/
│ └── my_hop3_plugin/
│ ├── __init__.py
│ ├── plugin.py
│ ├── builder.py
│ └── deployer.py
Plugin class:
# src/my_hop3_plugin/plugin.py
from hop3.core.hooks import hookimpl
from .builder import MyBuilder
class MyExternalPlugin:
name = "my_external_plugin"
@hookimpl
def get_builders(self) -> list:
return [MyBuilder]
plugin = MyExternalPlugin()
Testing Plugins¶
Unit Testing¶
Test your strategy classes in isolation:
# tests/test_my_builder.py
from pathlib import Path
from hop3.core.protocols import DeploymentContext
from my_hop3_plugin.builder import MyBuilder
def test_accept():
"""Test that builder accepts correct apps."""
context = DeploymentContext(
app_name="test",
source_path=Path("/tmp/test"),
app_config={}
)
builder = MyBuilder(context)
assert builder.accept() is True
def test_build():
"""Test build process."""
# ... create test fixtures
builder = MyBuilder(context)
artifact = builder.build()
assert artifact.kind == "my-artifact-type"
assert Path(artifact.location).exists()
Integration Testing¶
These tests cover plugin registration and discovery:
# tests/test_plugin_registration.py
from hop3.core.plugins import get_plugin_manager
def test_plugin_registered():
"""Test that plugin is discovered."""
pm = get_plugin_manager()
strategies = pm.hook.get_builders()
strategy_classes = [cls for sublist in strategies for cls in sublist]
names = [getattr(cls, "name", None) for cls in strategy_classes]
assert "my_builder" in names
System Testing¶
Test the full deployment pipeline with your plugin:
# tests/test_deployment.py
def test_deploy_with_my_plugin(tmp_path):
"""Test full deployment using my plugin."""
# Create test app
app = App(name="test", runtime="my-runtime")
# Deploy app
do_deploy(app)
# Verify deployment
assert app.is_running
Best Practices¶
1. Strategy Naming¶
- Use lowercase names:
"python", not"Python" - Be specific: prefer
"docker-compose"over the generic"docker" - Avoid conflicts with existing strategies
2. Error Handling¶
- Raise
Abortfromhop3.libfor user-facing errors - Use
log()fromhop3.libfor informational messages - Provide helpful error messages with available options
from hop3.lib import Abort, log
def deploy(self, deltas=None):
try:
# ... deployment logic
log("Deployment successful", fg="green")
except FileNotFoundError:
msg = "Docker not found. Please install Docker first."
raise Abort(msg)
3. Idempotency¶
Make operations safe to run multiple times:
def create(self):
"""Create service (idempotent)."""
if self._already_exists():
log(f"Service {self.addon_name} already exists", fg="yellow")
return
# ... create service
4. Resource Cleanup¶
Always clean up resources in destroy() methods:
def destroy(self):
"""Clean up all service resources."""
# Stop service
self.stop()
# Remove config files
self._remove_config()
# Remove data (if appropriate)
if self.env.get("REMOVE_DATA"):
self._remove_data()
5. Configuration¶
Use environment variables for configuration:
def accept(self) -> bool:
"""Check if builder is enabled."""
# Allow disabling builder via env var
if self.context.app_config.get("DISABLE_MY_BUILDER"):
return False
return self._detect_app_type()
6. Documentation¶
Document your strategies with clear docstrings:
class MyBuilder:
"""Build strategy for MyFramework applications.
This builder supports MyFramework 2.0+ applications with
either requirements.txt or Pipfile dependencies.
Environment Variables:
MY_BUILDER_VERSION: Specify builder version (default: latest)
MY_BUILDER_CACHE: Enable build caching (default: true)
Example hop3.toml:
[build]
builder = "my-builder"
[build.env]
MY_BUILDER_VERSION = "2.1"
"""
Common Pitfalls¶
1. Forgetting to Register Plugin Instance¶
Wrong:
# plugin.py
class MyPlugin:
@hookimpl
def get_builders(self):
return [MyBuilder]
# Missing: plugin = MyPlugin()
Correct:
class MyPlugin:
@hookimpl
def get_builders(self):
return [MyBuilder]
# Must create instance for auto-registration
plugin = MyPlugin()
2. Not Importing Plugin in __init__.py¶
Wrong:
Correct:
3. Returning Strategy Instances Instead of Classes¶
Wrong:
Correct:
4. Hardcoding Paths¶
Wrong:
Correct:
def build(self):
from hop3.config import HopConfig
cfg = HopConfig.get_instance()
venv_path = cfg.APP_ROOT / self.context.app_name / "venv"
5. Not Checking check_status() Properly¶
Wrong:
def check_status(self) -> bool:
# Just check if config exists (unreliable)
return Path(f"/etc/systemd/system/{self.app}.service").exists()
Correct:
def check_status(self) -> bool:
# Actually verify process is running
result = subprocess.run(
["systemctl", "is-active", f"{self.app}.service"],
capture_output=True
)
return result.returncode == 0
Next Steps¶
- See Protocol Reference for detailed protocol specifications
- See Hook Specifications for complete hook documentation
- See External Plugin Guide for publishing plugins
- Check
hop3/plugins/for working examples
Getting Help¶
- Review existing plugins in
hop3/plugins/ - Check hook specifications in
hop3/core/hookspecs.py - Check protocol definitions in
hop3/core/protocols.py - Ask questions in the project discussions
- Read the pluggy documentation: https://pluggy.readthedocs.io/