6.3 Fleets, groups and placement
Included from Basic tier
After this you can: start a fleet, use the example runner image, group runners by attributes, place work on them, read the fleet view and make everyday fleet moves.
Before you start — tick each one:
- Lesson 6.2 — Your first runner (
opschain learn 6.2)
In the GUI: the Runners panel in the Operations area shows the groups and their members with derived state, the drain/resume/forget verbs, and the queue backlog with each entry's placement verdict; the Overview's group cards carry per-group demand. New group… and each row's Edit group… open the group dialog — description, the Exclusive switch, the attributes as key/value rows offered from the registered vocabulary, the bindings one path per line — and the Placement attributes panel below the queue registers keys and their allowed values; both render the server's refusals verbatim.
Lesson 6.2 enrolled one runner. This lesson turns that into a fleet: several members, grouped and attributed so work lands where it should, read through the fleet view, and moved with the everyday verbs. Every fleet verb needs administrator rights (lesson 6.2).
1. Starting a fleet by hand
Three moves put a runner on the fleet: mint a registration token, start the runner with a state directory of its own, and watch it appear in the fleet view. Minting needs admin rights, and the token value prints exactly once — capture it:
opschain login --server https://opschain.example.com
TOKEN=$(opschain admin runner-token create \
--server https://opschain.example.com --group iac)
Your own login session is the credential (automation uses a
service-account API key in OPSCHAIN_ADMIN_TOKEN; the server's static
admin token works only on local-lane compositions — a full-auth server
refuses it).
Minting for a new group name creates the group (subject to the licence's group entitlement), so there is no separate "create a group" step. Now start a member — in the foreground, one terminal per member:
opschain runner --server https://opschain.example.com --token "$TOKEN" \
--dir ~/opschain-runners/iac-1 --name iac-runner-1
- Every member gets its own
--dirstate directory, never shared — it holds the durable identity lesson 6.2 described.--tokenis needed on the first start only; restart with the same command minus--tokenand the saved identity re-attaches the same fleet member. - One token enrols any number of members within its TTL (default 1h;
cap redemptions with
--max-uses) — repeat the start with a fresh--dirand--namefor each. - Stopping is graceful: Ctrl-C (SIGTERM) drains — the runner finishes
its in-flight job, says goodbye (the fleet view reads it
stoppedimmediately) and exits. A second signal stops hard.
The containerised equivalent runs the runner image with the same
ingredients as environment variables and the state directory bound to
/var/opschain/runner:
docker run -d --name iac-runner-2 --restart unless-stopped \
-v "$HOME/opschain-runners/iac-2:/var/opschain/runner" \
-e OPSCHAIN_SERVER=https://opschain.example.com \
-e OPSCHAIN_RUNNER_TOKEN="$TOKEN" \
-e OPSCHAIN_RUNNER_NAME=iac-runner-2 \
limepoint/opschain-runner:local
docker stop delivers the same SIGTERM goodbye, and docker start
re-attaches the kept identity. One container gotcha: from inside a
container, localhost is the container itself — point OPSCHAIN_SERVER
at a URL the container can reach (host.docker.internal for a server on
the docker host).
2. The example runner image
examples/opschain-runner-image builds limepoint/opschain-runner:local
— a containerised runner carrying a pinned, checksum-verified IaC
toolchain (tofu, terraform, ansible-playbook, kubectl, aws,
az, gcloud, gh, python3, git — every tool a build-arg layer
defaulting ON; trim with --build-arg WITH_<TOOL>=0) plus the
opschain binary, paired with the common action packs (lesson 2.4):
cd examples/opschain-runner-image
OPSCHAIN_PACKAGE=/path/to/opschain_v5.7.0_linux_arm64.tar.gz ./build.sh
Start members from the built image with the docker run form in
section 1.
Three principles the image demonstrates, worth copying into your own:
- Tools come from the image, never from run time. A step needing a missing binary fails loud rather than installing things mid-run; tool version bumps are build args — change one place, rebuild.
- Credentials never bake into layers. Secrets reach steps at run time via vault-backed properties, not the image.
- The runner binary comes from an official released package. Customer
image builds feed
build.shthe releasedopschain_<version>_linux_<arch>.tar.gz— never a source build (DEV_BUILD=1exists for LimePoint development only).
Vault credentials ride the runner, not the settings
When templates or actions call the secret vault (SecretVault.get_password),
the provider configuration comes from the secrets_providers section of
the runner's own config.yaml — one entry per provider the estate
registered with resolution: runner — projected into every hostexec
step. Write it before you start the fleet (opschain docs runner-executors;
a host still carrying the retired OPSCHAIN_VAULT_* variables migrates
them once with opschain runner migrate-vault-env). They are deliberately
NOT settings: a vault credential
stored in settings would need the vault to decrypt itself. The runner's
own token stays with the runner: each step receives a short-lived child
token minted for it at dispatch and revoked when the step finishes, so
action code never holds the fleet credential — and if your vault refuses
child minting, steps fall back to the runner's token with a named notice
at the top of the step log. Which fleet a
node's work runs on — and therefore which vault serves it — is the
runner.group setting, so "this project uses that vault" is a routing
decision, not a credential one. The MintPress guide
(opschain learn pack-mintpress) covers the SDK fleet's vault
environment and routing end to end.
3. Groups and placement
Runners belong to groups; work lands on groups. Three placement mechanisms, resolved once per step at enqueue:
-
Name the group. A change can target one directly:
opschain runs create change acme/environments/dev/assets/web \--action deploy --metadata runner.group=iacNode settings carry the same
runnerblock to make a subtree's default (leaf-most node wins), so "everything under prod runs on the prod runners" is configuration, not per-change discipline. -
Select by attribute. Admins register attribute keys (
location,networkship reserved) and tag groups with values; work then demandsselectors({"runner": {"selectors": {"location": "dc1"}}}in metadata or node settings) and any matching group's runners qualify. Unregistered keys or values are refused at write time, not silently never-matched. -
Bind a subtree.
--binding /projects/acmeon a group restricts that subtree's work to the bound groups' runners — a hard zone, and bound work never escapes it.--exclusiveis the converse: the group's runners take only work that names or selects them.
opschain admin runner-attributes set location --values dc1,dc2
opschain admin runner-group set iac --exclusive --attribute location=dc1
opschain admin runner-group list
NAME EXCLUSIVE ATTRIBUTES BINDINGS DRAINING
default false - - false
iac true location=dc1 - false
opschain admin runner-group delete iac is refused while runners or
redeemable tokens still reference the group — forget the runners and
let the tokens expire first.
The same two writes sit on the Runners page: the Placement attributes
panel registers location with its allowed values, and the group
dialog (New group…, or Edit group… on the iac row) sets the
exclusive flag, the location=dc1 attribute and any bindings. Saving
the dialog replaces the whole definition, exactly as runner-group set
does — re-state everything the group should keep.
One more routing rule rides beside placement: an actions.rb change holds
one runner for its whole run (run-scoped execution; actions.rb is only
applicable to existing MintPress customers and those using the MintPress
SDK or the packs:mintpress feature). Its first step pins
the run to whichever eligible runner takes it; every later step queues
behind that same runner and shares its workspace, so a file one step
stages on disk is there for the next — upstream's single change pod,
preserved. Declarative/YAML runs spread per step as usual, and
{"runner": {"execution_scope": "run" | "step"}} in metadata or node
settings overrides either default. If the pinned runner dies mid-run the
run fails with an event naming it (lesson 6.1's lost-runner cases) —
steps are never silently re-spread.
By default the pinned runner executes the run one step at a time; give
the same runner block a max_parallel_steps (a positive integer, e.g.
{"runner": {"max_parallel_steps": 4}}) and it executes up to that many of
the run's parallel steps at once in the one shared workspace —
upstream's in-pod parallelism, preserved — while still never taking other
runs' work mid-run (opschain docs dispatch). A runner can cap that to its
own resources: start it with --max-parallel-steps N (or
OPSCHAIN_RUNNER_MAX_PARALLEL_STEPS) and it executes at most
min(the run's setting, N) steps at once, logging one honest notice on the
run naming both numbers whenever the clamp binds; the declared capacity
shows beside the executors in admin runners list/runner show.
Work nobody can take stays truthfully queued with a reason (no registered runner satisfies the placement requirement …) rather than
hanging on a phantom lease, and
opschain admin runner-group demand <name> reports {queued, running}
per group — the autoscaler signal: queued counts work the group could
satisfy, even with zero members online:
opschain admin runner-group demand iac
{
"group": "iac",
"queued": 3,
"running": 1
}
opschain admin queue list shows every queued and leased entry with its
owning work item — a change, a workflow run, or a node background task
(a Regenerate Actions job, which the activities feed does not show) — plus
the target node path and correlation chain. A stuck entry terminates with
opschain admin queue terminate <run-id> (cancelling its owner through
the owner's own lane, with an audit event naming you), and
opschain admin queue sweep clears leftover entries whose owner already
finished.
4. The fleet view
opschain admin runners list
GROUP NAME ID ATTRIBUTES ONLINE LAST SEEN JOB STATE
default default-runner-1 ocr_m9X6WFUT7QWg5VfO616Qk4XNdbEG6qX2 - ✓ 2026-10-12T09:51:12Z - ready
default default-runner-2 ocr_7cg70dCQOW6r7kYhfibG8nmxPY0MDVaQ - ✓ 2026-10-12T09:50:53Z - ready
iac opschain-runner-iac-1 ocr_o5jMGidhzIdZA7W1GTk1hyDr_hw9yfPn - ✓ 2026-10-12T09:51:16Z - ready
iac opschain-runner-iac-2 ocr_VrT1gtRQMEbYH7rMBThTtd4LkMrVEM4m - ✓ 2026-10-12T09:51:06Z - ready
STATE is the lifecycle: ready, draining (hold in force, finishing
in-flight work), quiesced (hold in force, idle), stopped (clean
goodbye — immediate, versus the three-minute age-out for a crashed
runner), - (offline, nothing held). Group-scoped holds read
quiesced (group) / draining (runner+group).
opschain admin runner show <runner-id> is the detail surface — worth a
look at least once (trimmed here to the highlights):
ID ocr_m9X6WFUT7QWg5VfO616Qk4XNdbEG6qX2
NAME default-runner-1
GROUP default
STATE ready
…
EXECUTORS hostexec,containerexec
OS/ARCH darwin/arm64
…
HOSTNAME runner-host-1.example
CPUS 10
…
RUNNER VERSION v5.7.0
…
STATE DIR ~/.opschain/runners/default-1
OBSERVED REMOTE [::1]:51354
KEY FINGERPRINT SHA256:o6U8mWokVl9xVhaBMKvkTiF4UvRCNotZOwtEgNNSBvI
Identity, lifecycle, current jobs, the host inventory it self-reported at registration, the server-observed peer address, and the key's fingerprint (the raw public key is never rendered).
5. Everyday fleet moves
opschain admin runner drain <runner-id> # finish in-flight, take nothing new
opschain admin runner resume <runner-id>
opschain admin runner-group drain iac # the same, group-wide
opschain admin runner-group resume iac
opschain admin runner forget <runner-id> # remove a stale registration row
Each verb answers with the flag it set, and records who set it
(info:runner:drain_changed, info:runner:forgotten):
opschain admin runner drain ocr_o5jMGidhzIdZA7W1GTk1hyDr_hw9yfPn
{
"draining": true,
"id": "ocr_o5jMGidhzIdZA7W1GTk1hyDr_hw9yfPn"
}
The step boundary is the drain boundary: draining never cancels
in-flight work, it just stops offering new work — which makes
drain-replace-resume the safe patching loop for runner hosts. forget is
cleanup, not revocation (the identity may re-register, and an online or
job-holding runner is refused — drain and stop it first).
6. Provisioning runners
Hand-started workstation fleets are the dev loop; for real environments there are two provisioning lanes, each documented end to end (customer installs and image builds always start from official released opschain packages, never a source build):
- A hostexec runner on a host you own — the production hardening
checklist: a dedicated user and a
0700state directory, installing the binary from the released package, minting a registration token (opschain admin runner-token create), first-start enrolment, a systemd unit (and macOS launchd note), fleet verification, and the lifecycle (drain, upgrade in place, decommission). - The
opschain-runnerimage (examples/opschain-runner-image, default image name everywhere:limepoint/opschain-runner) — the containerised lane of section 2, carrying the full pinned IaC toolchain (OpenTofu, Terraform, Ansible, kubectl, AWS/Azure/GCP CLIs, gh) as build-arg layers defaulting ON; trim per fleet with--build-arg WITH_<TOOL>=0.
Check yourself
- How does a change choose a runner group, and what does
opschain admin runner-group demandreport for a group whose work is queued with no member online?
What you learned
- One token enrols any number of members; each keeps its own state directory, and Ctrl-C drains gracefully.
- The example runner image bakes tools in and credentials out; vault credentials ride the runner's environment, never settings.
- Work lands on groups by name, by registered attribute selectors, or by subtree binding; unsatisfiable work queues with a reason.
admin runners listandadmin runner showare the fleet view; STATE is the lifecycle vocabulary.- Drain, resume and forget are the everyday moves, and the step boundary is the drain boundary.
Next: lesson 6.4 — Hosts without a runner: brokered access (opschain learn 6.4), or back to your track (opschain learn).