A plan is an ordered list of steps. Each step names the hosts it targets and the tools to run against them. Running a plan executes its steps in order, and for each step, its work on every target.
name: Patch nginx
steps:
- name: Back up the current config
targets: [production]
tool: file.backup
args:
remote_path: /etc/nginx/nginx.conf
backup_path: /etc/nginx/nginx.conf.bak
- name: Reload
targets: [production]
tool: nginx.reloadSteps and targets#
Every step carries its own target list, so a plan can touch different hosts at different stages: back up on one host, deploy on three, verify on a load balancer.
A target is an environment name, a server name, or a {{ vars.x }} reference resolved
when the run starts. A step runs against each of its targets in turn.
Variables#
Variables come from three places, and the later ones win:
- Environment values, belonging to the environment each host is in
- Plan variables, defined on the plan
- Run overrides, supplied when the run is started
Reference a variable as {{ vars.name }}. Three narrower namespaces exist when you need
to be specific about where a value came from:
| Reference | Resolves to |
|---|---|
{{ vars.x }} | The merged value, run override over plan over environment |
{{ env.x }} | This host's environment values only |
{{ inputs.x }} | Run overrides only |
{{ outputs.x }} | A value captured from an earlier step |
{{ facts.x }} | What Opafra observed about this host, such as the OS family |
A variable that never resolves is empty at run time. A dry run lists every unresolved reference it found, which is the cheapest way to catch one.
Typed inputs#
A plan can declare the inputs it expects, each with a type, whether it is required, a default, and optionally a fixed set of choices. Whoever starts a run is then prompted for them rather than having to know which variable names matter.
An input marked secret is treated as one: it is not echoed back and it is redacted in previews and logs.
Plans without declared inputs still accept free-form run variables, so this is an addition rather than a requirement.
Capturing output#
A step can store its output into a variable that later steps read:
- name: Read the running version
targets: [web-01]
tool: ssh.cmd
args:
command: nginx -v 2>&1
capture:
var: nginx_version
mode: regex
pattern: "nginx/([0-9.]+)"Four modes: raw keeps stdout as text, json parses it and can drill in with a dotted
path, lines splits into an array, and regex extracts the first capture group.
A capture that fails to parse is a warning, not a failure. The variable is left undefined and the run continues, because a plan should not stop because a version string was formatted unexpectedly.
Conditions#
Any step or item can carry a condition, and runs only when it evaluates true. An empty condition always runs.
condition: "{{ facts.os_family }} == 'debian' && {{ outputs.needs_restart }} == true"The supported grammar is deliberately small: &&, ||, the comparisons == != >
>= < <=, and contains for substring or array membership, over literals and
{{ }} references, with parentheses.
A malformed condition fails open: the step runs, and a warning is logged. That is the deliberate choice of the two available failures. A broken condition that silently skips a step means production automation quietly stops happening and nobody notices, which is worse than a step running when it should not have.
When something fails#
By default a run halts at the first failed step. Later steps do not execute, and the run is recorded as failed. The second half of a plan usually assumes the first half worked.
Three per-item settings change that, and they are properties of the plan rather than decisions made at run time:
| Setting | Effect |
|---|---|
continue_on_error | The item may fail without failing the run. It is still recorded as failed. |
retries | Extra attempts after the first. |
retry_delay_seconds | Pause between attempts. |
An item can also carry its own timeout_seconds, and a plan can carry a whole-run
wall-clock cap.
Revisions#
Every edit to a plan increments its revision. The number matters at the approval gate: the revision is pinned when approval is requested and compared at the moment of decision, so a plan edited between the two is surfaced rather than quietly approved.
Plans from git#
A plan can be owned by a git repository instead of the builder. A managed plan is read-only in the UI: edits go through the repository and arrive on the next sync, so the repository stays the single source of truth rather than one of two.
Next steps#
- Commands and scripts for dropping out of tools, and what it costs
- Tool catalogue for what a step can call