Skip to content

    Plan structure

    A plan is an ordered list of steps, each targeting hosts and running tools. Here are the parts, and the rules for variables, conditions and failure.

    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.

    a two-step plan
    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.reload

    Steps 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:

    1. Environment values, belonging to the environment each host is in
    2. Plan variables, defined on the plan
    3. 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:

    ReferenceResolves 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:

    capture and branch
    - 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:

    SettingEffect
    continue_on_errorThe item may fail without failing the run. It is still recorded as failed.
    retriesExtra attempts after the first.
    retry_delay_secondsPause 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#