Skip to main content
Version: v5.7.0

6.5 Model a fleet properly

packs:infrastructurepacks:rpc

After this you can: apply the estate, templates and brokered access without new verbs: model a template, an asset and a fleet of hosts — then clean up.

Before you start — tick each one:

  • Lesson 6.4 — Hosts without a runner: brokered access (opschain learn 6.4)
  • Lesson 3.4 — Templates and versions (opschain learn 3.4)

In the GUI: the project page's Templates tab shows the pinned template, the asset's Actions tab lists patch_host and patch_fleet, and the Infrastructure entry under Estate lists the hosts the fan-out reached.

1. Model it properly: a template, an asset, and a fleet of hosts​

Lesson 6.4 called a pack action directly, with the command typed on the command line. Real estates do not run from one-liners: the operation belongs in a template repository (reviewable, versioned, pinned — lesson 3.3), an asset carries the catalog, and the hosts stay what they are — addresses with governance attached. Infrastructure nodes deliberately carry no action catalog of their own (opschain docs infrastructure-nodes has the full reasoning): the template version defines what can be done, the host nodes define where it reaches. This exercise builds that shape end to end.

First, register a second host beside jump01, so the fan-out later is real:

opschain infrastructure register \
--parent /projects/acme/environments/prod \
--code jump02 --name "Jump Host 2" --type host \
--address 10.1.9.5 --fqdn jump02.acme.example \
--properties '{"os_family":"linux","rpc":{"target":"deploy@10.1.9.5"}}'
/projects/acme/environments/prod/infrastructure/jump02 (host) lifecycle registered

No new authority is needed: lesson 6.4's grant scope /projects/acme/environments/prod/** is enforced at each host's own path, and it covers jump02 the moment the node exists.

Author the template repository. Host-targeted actions are ordinary declarative actions (lesson 2.1) that compose the pack's dialing actions (lesson 2.4). In a fresh git repository, lay out:

.opschain/
packs.yaml
templates/
hostops/
actions/
patch_host.yaml
patch_fleet.yaml

.opschain/packs.yaml mounts the infrastructure pack exactly as lesson 6.4 did:

packs:
- name: infrastructure
remote: opschain-pack-infrastructure
rev: <tag>

patch_host.yaml is the single-host shape: the action declares a host_ref input and hands it to the pack action, so which host it patches is the caller's typed decision at run creation — never repository content:

action: patch_host
description: Patch one registered host, named by reference
inputs:
host_ref: { required: true }
command: { default: "dnf -y update openssl" }
steps:
- name: patch
uses: infrastructure:host_exec
with:
host_ref: '${{ inputs.host_ref }}'
command: '${{ inputs.command }}'
collect_facts: "true"

patch_fleet.yaml is the fan-out shape. Fan-out is legal on run: and stage: steps (lesson 2.1), so the pack call sits inside a fanned-out stage, and the list comes from the filtered estate enumerate — every registered host under prod, enumerated server-side at step entry (opschain docs expressions):

action: patch_fleet
description: Patch every registered host under prod, in parallel
steps:
- stage: patch_hosts
strategy: parallel
for_each: ${{ estate('/projects/acme/environments/prod',
"type == 'host'").infrastructure }}
as: host
steps:
- name: patch
uses: infrastructure:host_exec
with:
host_ref: '${{ host.path }}'
command: dnf -y update openssl

Three facts worth pausing on. Each element is the host's hot-column descriptor — code, name, path, type, address, fqdn, lifecycle_state, never the property bag — so host.path is exactly the host_ref the pack action wants. The enumerate lists only live estate members the run's creator may read: a deregistered or destroyed host drops out of the next run's expansion automatically, and a host you cannot read never appears. And the expansion is capped by the orchestration.fan_out_limit setting (default 50) — past it the step fails loudly naming the cap.

Pin it and hand it to an asset. Commit, tag the revision (v1.0), push, then wire the repository in as lesson 3.3 taught — remembering that a new remote is unusable until a security administrator allow-lists it:

