Building a Provisioner Plugin
Create a custom provisioner by subclassing BaseDeployer and dropping a Python file into .strata/provisioners/. strata auto-discovers all BaseDeployer subclasses in that directory and makes them available as provisioner types in workspace YAML.
Quick Start
1. Create the plugin file:
.strata/
└── provisioners/
└── my_provisioner.py # auto-discovered on startup
2. Implement BaseDeployer:
# .strata/provisioners/my_provisioner.py
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from strata.deployers.base_deployer import (
STEP_APPLY, STEP_CHECK, STEP_DESTROY, STEP_OUTPUT,
STEP_PLAN, STEP_PLAN_DESTROY, STEP_SETUP, STEP_SHOW_PLAN,
BaseDeployer,
)
class MyProvisioner(BaseDeployer):
def get_deployer_name(self) -> str:
return "my-tool" # must match provisioner: in workspace YAML
def get_supported_steps(self) -> List[str]:
return [STEP_SETUP, STEP_CHECK, STEP_PLAN, STEP_APPLY,
STEP_DESTROY, STEP_PLAN_DESTROY, STEP_SHOW_PLAN, STEP_OUTPUT]
def validate_workspace(self) -> Tuple[bool, List[str]]:
return True, []
def validate_environment(self) -> Tuple[bool, List[str]]:
result = subprocess.run(["my-tool", "--version"], capture_output=True)
if result.returncode != 0:
return False, ["my-tool CLI not found on PATH"]
return True, []
def setup(self) -> Tuple[bool, List[str]]:
return True, ["Setup complete"]
def check(self) -> Tuple[bool, List[str]]:
return True, []
def plan(self) -> Tuple[bool, List[str]]:
return True, ["No changes"]
def apply(self) -> Tuple[bool, List[str]]:
return True, [f"✓ Stage '{self.stage.name}' applied."]
def destroy(self) -> Tuple[bool, List[str]]:
return True, []
def plan_destroy(self) -> Tuple[bool, List[str]]:
return True, []
def show_plan(self) -> Tuple[bool, Dict[str, Any], List[str]]:
return True, {}, []
def output(self) -> Tuple[bool, Dict[str, Any], List[str]]:
return True, {}, []
3. Reference the plugin in workspace YAML:
# workspace.yaml
spec:
provisioners:
- name: infra
provisioner: my-tool # matches get_deployer_name()
source:
repository: my-repo
source_path: infra/
4. Use it in a deployment:
# deploy/deploy-prd.yaml
spec:
stages:
- name: provision
provisioner: infra
That’s it. strata build run and strata deploy run will invoke your plugin.
Discovery Mechanism
DeployerFactory.load_plugins(work_path) is called once at startup. It:
Scans
.strata/provisioners/*.pyfor Python filesImports each module with
importlibRegisters all
BaseDeployersubclasses found via__subclasses__()recursion
Any class that is a concrete subclass of BaseDeployer (does not have unimplemented abstract methods) is auto-registered under the name returned by get_deployer_name().
Plugin names are case-sensitive and must be unique. Built-in names (terraform, ansible, helm, compose, script) cannot be overridden by plugins.
The BaseDeployer Contract
Your class must implement these abstract methods:
Method |
Signature |
Purpose |
|---|---|---|
|
|
Return the provisioner type name (e.g. |
|
|
Return the step names your deployer handles |
|
|
Check build artifacts exist |
|
|
Check external tool availability |
|
|
Initialise (e.g. |
|
|
Validate config (e.g. |
|
|
Preview changes (e.g. |
|
|
Apply changes (e.g. |
|
|
Remove resources |
|
|
Preview what destroy would remove |
|
|
Return structured plan data |
|
|
Return infrastructure outputs |
Optional overrides
Method |
Default behaviour |
Override when |
|---|---|---|
|
Returns empty dict + “not implemented” message |
Your tool can report live state |
|
Returns empty dict + “not implemented” message |
Your tool supports health checks |
|
Returns |
Your tool produces named outputs to pass to subsequent stages |
|
Returns |
Your tool can surface plan context in dry-run mode |
|
No-op |
Your tool produces a structured plan file |
Constructor and Instance Variables
The base constructor is called automatically via super().__init__(...). Your subclass receives these instance variables:
Variable |
Type |
Description |
|---|---|---|
|
|
The deployment stage configuration |
|
|
Access to the deployment model |
|
|
Access to the configuration model |
|
|
Root of the build artifacts directory |
|
|
Workspace root |
|
|
Verbose flag from CLI |
|
|
Force flag from CLI ( |
|
|
Resolved secrets and variables for this stage |
|
|
Structured logger bound to your module |
Accessing secrets and variables
self.resolved_values is populated from the deployment’s secrets: and variables: blocks, filtered to only the secrets declared for this stage.
def _build_env(self) -> dict:
import os
env = os.environ.copy()
if self.resolved_values is not None:
for key, value in self.resolved_values.env_vars.items():
env[key] = str(value)
return env
Accessing the workspace model
workspace_service = self.deployment_service.get_workspace_service()
if workspace_service and workspace_service.model:
provisioners = workspace_service.model.spec.provisioners or []
iac = next((p for p in provisioners if p.name == self.stage.provisioner), None)
Step Name Constants
Import step name constants from base_deployer to avoid magic strings:
from strata.deployers.base_deployer import (
STEP_SETUP, # "setup"
STEP_CHECK, # "check"
STEP_PLAN, # "plan"
STEP_APPLY, # "apply"
STEP_DESTROY, # "destroy"
STEP_PLAN_DESTROY, # "plan_destroy"
STEP_SHOW_PLAN, # "show_plan"
STEP_OUTPUT, # "output"
STEP_STATUS, # "status"
STEP_HEALTH, # "health"
)
Return Value Conventions
Every step returns a tuple. Follow these conventions:
# Success with messages
return True, ["✓ Applied successfully", f"3 resources created"]
# Failure with diagnostic messages
return False, ["Command failed: exit code 1", "Error: permission denied"]
# Methods returning (success, data, messages)
return True, {"key": "value"}, ["Collected 2 outputs"]
return False, {}, ["Could not retrieve outputs: connection refused"]
Return
(False, messages)— never raise exceptions — for recoverable failuresReturn
(True, [])for no-op steps your tool does not supportInclude the stage name in error messages for clarity:
f"Stage '{self.stage.name}': ..."
Timeout Helpers
Use self._get_timeout(step, default) to respect per-stage timeout overrides:
def apply(self) -> Tuple[bool, List[str]]:
timeout = self._get_timeout(STEP_APPLY, 1800) # 30min default
return self._run(["my-tool", "apply"], timeout=timeout)
Timeouts are configured in the deployment YAML under spec.stages[].timeouts.
Testing Your Plugin
Use click.testing.CliRunner to test end-to-end, or test the deployer class directly by mocking subprocess calls.
from unittest.mock import patch, MagicMock
from pathlib import Path
from strata.deployers.factory import DeployerFactory
# Load the plugin file (simulate auto-discovery)
import importlib.util, sys
spec = importlib.util.spec_from_file_location("my_provisioner", ".strata/provisioners/my_provisioner.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
# Verify registration
assert DeployerFactory.is_known_type("my-tool")
# Create an instance
stage = MagicMock()
stage.name = "infra"
stage.provisioner = "infra"
deployer = DeployerFactory.create(
"my-tool",
stage=stage,
deployment_service=MagicMock(),
configuration_service=MagicMock(),
build_path=Path("/tmp/build"),
work_path=Path("/tmp/workspace"),
)
# Test a step
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
ok, msgs = deployer.apply()
assert ok
Complete Examples
Ready-to-use examples are in docs/examples/provisioners/:
pulumi_provisioner.py— Pulumi IaC pluginargocd_provisioner.py— ArgoCD GitOps plugin
Checklist
Before shipping a provisioner plugin:
[ ]
get_deployer_name()returns a unique, lowercase, hyphen-separated name[ ]
validate_environment()checks the CLI binary is available on PATH[ ]
validate_workspace()checks that build artifacts exist before any step runs[ ] All 8 abstract methods are implemented (even if some are no-ops)
[ ] Steps return
(False, messages)on failure, never raise exceptions[ ] Subprocess calls use
self._build_env()to propagate resolved secrets[ ] Timeouts are honoured via
self._get_timeout(step, default)[ ] A unit test exists that mocks subprocess calls