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:sessionevents 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_refas a typed input; a fleet action fans out over the estate enumeration, capped byorchestration.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).