ADR-072: Infrastructure as playbooks
Status: Accepted
Date: 2026-04-23
Related: ADR-067 (playbooks unified primitive), ADR-068 (playbook runtime — §14/§15 extend the Phase 4 executor roster with type: ansible), ADR-069 (playbook security), ADR-070 (playbook UI contract), ADR-071 (product name — Weft), ADR-064 (gyrum-catalog + hex arch), ADR-060 (catalog-driven infrastructure), ADR-053 (frontend observability), ADR-020 (shared infra vs per-product isolation)
Decision (one paragraph)
Every infrastructure mutation Gyrum performs — DNS records, Cloudflare
Tunnel routes, Cloudflare Access applications and policies, Caddy site
configuration, systemd unit state, and every future sibling surface — is
expressed as an idempotent, dry-runnable Weft playbook. Deploys
compose these sub-playbooks via type: subplaybook steps (ADR-068 §4).
Rollback is "re-run the previous-version playbook". The infrastructure
source of truth is the committed YAML in
dark-factory/playbooks/infra/ plus the playbook_run_events audit
stream (ADR-068 §5); runs reconcile actual state to desired. The
catalogue ships in phases: a monolithic deploy-service playbook today,
an eight-playbook sub-catalogue once the subplaybook and http
executors land (ADR-068 Phase 4), and per-playbook scoped API tokens
plus destructive-step approvals once security hardens (ADR-069 Phase
5). A fleet-wide drift reconciler is named as a future capability, not
a current commitment.
Context
Weft is a dark factory. The whole point of the loom metaphor (ADR-071
§2) is that a pattern encoded in punch cards weaves products without a
human on the floor — and today, when we deploy a new product or move an
existing one, a human leaves the loom and goes into the Cloudflare
dashboard to click through a DNS record, a Tunnel route, an Access
application, and a Caddy reload. That is not a dark factory. That is
an operator with a browser tab open and a shell history full of
curl one-liners.
Concretely, deploying a new service today means touching at least five surfaces, each with its own interface, its own idempotency story (or lack of one), and its own rollback story (or lack of one):
- DNS. A new
AorCNAMErecord on the Cloudflare-hosted zone. Clicked in the UI. Sometimescurl-ed against the Cloudflare API from a laptop. No record of which record was created when, by whom, or at whose request. - Cloudflare Tunnel. A route added to the
cloudflaredtunnel that connects the public internet to the origin host behind NAT. Clicked in the Zero Trust UI. The mapping from hostname to origin service is in CF's database, not ours. - Cloudflare Access. An application created to gate the hostname, policies attached to allow specific email addresses or identity groups. Clicked in the Zero Trust UI. The membership of an Access app drifts silently when a user's email changes.
- Caddy. The origin reverse proxy. A new site block added to the
Caddyfile,caddy reloadssh'd onto the host. The Caddyfile is in git (indark-factory/infra/caddy/) but the reload step is tribal and the state on disk can diverge from the state in git without anyone noticing. - systemd. For services not in Docker, a unit file installed and
enabled,
systemctl daemon-reload && systemctl restart. Again, the unit file can be in git; the "is it running?" state can only be observed by ssh'ing and asking.
Each of those surfaces has at least one of four failure modes:
- Not auditable. A Cloudflare UI click does not appear in any log the engineering team reads. Six months later, when a DNS record has the wrong origin, there is no answer to "who changed this and why?".
- Not reproducible. The procedure lives in somebody's head, or in a Slack thread, or in a PR description that has since been squash-merged and lost its context. A new operator cannot re-run the steps without tribal knowledge.
- Not rollback-able. Clicking "Create record" in Cloudflare does not create a reverse "Delete record" script. Rollback is "clicking backward", which is error-prone and uneven across surfaces.
- Not composable. A deploy needs DNS + Tunnel + Access + Caddy, but there is no primitive for "run all four in order, halt on the first failure, and give me a single audit trail of the combined run". We get four audit trails (at best) and coordinate them by eye.
As Weft grows toward its product ambition (ADR-071 §2.1 — "finished fabric" being the SaaS products the platform produces), the number of services on the fleet goes up and to the right. Each service wants its own DNS record, its own Tunnel route, its own Access policy, its own Caddy site. Today that is two products. In twelve months it will be twenty. The manual click-path does not scale linearly, let alone to twenty. It fails worse than linearly because the probability of a human forgetting a step or misconfiguring a field per deploy is roughly constant, so the expected number of broken deploys per week scales with the deploy rate.
Meanwhile, ADR-067 landed the playbook as the unified primitive for operational procedures, ADR-068 built the runtime that executes them, ADR-069 locked down the security model, and ADR-070 fixed the UI contract. Weft already has a way to express a repeatable, audited, interactive procedure. Every infra operation fits the playbook shape: trigger, steps, outcome, retro/gaps (ADR-067 §3). The primitive exists; we are currently refusing to use it for the most valuable class of procedure.
This ADR closes that gap by committing to a pattern: infra is
playbooks, and infra is playbooks all the way down. A deploy is a
playbook that calls sub-playbooks. A DNS change is a sub-playbook. A
Tunnel route is a sub-playbook. Each sub-playbook is idempotent,
dry-runnable, and scoped to the minimum privilege it needs. The YAML
in dark-factory/playbooks/infra/ is the source of truth; the
Cloudflare UI, when it disagrees, is wrong.
Why not Terraform / Pulumi?
This is the obvious question and the right one to answer early. See §10 for the full argument; the short version is that Terraform and Pulumi solve a strict superset of what we need and introduce a second tool, a second state file, a second DSL, and a second audit trail. We already ship a runtime (ADR-068), a security model (ADR-069), a UI (ADR-070), and a catalogue of playbooks. Duplicating those in HCL-shaped containers is a paying-for-what-we-already-have move. If at Phase 6+ we discover that managing hundreds of infra resources in YAML feels worse than HCL, we revisit — the escape hatch exists. It does not exist now.
Why now?
Three converging pressures:
deploy-serviceis landing this cycle (see the sample playbooks referenced in ADR-068 §13 —hello-world.yaml,goodbye.yaml, anddeploy-service.yamlas a parallel follow-up). Ifdeploy-serviceships as a bespoke shell script today, the decomposition cost later is a rewrite. If it ships as a playbook that inlines the four-to-five infra steps today, the decomposition later is a refactor intosubplaybookcalls. Framing matters; we name the pattern now so the first deploy playbook is shaped for decomposition.- ADR-068 Phase 4 is on the runway. The
subplaybookandhttpexecutors are the two primitives that make the sub-playbook catalogue in §5 executable. Phase 4 is "ongoing" per ADR-068 §14; the sub-playbook PRs are the natural work items that fill Phase 4. - ADR-069's scoped-token pattern is implementable. ADR-069 §3
already gated
target: localbehind an allow-list and named the secret vault. Per-playbook scoped API tokens are the natural extension of that pattern to external APIs (Cloudflare, specifically). Committing the pattern now gives ADR-069 Phase 5 a concrete work item.
Decision
1. Summary of gating choices (committed, not re-litigated)
Six decisions are settled. The table is the quick index.
| # | Choice | Rejected alternatives | Gated by |
|---|---|---|---|
| 1 | Every infra mutation is a playbook | Terraform, Pulumi, Ansible-for-everything, CF UI forever, k8s controllers | §10 |
| 2 | Idempotency is a hard requirement | at-most-once shell scripts | §2.1 |
| 3 | Dry-run mode is a hard requirement | YOLO runs, "test in staging first" | §2.2 |
| 4 | Secrets via ${env.X} + vault references |
inline tokens, .env files committed, operator-pasted-in-Slack |
§2.3, ADR-069 §4 |
| 5 | Per-playbook scoped API tokens | one global CF token | §2.4, ADR-069 §3 |
| 6 | Catalogue lives at dark-factory/playbooks/infra/ |
per-repo infra playbooks, a new gyrum-infra repo |
§4 |
All six enforcements are additive to ADR-068's runtime and ADR-069's
security model. No new StepExecutor kind is introduced; infra
playbooks compose type: http, type: ssh, type: shell, type: approval, and type: subplaybook.
2. The pattern — four non-negotiables
The pattern is four rules. A playbook under playbooks/infra/ that
violates any of the four is rejected at review time; a future CI
check (§9) rejects them at parse time. These rules are
non-negotiable in the sense that loosening them would let the first
bad playbook corrupt the audit trail or the state reconciler that
depends on them.
2.1 Idempotency — read current state, compute delta, apply only if changed
Every infra sub-playbook has three phases, every time:
- Read. Call the underlying API (
GET /zones/{id}/dns_records,GET /accounts/{id}/tunnels/{tunnel_id}/configurations,ssh host cat /etc/caddy/Caddyfile) to establish what is. - Compute delta. Compare the desired state (derived from the
playbook's
inputs:) with the read result. Produce a structureddeltaobject:{action: none|create|update|delete, current: …, desired: …, diff: …}. - Apply — conditionally. If
delta.action == none, emit astep_completedevent withoutputs: {changed: false, delta: …}and return. Otherwise apply the change via the underlying API and emitstep_completedwithoutputs: {changed: true, delta: …, response: …}.
The invariant: re-running the same playbook with the same inputs
against a converged system produces changed: false. A playbook
that returns changed: true on the second run is buggy and is a CI
failure (Phase 4+ when the reconciler can run playbook pairs).
This is the classical configuration-management idempotency property (Ansible, Chef, Puppet all enforce it). We enforce it structurally by making the three phases the shape of every infra sub-playbook's body, not by trusting authors to remember.
2.2 Dry-run mode — show the delta, apply nothing
Every infra sub-playbook accepts a dry_run: bool input (default
false). When dry_run: true:
- Read phase runs as normal.
- Compute-delta phase runs as normal.
- Apply phase is skipped entirely. The step emits
step_completedwithoutputs: {changed: null, dry_run: true, delta: …}.null(notfalse) signals "we don't know whether the apply would have succeeded, only that we would have attempted it".
Why the tri-state (changed: true | false | null):
falsemeans the playbook ran and the system was already converged. Safe to re-run.truemeans the playbook ran and mutated the system. Audit trail matters.nullmeans the playbook ran in dry-run mode. The delta is real but no mutation happened.
Dry-run is required before the first real run of any destructive playbook. See §6 for the destructive-step pairing.
deploy-service threads dry-run through: passing dry_run: true to
the top-level deploy runs every sub-playbook in dry-run mode and
produces a combined "what would change" report. The operator reads
it, confirms, and re-runs with dry_run: false. ADR-070 §1's
<PlaybookRunner> surfaces the dry-run toggle as a checkbox on the
run-launch form.
2.3 Secrets via ${env.X} + vault references — never inline
No infra playbook ever contains a literal secret value. The authoring rule is absolute:
${env.CF_API_TOKEN}— the runtime resolvesenv.Xagainst the process environment of the worker, not the playbook author's environment. The author declares the variable name; the operator/SRE provisions the value out-of-band.${vault.cf_api_token_rw}— the runtime resolvesvault.Xagainst the ADR-069 secret vault by id. The vault id is referenceable; the value is not streamable into theplaybook_run_eventslog (ADR-069 §4.3).- Never inline. A literal
CF_API_TOKEN: "abc123"in YAML fails parse-time validation. The validator rejects any field whose resolved value is a non-redacted high-entropy string matching a known secret pattern (CF API tokens are 40 chars base64; GitHub tokens beginghp_/gho_/ etc).
The vault-vs-env choice per playbook:
${env.X}is appropriate for bootstrap secrets — the CF token the runtime itself uses to operate, provisioned by the operator at runtime startup.${vault.X}is appropriate for customer secrets — secrets a deployed service consumes, which the runtime never needs to read but anhttpstep needs to forward as a header.
Both resolutions happen at step-execution time, inside the executor,
after authorisation (ADR-069 §2). Neither secret form appears in
playbook_run_events.body — the event body stores the string
${env.CF_API_TOKEN} verbatim, not the resolved value.
The terminal-pane scrub (ADR-068 §4 prompt_secret / ADR-069 §4.3)
extends here: any stdout line that contains the resolved value gets
••••••••-substituted before the stdout event is emitted.
Best-effort, reverse-lookup, per ADR-069 §4.3.
2.4 Scoped blast radius — minimum-privilege token per playbook
Every infra sub-playbook declares its required API-token scope in its front-matter. The declaration is audited at parse time and at run time.
# cloudflare-dns-upsert.yaml — front-matter excerpt
---
kind: service_runbook
id: cloudflare-dns-upsert
title: "Cloudflare: upsert a single DNS record"
persona: sre
owner: "@jon"
allow_local_execution: false # no local bash
required_scopes: # ADR-072 addition
- vendor: cloudflare
scope: "Zone:DNS:Edit"
zone_filter: "${inputs.zone_id}"
secret_refs:
- env: CF_API_TOKEN_ZONE_DNS_EDIT # the token matching the required scope
---
Rules:
required_scopes:lists the minimum vendor-level API-token scopes the playbook needs. Cloudflare's scope taxonomy is used verbatim (Zone:DNS:Edit,Account:Cloudflare Tunnel:Edit,Account:Access: Apps and Policies:Edit, etc.).secret_refs:names the environment variable or vault id that holds a token matching those scopes. The operator, at token provisioning time, mints a token with exactly those scopes and installs it under that name. A token with more scopes fails a Phase 5 scope-verification check; a token with fewer scopes fails at first API call.- One token per playbook, not one token per runtime. The
runtime does not carry a single global
CF_API_TOKENthat every playbook uses. Each sub-playbook references its own scope-specific token. Compromise of a Caddy playbook's token does not compromise the DNS playbook's token. - A top-level orchestrator (e.g.
deploy-service) declares the union of its sub-playbooks'required_scopes:. This is computed at parse time, not author-maintained, by walking thesubplaybook:references.
The scope declaration is enforceable because the vendor (Cloudflare)
publishes the scope taxonomy as a machine-readable list; Phase 5's
scope-verification step calls GET /user/tokens/verify and checks
the declared scope is a subset of the token's actual scope. See §7
for the phase gate.
3. The sub-playbook contract — shape in YAML
Every infra sub-playbook under playbooks/infra/ has the same
structural shape. Authors do not invent a new shape per playbook; the
shape is the contract. Deviations are code-review and CI failures.
---
kind: service_runbook
id: cloudflare-dns-upsert
title: "Cloudflare: upsert a single DNS record"
persona: sre
owner: "@jon"
status: active
trigger: "Called by deploy-service or invoked manually to add a hostname"
success_metric: "Record exists in CF with the desired content; changed-flag accurate"
tags: [infra, cloudflare, dns]
related:
- runbooks:cloudflare-dns-delete
- runbooks:cloudflare-tunnel-route-upsert
- adrs:072
created: 2026-04-23
last_reviewed: 2026-04-23
# ADR-069 authorisation
allowed_personas: [sre, owner]
allow_local_execution: false
# ADR-072 additions
required_scopes:
- vendor: cloudflare
scope: "Zone:DNS:Edit"
zone_filter: "${inputs.zone_id}"
secret_refs:
- env: CF_API_TOKEN_ZONE_DNS_EDIT
# ADR-068 runtime section
runtime:
inputs:
- name: zone_id
type: string
required: true
description: "Cloudflare zone id (the apex domain's id)"
- name: name
type: string
required: true
description: "Record name, e.g. 'app.example.com' or '@' for apex"
- name: type
type: enum
values: [A, AAAA, CNAME, TXT, MX]
required: true
- name: content
type: string
required: true
- name: proxied
type: bool
required: false
default: true
- name: dry_run
type: bool
required: false
default: false
steps:
- id: read
type: http
target: host:api.cloudflare.com
method: GET
url: "https://api.cloudflare.com/client/v4/zones/${inputs.zone_id}/dns_records?name=${inputs.name}&type=${inputs.type}"
headers:
Authorization: "Bearer ${env.CF_API_TOKEN_ZONE_DNS_EDIT}"
expect_status: [200]
save_as: current
- id: compute_delta
type: shell # a pure-data step; "shell" here is the ADR-068 §4 shell executor in its "local-deterministic-no-side-effects" mode
target: local
run: |
# parse ${current}, compare with desired (${inputs.*}), emit delta JSON on stdout
save_as: delta
- id: apply
type: http
target: host:api.cloudflare.com
when: "${delta.action != 'none' && !inputs.dry_run}"
method: POST # or PUT/PATCH depending on delta.action; expressions resolve this
url: "…"
headers:
Authorization: "Bearer ${env.CF_API_TOKEN_ZONE_DNS_EDIT}"
body: "${delta.desired}"
expect_status: [200, 201]
save_as: response
outputs:
changed: "${delta.action != 'none' && !inputs.dry_run}"
dry_run: "${inputs.dry_run}"
delta: "${delta}"
record_id: "${response.result.id || current.result[0].id}"
---
# cloudflare-dns-upsert
Idempotent upsert of a single Cloudflare DNS record. Reads the record,
computes a delta, applies only if changed. Passes through in dry-run
mode.
## Symptoms
…
## Diagnostics
…
## Mitigations
…
Points the shape commits to:
- Kind is
service_runbook. Infra sub-playbooks are runbooks for one specific piece of infrastructure. The body follows theservice_runbookH2 convention (Symptoms / Diagnostics / Mitigations per ADR-067 §3). - Three-phase step sequence —
read,compute_delta,apply— is the canonical idempotency shape from §2.1. Authors may add phases (e.g. apost_verifystep that re-reads and asserts the delta is nownone), but may not collapse the three. applyhaswhen: "${delta.action != 'none' && !inputs.dry_run}"— this is the idempotency + dry-run enforcement. A missingwhen:is a CI failure.outputs.changedis always set. Callers (deploy-servicein particular) read this to decide whether to continue, report drift, or short-circuit.
The body sections (Symptoms / Diagnostics / Mitigations) are author-written and describe what the playbook does and when to run it — they are for humans; the runtime does not read them.
4. Catalogue layout — where the playbooks live
dark-factory/
└── playbooks/
└── infra/
├── cloudflare-dns-upsert.yaml
├── cloudflare-dns-delete.yaml
├── cloudflare-tunnel-route-upsert.yaml
├── cloudflare-access-app-upsert.yaml
├── cloudflare-access-policy-upsert.yaml
├── caddy-site-upsert.yaml
├── caddy-site-delete.yaml
├── systemd-service-reload.yaml
├── deploy-service.yaml # orchestrator; calls the others
└── _index.yaml # auto-generated (ADR-067 §6)
Rules:
- One infra sub-playbook per file. Bundling (a single
cloudflare.yamlwith multiplekinds or multiple deploys inside) is a CI failure. One-file-one-playbook matches ADR-067 §4. - File name =
idfield.cloudflare-dns-upsert.yamlmust haveid: cloudflare-dns-upsert. CI enforces. dark-factory/playbooks/infra/is THE canonical location. Not per-service directories, not per-vendor subdirectories (noplaybooks/infra/cloudflare/), not a newgyrum-infrarepo. A flat directory surfaces the whole catalogue tolsand keeps the ADR-067 typed-reference resolver simple.- Cross-cutting goes through
dark-factory. An infra playbook that mutates a Cloudflare resource does not live inai-frontend/playbooks/infra/even ifai-frontendis the service that most often invokes it. Infra is a cross-cutting concern (ADR-067 §4);dark-factoryis the cross-cutting repo.
The _index.yaml is auto-generated per ADR-067 §6. Infra playbooks
appear alongside journey, experiment, and incident-response
playbooks in the combined fleet catalogue.
5. The initial sub-playbook catalogue
Eight sub-playbooks form the Phase 4 target. This is the committed catalogue — every one of the eight is a tracked follow-up PR that cites this ADR. Each row gives the id, the key inputs, the declared scope, the vendor API the playbook calls, and whether the playbook is destructive. Bodies are sketched in §5.x below; full YAML is explicitly out of scope for this ADR per the scope constraint at the top of this PR.
| # | id | Inputs | Scope | API | Destructive? |
|---|---|---|---|---|---|
| 1 | cloudflare-dns-upsert |
zone_id, name, type, content, proxied?, dry_run? |
Zone:DNS:Edit (scoped to zone) |
/zones/{id}/dns_records |
No (upsert is idempotent) |
| 2 | cloudflare-dns-delete |
zone_id, name, dry_run? |
Zone:DNS:Edit (scoped to zone) |
/zones/{id}/dns_records/{record_id} DELETE |
Yes — implicit approval step |
| 3 | cloudflare-tunnel-route-upsert |
tunnel_id, hostname, service_url, dry_run? |
Account:Cloudflare Tunnel:Edit |
/accounts/{id}/cfd_tunnel/{tunnel_id}/configurations |
No |
| 4 | cloudflare-access-app-upsert |
domain, name, allowed_emails[], policy_name, dry_run? |
Account:Access: Apps and Policies:Edit |
/accounts/{id}/access/apps |
No |
| 5 | cloudflare-access-policy-upsert |
app_id, action, include_rules, dry_run? |
Account:Access: Apps and Policies:Edit |
/accounts/{id}/access/apps/{app_id}/policies |
No |
| 6 | caddy-site-upsert |
host, hostname, backend_url, tls?, dry_run? |
ssh:ops-vps:sudo:caddy (see §5.6) |
ssh — Caddyfile edit + caddy reload |
No |
| 7 | caddy-site-delete |
host, hostname, dry_run? |
ssh:ops-vps:sudo:caddy |
ssh — Caddyfile edit + caddy reload |
Yes — implicit approval step |
| 8 | systemd-service-reload |
host, unit_name, mode?, dry_run? |
ssh:host:sudo:systemctl:<unit> |
ssh — systemctl <mode> <unit> |
Mode-dependent (restart/stop require approval) |
Each sub-playbook follows the §3 shape. The rest of this section sketches the front-matter + step outline per playbook. Full YAML bodies land in dedicated follow-up PRs (one per playbook, ≤ 800 lines each, citing ADR-072 in the PR description).
5.1 cloudflare-dns-upsert
---
id: cloudflare-dns-upsert
kind: service_runbook
required_scopes: [{vendor: cloudflare, scope: "Zone:DNS:Edit", zone_filter: "${inputs.zone_id}"}]
secret_refs: [{env: CF_API_TOKEN_ZONE_DNS_EDIT}]
---
runtime:
inputs: [zone_id, name, type, content, proxied?, dry_run?]
steps:
- id: read # GET /zones/{id}/dns_records?name=…&type=…
- id: compute_delta
- id: apply # POST (create) | PUT (update) | noop (none); when: delta.action != 'none' && !dry_run
outputs: {changed, dry_run, delta, record_id}
Upsert semantics: CNAME to A requires a delete-then-create, not
a PUT. The delta-compute step handles the type-change case by
producing an action: replace delta; the apply step then issues a
DELETE followed by a POST. All within the same step execution —
partial failure leaves the record in whatever state CF is in at
that moment, and the run fails; operator re-runs.
5.2 cloudflare-dns-delete
---
id: cloudflare-dns-delete
kind: service_runbook
required_scopes: [{vendor: cloudflare, scope: "Zone:DNS:Edit", zone_filter: "${inputs.zone_id}"}]
secret_refs: [{env: CF_API_TOKEN_ZONE_DNS_EDIT}]
# ADR-069 destructive annotation
destructive: true
---
runtime:
inputs: [zone_id, name, dry_run?]
steps:
- id: read # GET — find the record
- id: compute_delta
# implicit approval step injected by the runtime — ADR-069 §6
- id: apply # DELETE; when: delta.action == 'delete' && !dry_run
outputs: {changed, dry_run, delta, deleted_record_id}
Destructive flag triggers ADR-069 §6 implicit-approval behaviour:
the runtime prepends an approval step with the question "Delete
DNS record ${inputs.name} (type ${current.type}, content
${current.content}) in zone ${inputs.zone_id}? This is
irreversible." The operator must approve before apply runs.
Dry-run mode skips the approval (there is nothing to approve — no
mutation).
5.3 cloudflare-tunnel-route-upsert
---
id: cloudflare-tunnel-route-upsert
kind: service_runbook
required_scopes: [{vendor: cloudflare, scope: "Account:Cloudflare Tunnel:Edit"}]
secret_refs: [{env: CF_API_TOKEN_ACCOUNT_TUNNEL_EDIT}]
---
runtime:
inputs: [tunnel_id, hostname, service_url, dry_run?]
steps:
- id: read # GET /accounts/{id}/cfd_tunnel/{tunnel_id}/configurations
- id: compute_delta # tunnel configs are a single document with ingress list — delta is a list-patch
- id: apply # PUT the full config with the patched ingress list
outputs: {changed, dry_run, delta}
Tunnel configuration is a single document with an ingress list,
not a collection of routes. The read-modify-write is
whole-document; concurrent runs race. The apply step uses an
If-Match: ${current.etag} header (CF supports it) to fail-fast on
lost updates. A failed apply due to etag mismatch triggers an
on_error: fail with a specific error class
(ErrTunnelConfigConflict) so the operator can re-run.
5.4 cloudflare-access-app-upsert
---
id: cloudflare-access-app-upsert
kind: service_runbook
required_scopes: [{vendor: cloudflare, scope: "Account:Access: Apps and Policies:Edit"}]
secret_refs: [{env: CF_API_TOKEN_ACCOUNT_ACCESS_EDIT}]
---
runtime:
inputs: [domain, name, allowed_emails[], policy_name, dry_run?]
steps:
- id: read # GET /accounts/{id}/access/apps?domain=…
- id: compute_delta
- id: apply_app # POST | PUT app
- id: apply_policy # POST | PUT default policy; only if app changed or policy differs
outputs: {changed, dry_run, delta, app_id, policy_id}
Access apps and their policies are coupled — an app with no policy
is unreachable. This playbook atomically ensures both. allowed_emails
is the simplest policy shape (include by email); more complex rules
(group membership, service token, warp-identity) are
cloudflare-access-policy-upsert territory (§5.5).
5.5 cloudflare-access-policy-upsert
---
id: cloudflare-access-policy-upsert
kind: service_runbook
required_scopes: [{vendor: cloudflare, scope: "Account:Access: Apps and Policies:Edit"}]
secret_refs: [{env: CF_API_TOKEN_ACCOUNT_ACCESS_EDIT}]
---
runtime:
inputs: [app_id, action, include_rules, exclude_rules?, require_rules?, dry_run?]
steps:
- id: read # GET /accounts/{id}/access/apps/{app_id}/policies
- id: compute_delta
- id: apply # POST | PUT | DELETE a specific policy
outputs: {changed, dry_run, delta, policy_id}
Applies to one policy on one app. The composition in §5.4's
apply_policy step is a subplaybook: call into this one with the
minimum policy shape; bespoke policies bypass §5.4 and call
§5.5 directly.
5.6 caddy-site-upsert
---
id: caddy-site-upsert
kind: service_runbook
required_scopes: [{vendor: ssh, scope: "host:ops-vps:sudo:caddy"}]
---
runtime:
inputs: [host, hostname, backend_url, tls?, dry_run?]
steps:
- id: read # ssh host 'cat /etc/caddy/Caddyfile'
- id: compute_delta # parse Caddyfile, compute block-upsert
- id: apply # ssh host 'caddy-upsert.sh'; passes the patched Caddyfile atomically
- id: reload # ssh host 'sudo systemctl reload caddy'; skipped if no change
- id: verify # ssh host 'caddy validate --config /etc/caddy/Caddyfile'
outputs: {changed, dry_run, delta, caddyfile_hash}
Caddy is the first non-CF infra playbook. target: on the ssh
steps is host:ops-vps (resolves via gyrum-catalog per ADR-068 §4
ssh executor). The scope ssh:host:ops-vps:sudo:caddy is our
internal scope taxonomy, not a vendor one — it names the sudo
command pattern this playbook requires (a sudoers entry scoped to
/usr/bin/systemctl reload caddy and /usr/local/bin/caddy-upsert.sh
precisely, nothing else).
The caddy-upsert.sh script on the host is itself source-controlled
in dark-factory/infra/caddy/ and deployed by a separate
bootstrap-the-host playbook (out of scope for this ADR). The
playbook's contract with the script is: accept a Caddyfile on stdin,
validate it, write it atomically, print the new Caddyfile hash. The
playbook verifies that hash matches its own computation.
5.7 caddy-site-delete
---
id: caddy-site-delete
kind: service_runbook
destructive: true
required_scopes: [{vendor: ssh, scope: "host:ops-vps:sudo:caddy"}]
---
runtime:
inputs: [host, hostname, dry_run?]
steps:
- id: read
- id: compute_delta
# implicit approval step — ADR-069 §6
- id: apply # ssh — remove block, reload
- id: verify
outputs: {changed, dry_run, delta}
Destructive twin of §5.6. The approval prompt shows the block being removed, in full, before proceeding.
5.8 systemd-service-reload
---
id: systemd-service-reload
kind: service_runbook
required_scopes: [{vendor: ssh, scope: "host:${inputs.host}:sudo:systemctl:${inputs.unit_name}"}]
---
runtime:
inputs:
- host # catalog host alias
- unit_name # systemd unit name
- mode # one of: reload, restart, stop, start
- dry_run?
steps:
- id: read # ssh host 'systemctl is-active ${unit_name}' && 'systemctl show …'
- id: compute_delta # "running" → "running after restart"; mode-dependent
# implicit approval if mode in [restart, stop]
- id: apply # ssh host 'sudo systemctl ${mode} ${unit_name}'
- id: verify # ssh host 'systemctl is-active ${unit_name}'
outputs: {changed, dry_run, mode, was_active, is_active}
Generic enough to cover the 80% case (reload grafana-server,
restart caddy, restart a product's own unit). Modes that imply
downtime (restart, stop) carry the destructive approval; modes
that do not (reload, start-when-inactive) do not. Enforcement
lives in the YAML via conditional destructive: rather than a
hard-coded flag — Phase 4's runtime knows how to interpret a
template expression in destructive:.
6. Composition — the deploy-service orchestrator
deploy-service is the first orchestrator playbook. It takes
a service definition and produces a deployed, publicly reachable,
Access-gated, Caddy-fronted service. It is the single point where
the eight sub-playbooks compose.
---
id: deploy-service
kind: release_flow
title: "Deploy a service — DNS + Tunnel + Access + Caddy + app"
persona: sre
owner: "@jon"
status: active
trigger: "Manual invocation by an SRE with operator role; also invokable by CI on tag push (future)"
success_metric: "Service reachable at https://${inputs.hostname}, returns expected /api/version payload, /metrics gated by Access"
tags: [infra, deploy, orchestrator]
allowed_personas: [sre, owner]
# Orchestrator declares the UNION of sub-playbook required_scopes.
# Author does not maintain this list — Phase 4 CI computes it from
# the subplaybook graph. Listed here for illustration.
required_scopes:
- {vendor: cloudflare, scope: "Zone:DNS:Edit"}
- {vendor: cloudflare, scope: "Account:Cloudflare Tunnel:Edit"}
- {vendor: cloudflare, scope: "Account:Access: Apps and Policies:Edit"}
- {vendor: ssh, scope: "host:ops-vps:sudo:caddy"}
- {vendor: ssh, scope: "host:${inputs.host}:sudo:systemctl:${inputs.unit_name}"}
runtime:
inputs:
- name: service_name # logical name, e.g. "distill"
type: string
required: true
- name: hostname # public FQDN, e.g. "distill.gyrum.ai"
type: string
required: true
- name: host # catalog host alias for the origin
type: string
required: true
- name: unit_name # systemd unit on the host
type: string
required: true
- name: backend_url # what Caddy proxies to, e.g. "http://localhost:8080"
type: string
required: true
- name: tunnel_id # which cloudflared tunnel routes this service
type: string
required: true
- name: zone_id # CF zone for the hostname
type: string
required: true
- name: allowed_emails # Access allow-list
type: list[string]
required: true
- name: dry_run
type: bool
default: false
steps:
- id: pre_verify
type: http
target: host:${inputs.hostname}
method: GET
url: "https://${inputs.hostname}/api/version"
expect_status: [200, 404, 502, 503] # "it's fine if this fails — we're about to make it work"
on_error: continue
save_as: pre
- id: ensure_dns
type: subplaybook
playbook: service_runbook:cloudflare-dns-upsert
with:
zone_id: "${inputs.zone_id}"
name: "${inputs.hostname}"
type: "CNAME"
content: "${inputs.tunnel_id}.cfargotunnel.com"
proxied: true
dry_run: "${inputs.dry_run}"
save_as: dns
- id: ensure_tunnel_route
type: subplaybook
playbook: service_runbook:cloudflare-tunnel-route-upsert
with:
tunnel_id: "${inputs.tunnel_id}"
hostname: "${inputs.hostname}"
service_url: "http://${inputs.host}:${port_from_backend_url}"
dry_run: "${inputs.dry_run}"
save_as: tunnel
- id: ensure_access
type: subplaybook
playbook: service_runbook:cloudflare-access-app-upsert
with:
domain: "${inputs.hostname}"
name: "${inputs.service_name}"
allowed_emails: "${inputs.allowed_emails}"
policy_name: "default-email-allowlist"
dry_run: "${inputs.dry_run}"
save_as: access
- id: deploy_app
type: ssh # or "ansible" / "k8s" in future variants; ssh for today
target: host:${inputs.host}
run: |
sudo systemctl is-active ${inputs.unit_name} || sudo systemctl enable --now ${inputs.unit_name}
when: "!inputs.dry_run"
save_as: app
- id: ensure_caddy
type: subplaybook
playbook: service_runbook:caddy-site-upsert
with:
host: "ops-vps"
hostname: "${inputs.hostname}"
backend_url: "${inputs.backend_url}"
dry_run: "${inputs.dry_run}"
save_as: caddy
- id: wait_ready
type: shell
target: local
when: "!inputs.dry_run"
run: |
for i in $(seq 1 30); do
curl -sf https://${inputs.hostname}/api/version && exit 0
sleep 2
done
exit 1
timeout: 90s
- id: verify_sha
type: http
target: host:${inputs.hostname}
method: GET
url: "https://${inputs.hostname}/api/version"
when: "!inputs.dry_run"
expect_status: [200]
save_as: post
outputs:
dns_changed: "${dns.outputs.changed}"
tunnel_changed: "${tunnel.outputs.changed}"
access_changed: "${access.outputs.changed}"
caddy_changed: "${caddy.outputs.changed}"
version_before: "${pre.body.version || 'unknown'}"
version_after: "${post.body.version || 'unknown'}"
dry_run: "${inputs.dry_run}"
---
The composition points:
pre_verifyis allowed to fail.expect_status: [200, 404, 502, 503]+on_error: continuecaptures "the service might not exist yet, or might be down — that is fine, we're about to make it work". Its output feeds the before/after comparison.- Every infra step is a
subplaybook:call.deploy-servicedoes not inline the Cloudflare API or the Caddy edit. It composes the catalogue. dry_runthreads through. A dry-run deploy invokes each sub-playbook in dry-run mode. The combined report is readable in the<PlaybookRunner>terminal pane.- Each
subplaybookstep can be skipped if inputs not provided. The minimal deploy (an already-networked service that just needs the systemd unit restarted) isdeploy_app + wait_ready + verify_sha— omit thezone_id/tunnel_id/allowed_emailsinputs and the infra stepswhen-out. (Threading thewhen:expressions is an author responsibility; Phase 4 ADR-068 supportswhen:onsubplaybooksteps per §4.) - Max subplaybook depth of 5 is plenty. This orchestrator is
depth 2 (itself → sub-playbook →
http/ssh). ADR-068 §4's depth cap of 5 gives us room for future composition (e.g. adeploy-productplaybook that composes multipledeploy-servicecalls).
7. Phase gates — what lands when
Infra-as-playbooks is not a big-bang. It ships incrementally, each phase aligned with the ADR-068 phase plan, each phase independently useful.
| Phase | ADR-072 scope | ADR-068/069 prerequisite | Exit criteria |
|---|---|---|---|
| Phase 1 — today | deploy-service as a monolithic playbook with inlined http + ssh steps. Sub-playbooks deferred. No subplaybook: calls; YAML is a flat step list. |
ADR-068 Phase 1 walking skeleton (shell/http/ssh) | First service deploy runs end-to-end via one playbook. Audit log is one run, not eight. |
Phase 4 — subplaybook extraction |
Extract the eight sub-playbooks (§5). Refactor deploy-service to compose them via subplaybook steps. The monolithic playbook is archived per ADR-067 §11. |
ADR-068 Phase 4 (subplaybook + http executors) |
All eight sub-playbooks ship. deploy-service is a thin orchestrator. Idempotency (§2.1) and dry-run (§2.2) are enforced by CI. |
| Phase 5 — security hardening | Per-playbook scoped tokens (§2.4). Destructive-step approvals mandatory (§6, sub-playbooks 2 and 7). Vault references for non-bootstrap secrets (§2.3). Scope verification (GET /user/tokens/verify) at first-run. |
ADR-069 Phase 5 (allow-list, vault, destructive annotation, audit chain) | Every sub-playbook's required_scopes: verifies against its token at first-run. Destructive sub-playbooks refuse to run without approval. Audit chain includes playbook runs. |
| Phase 6+ — fleet drift reconciler | A cron job re-runs every playbooks/infra/ playbook in dry-run mode, collects delta outputs, and alerts on delta.action != 'none'. A green fleet is a fleet where every playbook reports none. |
ADR-068 Phase 2 (queue + SSE), ADR-068 Phase 7 (dashboards) | Drift dashboard lights up when the Cloudflare UI is touched directly. Alert fires within 1h. Opt-in per playbook initially; mandatory at Phase 7+. |
Phases are additive. Phase 1 ships value (auditable deploy) with no Phase 4 prerequisite; Phase 4 is cleaner (one sub-playbook per concern) with no Phase 5 prerequisite; Phase 5 is secure; Phase 6 is self-healing. A deploy that runs at Phase 1 still runs at Phase 6, unchanged — the wrapping got tighter, the contract did not change.
8. GitOps angle — YAML as source of truth
The committed YAML in dark-factory/playbooks/infra/ is the
canonical infra spec. Concretely:
- A change to a service's DNS record is a PR that edits the
call-site in
deploy-serviceor the inputs the caller passes, reviewed through the normal 3-persona formal review (ADR-067 §7 — Priya / Marcus / Lin + Dana advisory), and applied by running the playbook on merge. Not a click in Cloudflare. Not acurlfrom a laptop. A PR. - Drift detection is a first-class capability. If someone
(including the operator) edits DNS in the Cloudflare UI directly,
the next Phase 6 reconciler run on
cloudflare-dns-upsertwith the current PR's inputs computes a non-empty delta and either reverts (aggressive mode) or alerts (conservative mode, which is what we ship first). - "What does our infra look like right now?" is a report
query. Run every infra playbook in dry-run mode, collect the
currentoutputs of eachreadstep, render the aggregate. This is a Phase 6 capability; Phase 5 gives us the building blocks. - Rollback is a re-run of the previous version. The playbook
file is versioned in git;
playbook_runs.playbook_versionis the content hash per ADR-068 §5. To roll back:git revertthe PR that edited the playbook, re-run the now-reverted playbook. The re-run produceschanged: true, delta: {action: update, …}and converges the system. No bespoke rollback script per surface.
Mentioned as a future capability, not a current commitment. Phase 6's reconciler is the capability that makes the GitOps loop complete. Phase 1-5 give us: auditable changes, scoped secrets, and a convergent contract — enough. Phase 6 closes the loop (detects and remediates out-of-band changes). We commit to Phase 1-5 now, and to naming Phase 6 as the next horizon; we do not commit to shipping Phase 6 in this ADR.
9. Enforcement — where each rule is checked
The four non-negotiables (§2) are not a style guide. They are enforced at specific points in the pipeline:
| Rule | Parse-time (YAML → struct) | CI (playbook-lint) |
Run-time (runtime executor) |
|---|---|---|---|
| Idempotency shape (read / compute_delta / apply) | — | ✓ structural check: three named steps in order | — |
when: on apply |
✓ required field | ✓ must reference delta.action and dry_run |
— |
| Dry-run input declared | — | ✓ every infra playbook under playbooks/infra/ declares dry_run input |
✓ runtime skips apply if dry_run: true |
| No inline secrets | ✓ entropy check on string values | ✓ pattern match for known token shapes | ✓ event-scrubber blanks matches |
required_scopes: declared |
✓ required front-matter on infra playbooks | ✓ orchestrator's scope union matches sub-playbook graph | ✓ Phase 5: GET /user/tokens/verify on first-run |
secret_refs: matches required_scopes: |
✓ one secret_refs entry per required_scopes vendor |
— | ✓ resolution errors if env/vault missing |
| Destructive flag pairs with approval | — | ✓ destructive: true + no approval step = CI fail (ADR-069 §6) |
✓ runtime injects the approval |
| Catalogue location | ✓ infra playbooks in playbooks/infra/ |
✓ no infra playbooks elsewhere | — |
The playbook-lint CI job is new work; scoped to Phase 4. Until
then, code review enforces. The point of the table is to name the
boundary — rules that look like author discipline today become
machine-checkable by Phase 4.
10. Alternatives considered
Terraform (or OpenTofu). Rejected, for now. Terraform solves a strict superset of what we need — it already has read/compute/apply semantics, plan mode (our dry-run), state files, lockable backends, scoped providers. Using Terraform would look like: one Terraform module per Cloudflare resource kind, a state file in S3 (or equivalent),
terraform planin CI,terraform applyon merge. It works. It is battle-tested. Reasons we reject it:- Second tool, second DSL, second audit trail. We already
have Weft, YAML, and the
playbook_audittable. Terraform introduces HCL, a state backend, and a separate audit log (whatever the state-file history shows). Operators have to context-switch: "is this a Weft playbook concern or a Terraform concern?" The boundary drifts over time. - State-file operational burden. Terraform state files get
corrupted, locked, diverged. Managing them is a third job on
top of the two we already do (running Weft, running
Postgres). We would add an S3 bucket with versioning, a DynamoDB
lock table (or equivalent), and a runbook for
terraform state rm. - Worse UX for the interactive cases. Terraform's input
UX is batch (
-var-file, env vars). Our playbook UX is interactive (ADR-068prompt/prompt_secret/approval). Aterraform applythat needs to pause for a typed API key at step 3 of 7 is not a thing Terraform does. We would bolt on a wrapper — at which point we have re-implemented the playbook runtime around Terraform. - Playbook composition beats Terraform composition for our
case. We have exactly one deploy-orchestrator shape
(
deploy-service) calling ~8 sub-resources, not hundreds of modules. Terraform shines when the graph is large and state-sharing is intricate; our graph is small and mostly star-shaped.
Reasons we might revisit: if our infra resource count hits the low hundreds (Phase 6+), Terraform's mature plan/apply and state-diff tooling might out-earn the integration cost. The escape hatch is: write a
type: terraformexecutor that invokesterraform applywith a pinned module. We do not build it now; naming it is enough.- Second tool, second DSL, second audit trail. We already
have Weft, YAML, and the
Pulumi. Rejected for the same reasons as Terraform, plus: Pulumi's value proposition is "use a real programming language for your IaC". We already have a real programming language (Go) and the reason we want YAML is that authors are not writing Go for each deploy. Pulumi would be a regression on the author ergonomics we optimise for.
Ansible for everything. Rejected. Ansible is imperative at its core — it is a very clever ssh-loop with templating. It has no persistent audit log, no run record, no idempotency contract beyond what each module ships with, and no interactive prompt semantics. We already use Ansible for service bootstrap on new hosts (setting up caddy, systemd, a non-root user, etc.); we continue to use it for that (bootstrapping a host once is imperative by nature). Day-to-day infra operations are not bootstrap and belong in the playbook runtime. See §14 for the WITHIN-HOST / ACROSS-FLEET boundary and §15 for the
type: ansiblestep that bridges the two without inverting the orchestration direction.Manual Cloudflare UI forever. Rejected. Does not scale past one or two services. See §Context for the concrete failure modes. The argument for keeping it — "it's what we know" — is the argument we reject every time we adopt a new primitive.
Direct Kubernetes controllers (Crossplane, ACK, etc.). Rejected. Crossplane/ACK solve this problem Kubernetes-natively: Cloudflare resources as custom resources, a controller that reconciles, a git-ops flow via Flux/ArgoCD. Excellent, if we were Kubernetes-first. Gyrum is not Kubernetes-first (ADR-020 commits to VMs + systemd + Caddy). Adopting Kubernetes for this specific problem means either running a control-plane cluster (all of the k8s operational overhead for one use case) or paying for a managed control plane (external dependency for an internal concern).
A purpose-built
gyrum-infrarepo with hand-rolled scripts per surface. Rejected — this is the status quo slightly formalised. It gives us code-in-git (already partly true for Caddy) but neither the audit log, the idempotency contract, the dry-run, the interactive UX, nor the composition primitive. It is what we would build if ADR-068 did not exist. ADR-068 exists.A single "infra" monolithic playbook that does everything inline. Rejected as a Phase 4+ endpoint — but accepted as the Phase 1 shape (see §7). A flat
deploy-service.yamlwith inlinedhttp+sshsteps is the right Phase 1 artifact because thesubplaybookexecutor does not ship until ADR-068 Phase 4. Phase 1 monolithic → Phase 4 composed is the intended migration, not a sign that Phase 1 was wrong.Manage infra via a web UI in the Weft frontend (click-ops with audit). Rejected. A UI that mutates infra is a UI that has to own authorisation, scopes, audit, and idempotency — all the concerns that live in the playbook runtime. Building it in the UI is re-implementing the runtime in JavaScript. The UI's job (ADR-070) is to run playbooks; it is not a config tool. An operator who wants to click-deploy a service opens the Weft
<PlaybookRunner>fordeploy-serviceand fills the input form. That is the UI.
11. Consequences
Harder.
- Every infra change now needs a playbook. A new vendor surface
(e.g. adding Fastly, adding a second cloud) is a new playbook (or
set of them) before it is usable. This is discipline, not
friction — the alternative is drift. But it is real work: a
first-time-ever deployment on a new surface now takes longer than
it would with
curl. The payoff is every subsequent deploy. - Playbook YAML becomes THE canonical infra spec. An operator who edits infra via the CF UI without also updating the playbook causes drift. At Phase 5 this is a CI concern (the playbook has drifted from state but nobody noticed until the next real run); at Phase 6 the reconciler either alerts or reverts. Until Phase 6, discipline is the enforcement. The operator's rule: "if you clicked it in the UI, you owe a PR to the playbook". We state this explicitly because otherwise the invariant breaks the first week.
- Author cost per sub-playbook. Eight sub-playbooks at ~300-400 lines of YAML each plus test coverage plus CI integration plus docs is roughly 8 follow-up PRs at ~600-800 lines each (within the 800-line ADR-067 PR cap). This is Phase 4 work and lands alongside ADR-068 Phase 4, not before.
- Scope-taxonomy maintenance. Cloudflare's scope names
occasionally change (new scopes, renamed scopes). Our
required_scopes:declarations are strings today; a CF rename breaks them. Mitigation: Phase 5's scope-verification step turns a rename into a clear first-run failure, not a silent production bug. Author updates the playbook; CI accepts. - Dry-run diverges from apply occasionally. A playbook that passes dry-run can still fail at apply (race with another mutator, rate limit, CF API bug). Dry-run is not a guarantee; it is a higher-confidence preview. Operators have to know the difference.
Easier.
- Audit log is free. ADR-068's
playbook_run_eventstable records every infra run with its actor, its inputs (hashed per ADR-069 §5), its duration, its outcome, and the committed playbook content hash. Six-months-later question "who changed the DNS?" is a SQL query, not an archaeology dig. - Rollback cost drops dramatically.
git revertthe PR; re-run the playbook. Works for every surface in the catalogue, uniformly. - One interactive UX across every infra surface. Operator
opens
<PlaybookRunner>fordeploy-service, fills the form, watches the terminal pane stream, approves the destructive step when prompted, sees the before/after report. Same UX for DNS-only changes, for Caddy-only changes, for full deploys. New SRE training drops from "six vendor UIs and a shell history" to "open the runner and fill the form". - Cross-vendor composition becomes trivial. Adding a Fastly
step (hypothetical Phase 7 second CDN) is a new sub-playbook
plus a new
subplaybook:call indeploy-service. No re-architecture of the orchestrator. - Infra tests become possible. Phase 6's reconciler runs
every infra playbook in dry-run mode on a schedule. The
aggregate
changed: null|falsereport is a fleet-health KPI. A change that drifts infra makes the next scheduled run yellow.
What we sign up to maintain.
- The idempotency contract (§2.1). Every new infra playbook
adheres. A sub-playbook that cannot be made idempotent (because
the underlying API isn't — e.g. a create-only webhook) still has
to declare the idempotency limit in its front-matter
(
idempotent: false) and pair with an ADR-069 idempotency-key guard. We write the amendment ADR if a recurring case emerges. - The sub-playbook catalogue. The eight in §5 are the minimum.
We expect to land a handful more over the next twelve months —
docker-compose-up(for the stacks we still run via compose),cloudflare-waf-rule-upsert(once WAF becomes load-bearing), a second-CDN playbook if we adopt one. Each carries the four non-negotiables; each is a follow-up PR citing this ADR. - The scope taxonomy. Vendor-scope strings (
Zone:DNS:Edit, etc.) are tracked in a singledark-factory/playbooks/infra/ scope-taxonomy.yamlcatalogue (follow-up; out of scope here). This is where Cloudflare scope renames land as PRs. - The Phase 1 → Phase 4 migration. The monolithic
deploy-serviceat Phase 1 is a debt we repay at Phase 4. Tracking issue: "Decompose deploy-service into subplaybook calls" on the dark-factory board, blocking Phase 4 exit. - The GitOps discipline (§8). If we click the UI without owing a PR to the playbook, we betray the invariant and the reconciler (Phase 6) will churn. Operator muscle memory.
12. Migration path
| Step | Where | What |
|---|---|---|
| 1 | dark-factory | This ADR merges (Phase 0). |
| 2 | dark-factory (follow-up) | playbooks/infra/ directory created with _index.yaml scaffold. Empty except for a placeholder README pointing at this ADR. |
| 3 | dark-factory (follow-up, parallel with ADR-068 Phase 1) | deploy-service.yaml lands as a Phase 1 monolithic playbook — inlined http + ssh steps, no subplaybook: yet. Used for the first real Weft service deploy. |
| 4 | dark-factory (follow-up, after ADR-068 Phase 4) | First sub-playbook: cloudflare-dns-upsert.yaml. One PR ≤ 800 lines. |
| 5 | dark-factory | Remaining seven sub-playbooks, one per PR, ordered: cloudflare-dns-delete, cloudflare-tunnel-route-upsert, cloudflare-access-app-upsert, cloudflare-access-policy-upsert, caddy-site-upsert, caddy-site-delete, systemd-service-reload. |
| 6 | dark-factory | Refactor deploy-service.yaml from monolithic to composed (replace inline steps with subplaybook: calls). Phase 4 exit. |
| 7 | dark-factory | playbook-lint CI job — structural checks from §9 table. Initially warns; promotes to hard-fail once the backlog is clean. |
| 8 | dark-factory (after ADR-069 Phase 5) | scope-taxonomy.yaml + scope-verification step on first-run. Per-playbook token provisioning runbook under playbooks/infra/bootstrap/. |
| 9 | dark-factory (Phase 6) | Drift reconciler cron — dry-run every infra playbook hourly, alert on non-empty deltas. |
| 10 | ai-frontend | <PlaybookRunner> exposes an "Infra" persona route filter (ADR-065 persona IA) for the SRE persona's /ops screen — the catalogue of infra playbooks is surfaced as its own list. |
Steps 1 is this PR. Steps 2-3 are in the same shipping cohort (Phase 1 value). Steps 4-6 fill ADR-068 Phase 4. Steps 7-10 are tracked follow-ups.
13. Review cadence
Re-review every 90 days (next: 2026-07-22) against:
- Sub-playbook coverage — are the eight in §5 shipped? Has a ninth or tenth been added? Have any been superseded?
- Idempotency violations — how many times has the reconciler
(Phase 6) reported
changed: trueon a re-run? Each is a bug to file; the count is a trend KPI. - Dry-run fidelity — how often has an apply failed after a successful dry-run? Target: rare. Rising trend is a signal to tighten the delta-compute step.
- Scope correctness — has the scope-verification step rejected any token as over-scoped or under-scoped? Each rejection is a good signal (the discipline is catching drift).
- Terraform pressure — has the escape hatch (§10) started to feel necessary? If we pass ~50 infra resources and the YAML authoring cost feels higher than HCL, we revisit.
- GitOps discipline — how many times has an operator clicked a CF UI without a matching PR? Each is a retro item for the next review.
Stale flag on the Owner dashboard once last_reviewed on this
ADR passes 90 days.
References
- ADR-067 — playbooks unified primitive (the kind / front-matter / related conventions)
- ADR-068 — playbook runtime architecture (
http,ssh,subplaybookexecutors,playbook_run_eventsaudit stream); §14-§15 here extend its Phase-4 executor roster withtype: ansible(the eleventh step kind) and name the bridge between ACROSS-FLEET Gyrum playbooks and WITHIN-HOST Ansible roles - ADR-069 — playbook security model (authorisation, secret vault, destructive-step approval, audit chain)
- ADR-070 — playbook UI contract (
<PlaybookRunner>, terminal pane, form-from-schema) - ADR-071 — product name Weft; the "loom" metaphor (pattern → shuttle → pass → fabric) grounds why infra-as-playbooks reads as Weft's own primitive rather than a bolt-on
- ADR-064 — gyrum-catalog + hex arch; SSH Executor port backs
every
sshstep; host aliases (ops-vps, etc.) resolve through the catalog - ADR-060 — catalog-driven infrastructure; service catalog
entries reference the
deploy-serviceplaybook id that last deployed them - ADR-053 — frontend observability; dry-run preview payloads stream through the same SSE channel as normal runs
- ADR-020 — shared infra vs per-product isolation; this ADR operates within the "shared infra" boundary (one CF account, one apex zone, one tunnel)
ai-research/playbooks/hello-world.yaml— sample playbook referenced by ADR-068 §13 as the walking-skeleton template the infra sub-playbooks followai-research/playbooks/goodbye.yaml— second sample playbook; the${inputs.x}and${steps.y.z}reference syntax demonstrated there is the reference syntax the infra playbooks use- Warp /
warp.devSECURITY.md — the per-command scoped-token pattern we borrow for therequired_scopes:+secret_refs:front-matter shape (adapted, not copied)
14. Ansible boundary — WITHIN-HOST vs ACROSS-FLEET
Ansible came up twice already in this ADR — §10 ("Ansible for
everything") as a rejected alternative, and §11 ("Consequences")
as the tool we already use for host bootstrap. This section
names the boundary explicitly, because the previous treatment
conflates two questions: "should Ansible be the orchestrator?"
(no, §10) and "should Ansible exist at all in Weft's world?"
(yes, and here is where). Getting this wrong in either direction
is expensive: replicate Ansible modules inside Weft playbooks and
we ship a worse apt wrapper forever; ignore the boundary and a
Weft author reaches for an inline ssh … apt install … step when
a hardened role already exists.
14.1 The division
Two tools, two scopes. Gyrum playbooks and Ansible roles address different axes of the same problem. Draw the line through the host boundary: what crosses it and what does not.
ACROSS-FLEET WITHIN-HOST
(Gyrum playbooks) (Ansible)
┌──────────────────────────────────────────┐ ┌──────────────────────────────────┐
│ deploy-service │ │ roles/docker/ │
│ • Cloudflare DNS upsert ──────────────┼──(CF API) │ roles/postgres/ │
│ • Cloudflare Tunnel route │ │ roles/caddy/ │
│ • Cloudflare Access app/policy │ │ roles/node_exporter/ │
│ • Caddy site upsert ──────────────────┼───(ssh)───▶ roles/firewall/ │
│ • systemd unit reload │ │ roles/cloudflared/ │
│ • claude step (rendered commit msg) │ │ roles/<per-service>/ │
│ • approval step │ │ │
│ • prompt / prompt_secret │ │ tasks: │
│ • http / ssh / subplaybook │ │ - apt │
│ │ │ - systemd │
│ cross-service orchestration, API-level │ │ - copy / template │
│ state convergence, human-in-the-loop, │ │ - user / group / file │
│ audit, dry-run, approvals, scopes │ │ - sysctl, ufw, seccomp │
└──────────────────────────────────────────┘ └──────────────────────────────────┘
│ ▲
│ │
└──────── type: ansible step (§15) ───────────────────────┘
ansible-playbook --limit host --tags …
Read the diagram as two planes: ACROSS-FLEET sits above the host
boundary and moves API state between services (Cloudflare,
Caddy-on-another-host, a systemd unit on some host); WITHIN-HOST
sits below the boundary and moves package / file / service state
inside a single machine. Every call between the planes goes
through the type: ansible step (§15) — there is no other bridge.
14.2 Which tool handles which class of task
| Class of task | Tool | Why |
|---|---|---|
Install a package (apt install …, snap install …) |
Ansible | ansible.builtin.apt / package handles idempotency, held-packages, apt-cache staleness. Weft would re-implement it badly. |
| Manage a systemd unit (write unit file, enable, start, reload) | Ansible | ansible.builtin.systemd handles daemon-reload ordering and state=started idempotency. |
Write a config file with templating (Caddyfile fragment, /etc/postgresql/pg_hba.conf) |
Ansible | ansible.builtin.template + handlers give atomic write + notify + restart. |
| Create a Unix user / group / sudoers entry | Ansible | ansible.builtin.user / group / copy into /etc/sudoers.d/ — hardened for decades. |
Enable a firewall rule (ufw allow …) |
Ansible | community.general.ufw — idempotent, rule-ordering aware. |
Run a shell command that is intrinsically a host-level primitive (ufw disable, apt-get update) |
Ansible | Even when a shell one-liner would work, wrapping it in a role keeps idempotency and check-mode honest. |
| Upsert a Cloudflare DNS record via the CF API | Gyrum | Cross-fleet concern. Ansible has community.general.cloudflare_dns but it runs on a host; we want the audit + approval chain in the Weft runtime. |
| Upsert a Cloudflare Tunnel route / Access app / Access policy | Gyrum | Same reasoning — cross-fleet API state; scope-verified token; destructive-step approval. |
| Compose a multi-service deploy (DNS + Tunnel + Access + Caddy + unit reload) | Gyrum | Cross-service orchestration. Ansible has no native "pause for approval" nor "dry-run then confirm" interactive UX. |
| Ask Claude to render a commit message / release note / incident summary | Gyrum | type: claude is a Weft executor (ADR-068 §4). Ansible has no LLM primitive and should not grow one. |
Prompt the operator for a value (prompt / prompt_secret) |
Gyrum | ADR-068's interactive executors. Ansible's vars_prompt is batch-time, not run-time. |
| Gate a destructive step on operator approval | Gyrum | ADR-069 §6's approval executor. Ansible has no equivalent. |
| Bootstrap a brand-new host from scratch (disk, ssh keys, base packages, sshd hardening) | Ansible (via a type: ansible step from Gyrum) |
Classic Ansible sweet spot. The invocation is a Gyrum playbook so it is audited; the work is an Ansible role. |
| Drift reconciliation across the fleet (Phase 6 §7) | Gyrum | Reconciler = dry-run every playbook and aggregate. Ansible has no aggregate dry-run of a heterogeneous playbook fleet. |
The heuristic for an author deciding where a new task goes: if the task operates on state inside a single host and can be expressed as "install / configure / run this service, once, with these parameters", it is an Ansible role. If the task operates on state across multiple hosts or outside any host (an API, a DNS zone, a tunnel config), or it needs interactive UX (approval, prompt, claude), it is a Gyrum playbook.
14.3 The rule — Gyrum calls Ansible, never replicates it
Gyrum playbooks never replicate what Ansible modules do. They
CALL ansible-playbook as a single type: ansible step (§15).
This rule is non-negotiable in the same sense as the four in §2. Violations are code-review and (post Phase-4) CI failures. The rule has two operational implications:
- No inline
aptin atype: shellstep. A Weft playbook that writesrun: sudo apt-get install -y postgresqlis wrong even when it works. The idempotency story is weaker thanansible.builtin.apt; the error taxonomy is shell exit codes, not module-level structured results; the audit trail saysran a shellrather thanapplied role postgres. - No inline
systemctlto enable a unit that a role would manage.ssh host 'sudo systemctl enable --now postgres'is fine for a reload after a config change the role just made (that issystemd-service-reloadterritory, §5.8). It is wrong for the initial enable of a unit whose installation is a role concern.
What a Gyrum playbook may still do at the host level, without
going through Ansible: the narrow set of primitives ADR-072
committed to in §5 — systemd-service-reload, caddy-site-upsert
(which edits the caller's Caddyfile fragment and reloads),
ssh one-shots that observe rather than mutate (e.g. systemctl is-active). These are observe-and-nudge, not configure-from-
scratch.
14.4 Worked example — wrong vs right
Wrong — inlining the package install in a Weft shell step:
# playbooks/infra/deploy-observ-stack.yaml (fragment — DO NOT SHIP)
steps:
- id: install_prom
type: shell
target: host:ops-vps
run: |
sudo apt-get update
sudo apt-get install -y prometheus prometheus-node-exporter
sudo systemctl enable --now prometheus
sudo systemctl enable --now prometheus-node-exporter
sudo tee /etc/prometheus/prometheus.yml <<EOF
# ... 40 lines of yaml ...
EOF
sudo systemctl restart prometheus
What is wrong here:
apt-get installis not idempotent in a useful sense. It returns 0 on both "installed now" and "already present", sochanged: true/falseis not recoverable. Ansible'saptmodule reportschangedtruthfully.teeis not atomic. A failed restart mid-teeleaves a half-written config.ansible.builtin.templatewrites to a temp file and renames.- Package version drift. No pinning, no hold, no dpkg-lock
retry. Ansible's
apthas all three. - Re-running is dangerous. A second run re-runs
apt update+teeeven when nothing changed — noisy in the audit log, and any transient apt mirror failure fails the whole playbook. - The audit log reads
ran a 12-line shell. Not "applied role node_exporter on ops-vps, changed=0".
Right — delegate the host-level work to an Ansible role:
# playbooks/infra/deploy-observ-stack.yaml (fragment — correct)
steps:
- id: install_observ_stack
type: ansible
target: local
playbook: install-observ-stack.yml # under $ANSIBLE_DIR
limit: "ops-vps"
inventory: prod.ini
extra_vars:
prometheus_version: "${inputs.prometheus_version}"
node_exporter_version: "${inputs.node_exporter_version}"
tags: [install, configure]
diff: true
become: true
save_as: observ_install
- id: register_metrics_route
type: subplaybook
playbook: service_runbook:cloudflare-access-app-upsert
with:
domain: "metrics.gyrum.ai"
name: "prometheus"
allowed_emails: "${inputs.sre_emails}"
policy_name: "metrics-access"
dry_run: "${inputs.dry_run}"
save_as: access
The right shape leaves the host-level concerns (package install,
config template, systemd enable + restart) inside the Ansible
role and keeps the Weft playbook as the cross-fleet
orchestrator. ansible/playbooks/install-observ-stack.yml
references roles/prometheus and roles/node_exporter, which
are apt-based, idempotent, check-mode clean. The audit log
records applied install-observ-stack.yml on ops-vps, changed=2 (the two newly installed packages), and the
structured step output (§15.3) gives the reconciler something
meaningful to compare on the next run.
14.5 The Ansible role library — seven roles to grow
The rule in §14.3 is only fair if the roles exist. Committing to the rule commits us to growing the role library in step with the sub-playbook catalogue (§5). The initial seven:
| # | Role | Purpose | Depends on |
|---|---|---|---|
| 1 | roles/docker |
Install + configure Docker Engine, docker group, log rotation, daemon.json with journald driver. |
— |
| 2 | roles/postgres |
Install Postgres (pinned major), pg_hba.conf template, postgresql.conf tuning, create role + db, backup cron. |
— |
| 3 | roles/caddy |
Install Caddy (apt repo), drop base Caddyfile with import glob, install caddy-upsert.sh hook used by §5.6. |
— |
| 4 | roles/node_exporter |
Install prometheus-node-exporter, bind to localhost:9100, systemd override for --collector.disable-defaults tuning. |
— |
| 5 | roles/firewall |
ufw enable, default-deny-in / allow-out, allow ssh + 443 from anywhere, allow metrics ports from cloudflared-only. |
— |
| 6 | roles/cloudflared |
Install cloudflared, install tunnel credentials from vault, systemd unit, health-check. |
roles/docker (optional — co-hosted docker workloads). |
| 7 | roles/<per-service> |
Per-service role (e.g. roles/distill, roles/ai-research): binary drop, systemd unit, env file, log config. Thin wrappers — the service is containerised via docker where possible. |
roles/docker. |
These are the bootstrap-plus-baseline roles every ops-class host
needs. Growth beyond seven is author-proposed via the same
three-persona review the ADRs use (§8 review ladder); a new
role is a PR to dark-factory/ansible/roles/<name>/ with
tasks/ handlers/ defaults/ README.md.
14.6 Structured inventory discipline
Ansible's inventory is a first-class artefact, not a scratchpad.
dark-factory/
└── ansible/
├── ansible.cfg # inventory = inventory/prod.ini
├── inventory/
│ ├── prod.ini # committed, canonical
│ ├── staging.ini # committed, mirrors prod
│ ├── group_vars/
│ │ ├── all.yml # fleet-wide defaults
│ │ ├── web.yml # web-facing VMs
│ │ ├── db.yml # database VMs
│ │ └── ops.yml # observability / tooling VMs
│ └── host_vars/
│ ├── ops-vps.yml # per-host overrides
│ ├── db-01.yml
│ └── web-01.yml
├── playbooks/
│ ├── bootstrap-host.yml # `roles: [firewall, cloudflared, node_exporter]`
│ ├── install-observ-stack.yml # `roles: [prometheus, node_exporter]` (on ops-vps)
│ ├── install-caddy.yml
│ ├── install-postgres.yml
│ └── install-X.yml # one per service
└── roles/ # per §14.5
Rules:
- Three groups, not more.
web,db,ops. Anything else is a tag or a host-var, not a group. Three groups keep the group-var explosion bounded; a fourth group is an ADR amendment. - One host per line in
inventory/*.ini.ansible_hostansible_userexplicit; no SSH-config magic. Matches the gyrum-catalog host aliases (ADR-064) — a host aliasops-vpsin gyrum-catalog corresponds to an inventory entryops-vps ansible_host=… ansible_user=opsone-for-one.
- No secrets in
host_vars/orgroup_vars/. Secrets go through the Weft vault (ADR-069 §4).host_vars/ops-vps.ymlmay referencecf_api_token: "{{ vault_cf_api_token_zone_dns_edit }}"where the right-hand side resolves at runtime from the Weft vault (bridged viaextra_varsin thetype: ansiblestep — §15.2). Ansible Vault is not used; we have one vault, the Weft vault, per ADR-069. - Inventory is reviewed. A new host is a PR. The 3-persona review applies — Priya checks SSH exposure, Marcus checks group/host-var sanity, Lin checks role invocation.
14.7 Idempotency test — every role runs twice
The non-negotiable on the WITHIN-HOST side is the same as the §2.1 non-negotiable on the ACROSS-FLEET side: idempotency. We enforce it identically:
- First-run acceptance. A role applied to a clean host
produces
changed=Nfor some N >= 0 and exits 0. Ansible's default behaviour. - Second-run assertion. Immediately re-applied, the same
role on the same host must produce
changed=0. Any non-zero changed count is a bug in the role. - CI check. For each of the seven roles in §14.5, a CI
job stands up a disposable VM (or container), applies the
role, re-applies the role, asserts
changed=0. The assertion reads the structuredPLAY RECAPoutput (same shape §15.3 names) and fails the CI job onchanged>0orfailed>0. - Phase-4 integration. The CI assertion is the WITHIN-HOST
analogue of the Phase-4 reconciler's
delta.action == 'none'check (§8). Together they make the fleet self-describing: across-fleet drift = Weft playbook reports a non-empty delta; within-host drift = an Ansible role re-run reportschanged>0.
The idempotency test catches the class of mistake that breaks
config-management most often: a role that works once (installs
a package, writes a config) but mutates on every subsequent run
(adds another line to a config, rewrites a file with a new
timestamp). A role that fails the second-run check is not
merged.
14.8 What this section does not say
- Ansible is not the orchestrator. §10's rejection stands. Weft does not adopt Ansible as a top-level driver. Ansible is a callee of Gyrum playbooks, invoked via §15.
- We do not ship Ansible Tower / AWX. The Ansible we run is
plain
ansible-playbookfrom the CLI (or from thetype: ansibleexecutor, §15). No control-plane service, no separate UI. The UI for running Ansible is Weft's<PlaybookRunner>— the same as every other step type. - We do not write custom Ansible modules. Upstream
ansible.builtin+community.general+community.dockercover every WITHIN-HOST primitive the seven roles need. A hypothetical custom module is an ADR amendment. - We do not use Ansible Vault. One vault (Weft's), one
secret-resolution path (
${vault.X}/${env.X}). Vault is bridged into Ansible viaextra_varson thetype: ansiblestep — §15.2.
15. type: ansible step executor — Phase 4 runtime addition
§14 named the boundary. This section names the bridge. ansible
becomes a first-class step type in the ADR-068 runtime,
alongside shell, ssh, claude, http, prompt,
prompt_secret, approval, pipeline, experiment, and
subplaybook. That is eleven executor kinds in the Phase 4
runtime, and ansible is the eleventh.
15.1 Why first-class, not shell-wrapped
An obvious cheaper alternative is to let authors write a
type: shell step that runs ansible-playbook …. We reject
that alternative for the same reasons §14 rejects inline apt:
the contract the executor gives us is load-bearing. Specifically:
- Parse-time validation of
playbook+limit. A shell step'srun:is an opaque string; parse-time can only syntax-check it. Thetype: ansiblestep has typed fields, so the validator can check:playbookresolves to a file under$ANSIBLE_DIR;limitresolves to a host or group in the declaredinventory;tagsmatch tags declared in the playbook;extra_varskeys are expected inputs. A typo inlimit: web-o1(oh-one vs zero-one) fails at CI, not at the fourth step of a deploy. - Structured output for idempotency assertions. §14.7's
CI check and §8's drift reconciler both want the same
structured summary:
changed,failed,unreachable. Atype: shellstep emits stdout bytes; the caller would have to parsePLAY RECAPwith a regex. Thetype: ansibleexecutor parses it once, in Go, and hands callers a typed struct via${steps.X.changed}/${steps.X.failed}. - Per-task run-log events. The Phase-2 SSE stream
(ADR-068 §6) wants fine-grained progress.
ansible-playbook --stdout-callback=jsonemits one JSON event per task; the executor re-emits those asstep_progressevents with{task: "…", host: "…", status: "ok|changed|failed", duration_ms: N}. A shell-wrap would collapse them into a single stdout blob. - Typed downstream references. A downstream step can
write
when: "${steps.install_X.changed > 0}"orwhen: "${steps.install_X.unreachable == 0}"and have the expression engine evaluate integers, not strings. Theclaudestep composes with this —${steps.install_X.per_host}passed as prompt context gives Claude a structured input to render a release note from.
The cheap alternative gets us some of (1) — a shell step can
at least check the command is syntactically valid — but none of
(2-4). First-class wins.
15.2 Full executor specification
steps:
- id: deploy_X
type: ansible
target: local # runs `ansible-playbook` on the worker; the playbook SSHes fan-out
playbook: install-X.yml # path relative to $ANSIBLE_DIR (dark-factory/ansible/playbooks/)
limit: web-01 # --limit; host, group, or comma-separated list
inventory: prod.ini # optional; default is ansible.cfg's inventory
extra_vars: # --extra-vars; passed as JSON (preserves types)
version: "${inputs.version_tag}"
emails: "${inputs.allowed_emails}"
cf_api_token: "${vault.cf_api_token_zone_dns_edit}" # vault bridge (§14.6)
tags: [deploy, verify] # --tags
skip_tags: [debug] # --skip-tags (optional)
check: false # --check; Ansible's dry-run (default: false)
diff: true # --diff; show template/config deltas (default: true)
become: true # --become; sudo on target (default: false)
become_user: root # --become-user (default: root when become: true)
forks: 5 # --forks; parallelism across hosts (default: 5)
timeout: 600s # executor timeout; kills ansible-playbook on overrun
save_as: ansible_result # ${steps.deploy_X.*} downstream
Field semantics:
target: local— theansibleexecutor always runs on the Weft worker. The worker has the SSH key, the inventory file, and the role library on disk; the fan-out happens insideansible-playbook.target: host:ops-vpsis rejected at parse time (nonsensical — Ansible would then need its own Ansible to reach each host).playbook:— required. Resolves relative to$ANSIBLE_DIR(defaulted todark-factory/ansible/playbooks/at runtime startup; see ADR-068 §3 runtime config). Parse-time validator confirms the file exists; CI validator confirms it parses viaansible-playbook --syntax-check.limit:— optional but strongly recommended. If absent, the playbook's ownhosts:declaration governs — which is usuallyallor a group, and usually too broad for safety. Authors default to a narrowlimit:; broad limits require a comment explaining why.inventory:— optional. Defaults to the inventory inansible.cfg. Explicit override supports staging/prod switches (inventory: staging.iniin a dry-run variant).extra_vars:— a map, JSON-serialised when passed to--extra-vars. This is the vault-bridge: a${vault.X}reference is resolved by the Weft vault resolver (ADR-069 §4) before the JSON is built, so the token is in Ansible's process environment only, never on the command line. stdout scrubbing (§2.3, ADR-069 §4.3) extends to Ansible's stdout.check: / diff:— Ansible's dry-run surface.check: trueis the natural bridge for the playbook-leveldry_run: trueinput at §2.2: the top-level playbook setscheck: "${inputs.dry_run}"on everytype: ansiblestep, so a dry-run deploy runs Ansible in check mode on every host.diff: trueis almost always desired — it surfaces template + copy deltas in the per-task event stream.become:/become_user:— Ansible's sudo control. Defaults tobecome: false, deliberately: the author opts in. A role that needs root says so loudly.forks:— parallelism. Small by default (5) because fan-out to 5+ hosts is the rare case in our fleet size. Bumps require a comment.timeout:— executor-level; distinct from Ansible's own--timeout(which is per-SSH-connection). On expiry the executor SIGTERMsansible-playbook, captures partial state, emitsstep_failedwith{reason: "executor_timeout", per_host: {…partial…}}.
Forbidden fields (parse-time rejection):
ansible_vault_password_file:— Ansible Vault is not used; see §14.8.target: host:*— see above.run:/command:— this is not a shell step; the playbook is the unit of execution.- Literal passwords or tokens in
extra_vars— same entropy/pattern check as §2.3.
15.3 Structured output — step_completed body
On success, the executor emits a step_completed event whose
outputs field is:
{
"host_count": 1,
"changed": 3,
"failed": 0,
"unreachable": 0,
"ok": 14,
"skipped": 2,
"per_host": {
"web-01": {
"ok": 14,
"changed": 3,
"failed": 0,
"unreachable": 0,
"skipped": 2,
"rescued": 0,
"ignored": 0,
"tasks": [
{"name": "install postgres", "status": "changed", "duration_ms": 1821},
{"name": "ensure postgres service running", "status": "ok", "duration_ms": 112}
]
}
},
"stderr_tail": "...",
"playbook": "install-X.yml",
"limit": "web-01",
"tags": ["deploy", "verify"],
"check_mode": false,
"duration_ms": 18243
}
Field contract:
host_count— number of hosts in the resolved--limit. Driven from Ansible's JSON callback, not re-derived client-side.changed/ok/failed/unreachable/skipped— fleet-wide sums across all hosts. These are the primary signals for downstreamwhen:expressions.per_host— map of host → per-host stats. Matches Ansible'sPLAY RECAPper-host counts exactly, plus atasksarray for the per-task trace when--stdout-callback=jsonwas used.tasksis capped at 500 entries; overflow is elided with a tail note instderr_tail.stderr_tail— last 4KB of stderr, newline-trimmed. For failure-mode debugging without streaming the full stderr intoplaybook_run_events.body(ADR-068 §5 event-size cap).playbook/limit/tags/check_mode— echo-backs of the step's inputs, for audit-time reconstruction without reading the step definition.duration_ms— executor wall-clock.
Failure variants:
- Ansible exits non-zero but produced a recap. Emit
step_failedwith the same shape;failedandunreachableare non-zero and name the offending hosts. - Ansible did not start (missing inventory, syntax error).
Emit
step_failedwith{reason: "ansible_startup", stderr_tail: "…"}; noper_host. - Executor timeout. Emit
step_failedwith{reason: "executor_timeout", per_host: {…partial…}}as above.
Downstream when: composability:
steps:
- id: install_X
type: ansible
playbook: install-X.yml
limit: "${inputs.target_host}"
save_as: install_X
- id: cloudflare_dns
type: subplaybook
when: "${steps.install_X.changed > 0}" # only register DNS if the unit actually rolled
playbook: service_runbook:cloudflare-dns-upsert
with:
name: "${inputs.target_host}"
content: "${inputs.tunnel_id}.cfargotunnel.com"
dry_run: "${inputs.dry_run}"
- id: announce_release
type: claude
when: "${steps.install_X.failed == 0 && steps.install_X.changed > 0}"
prompt: |
Render a release note for version ${inputs.version_tag}.
Per-host changes: ${steps.install_X.per_host}
Task summary: changed=${steps.install_X.changed},
ok=${steps.install_X.ok}, skipped=${steps.install_X.skipped}.
This is the payoff of first-class structure: the downstream
steps read typed values, not parsed stdout; the claude step
composes the structured trace directly into its prompt.
15.4 Idempotency test — runtime-asserted
The executor supports an assertion mode for the CI idempotency
check (§14.7). Setting assert_idempotent: true makes the
executor run the playbook twice and fail the step unless the
second run reports changed == 0 && failed == 0 && unreachable == 0:
- id: verify_role_idempotency
type: ansible
playbook: install-observ-stack.yml
limit: ci-disposable
inventory: ci.ini
become: true
assert_idempotent: true # ADR-072 §15.4
save_as: idem
On pass, outputs.second_run is attached with the same shape
as §15.3; outputs.idempotent: true. On fail,
step_failed with {reason: "not_idempotent", first_run: {…}, second_run: {…}}. This is the machine check §14.7
describes; the flag is off by default because a normal deploy
only wants one run.
15.5 Phase-4 placement — alongside ssh / http / claude
The type: ansible executor lands in ADR-068 Phase 4,
the phase that also carries subplaybook, http, and (per
§14 here, newly enumerated) the refinement of ssh, claude,
prompt, prompt_secret, and approval to their Phase-4
contracts. The executor roster at the end of Phase 4 is:
shell— already in Phase 1.ssh— already in Phase 1; hardened in Phase 4.http— Phase 4.claude— Phase 4.prompt— Phase 4.prompt_secret— Phase 4.approval— Phase 4.subplaybook— Phase 4 (also gates the §7 Phase-4 exit criteria for this ADR).pipeline— Phase 4.experiment— Phase 4.ansible— Phase 4 (this ADR, §14-§15).
Phase 4 is the "complete the runtime" phase. ansible does
not introduce new infrastructure (no new queue, no new SSE
topic, no new event shape beyond what step_completed already
supports); it is another executor registered against the same
runtime. The implementation is an executor that shells out to
ansible-playbook, parses the JSON callback, and emits the
structured events. The role library (§14.5) grows in parallel
but on its own schedule — a Phase-4 exit requires only the
executor and at least one first-party role under
roles/bootstrap/ so that the executor has something to run
against in integration tests.
15.6 Generalisation — the reconciliation-tool executor shape
type: ansible is the first of a class. Two obvious future
siblings, not committed to here, are:
type: terraform—target: local,module:,workspace:,variables:,auto_approve: false, structuredplanoutput via-json, structuredapplyoutput via-json. Same shape: shell out to a reconciliation tool, parse its structured output, emit typed step results. §10's escape hatch (Terraform for very large resource counts) lands as atype: terraformexecutor, not as a Terraform-calls-Weft inversion.type: pulumi—target: local,stack:,program:,config:,preview: bool, structured output via--json. Same shape again.
The pattern the three share — Gyrum invokes a
reconciliation tool; the tool operates on its own scoped
state; Gyrum parses the structured summary; downstream
references read typed fields — is what makes type: ansible first-class rather than shell-wrapped. The executor
class name in the runtime taxonomy is reconciliation_tool;
ADR-068's Phase-4 executor registry can accept a plugin
contract so terraform / pulumi can be added without an
ADR-068 amendment, only an ADR-072 amendment per new tool.
Supersedes: none Superseded by: leave blank until a later ADR reverses this one