opschain git-remotes create acme --name hostops \
--url https://git.example.com/acme/hostops.git \
--username ci-bot --password-env ACME_GIT_TOKEN

opschain templates create acme \
--code hostops --name "Host Operations" \
--git-remote hostops \
--version 1.0.0 --git-rev v1.0

opschain nodes create acme/environments/prod/assets/fleet \
--template hostops:1.0.0

The asset's catalog refreshes in the background from the version's pinned commit; opschain actions list --project acme --env prod --asset fleet shows patch_host and patch_fleet beside the pack's own actions under Library.

Run the single-host action. The change runs on the asset; the host enters as a typed input:

opschain runs create change acme/environments/prod/assets/fleet \
--action patch_host \
-i host_ref=/projects/acme/environments/prod/infrastructure/jump01 \
--follow
created change 019a0d9b-4e02-7a11-8c7d-0f3b6e2a9d44 (patch_host on acme/environments/prod/assets/fleet) — pending
run 019a0d9b-4e02-7a11-8c7d-0f3b6e2a9d44 running
run 019a0d9b-4e02-7a11-8c7d-0f3b6e2a9d44 success (41s)

The step dials jump01 under the full brokered mechanism of lesson 6.4 — provider resolved at the host, your grant enforced at the host's path, the one-shot session in the masked environment variable — and collect_facts stamps fresh facts onto the node after the command.

Run the fleet action. Preview the expansion first, then run it:

opschain runs create change acme/environments/prod/assets/fleet \
--action patch_fleet --plan
opschain runs create change acme/environments/prod/assets/fleet \
--action patch_fleet --follow

The plan resolves the enumerate and renders the expansion per element — one patch child per registered host, jump01 and jump02 — before anything dials. The run then admits one transport session per host, each one independently resolved and independently granted.

Read the evidence. Lesson 6.4's audit verbs now tell the whole story:

opschain events --filter type=info:transport:* --since 1h

The fleet run landed one info:transport:session event per host, each admitted by your grant evaluated against that host's own path — the same grant, enforced twice, once per host. opschain infrastructure show on jump01 shows the facts freshness the piggyback stamped.

Prove it. Three reads, all of which must come back clean: the run log (opschain logs <run-id>) names the provider, the grant and the masked session variable but never a credential value; the session events carry identifiers only; and neither host node's properties document holds any credential material — the registry refuses those keys by name. What ran came from a pinned, reviewable commit; where it ran was decided by registered estate content and a grant at each host's path; and no standing credential exists anywhere in between.

2. Clean up​

opschain infrastructure deregister /projects/acme/environments/prod/infrastructure/jump01
opschain infrastructure deregister /projects/acme/environments/prod/infrastructure/jump02
/projects/acme/environments/prod/infrastructure/jump01 (host) lifecycle deregistered
/projects/acme/environments/prod/infrastructure/jump02 (host) lifecycle deregistered

Deregistration is the soft-close posture: the node leaves list defaults and estate reads (greyed in screens), its full history and events stay queryable, and registering the same code again revives the same node — identity survives the close. Unwind the authority the same way: opschain admin transport-grants revoke <id> terminally revokes a grant, opschain admin transport-providers bindings delete <scope> removes a binding, and deleting a provider leaves its vault values with the secrets plane. Revoke, reject and the deletes stay open on a lapsed licence — unwinding authority is never entitlement-gated.

Check yourself​

  • How many info:transport:session events did the fleet run land, and why that number?
  • What does deregistering a host leave behind, and what revives it?

What you learned​

  • The template version says what can be done; the host nodes say where it reaches; the asset carries the catalog.
  • A single-host action takes host_ref as a typed input; a fleet action fans out over the estate enumeration, capped by orchestration.fan_out_limit.
  • One grant is enforced once per host, at each host's own path.
  • Logs, events and node properties never hold credential material — three reads prove it.
  • Deregistration is a soft close: history stays, and the same code revives the same node.

Next: this closes the module The execution estate — back to your track (opschain learn).