Rigs (Docker compose lifecycle)
ign rig manages a compose rig — a Docker compose project running
an Ignition gateway plus its satellites. It shells out to
docker compose (the v2 plugin line; compose ≥ v2 required — the
legacy docker-compose v1 binary is not supported and a missing/old
compose fails fast with exit 7 and an install hint). Every operation
starts with a one-shot resolve run (docker compose -f <file> --project-directory <dir> config --format json); the resolved project
.name — which honors the rig's own .env COMPOSE_PROJECT_NAME —
is the identity truth, and every later op passes it as an explicit
-p <name> (no implicit directory-name projects, ever).
Rig discovery (in this order)
| Level | Source | Notes |
|---|---|---|
| 1 | --rig NAME | looks up [rigs.NAME] in the config; unknown name → exit 7 with the known rigs listed |
| 2 | IGNITION_RIG env | same lookup (folded into the flag — one env→flag home) |
| 3 | [rig].default | the config's explicit preference — BEATS the cwd scan; a stale default is a loud exit 7 |
| 4 | cwd compose files | ./docker/compose.yml, ./docker/docker-compose.yml, ./compose.yml, ./compose.yaml, ./docker-compose.yml |
| 5 | WHK conventions | ignition-git-module/docker/docker-compose.yml, then whk-environment-orchestration/docker-compose.yml — each probed under BOTH ~/Documents/whiskeyhouse/ and ~/whiskeyhouse/ (first hit wins) |
Config surface:
[rig]
default = "git-module"
[rigs.git-module]
compose_file = "~/Documents/whiskeyhouse/ignition-git-module/docker/docker-compose.yml"
# project_name optional — omit to honor the rig's own .env
compose_file expands ~ and ${VAR}. IGNITION_RIG_ROOTS
(path-separated) overrides the convention home roots — for machines
whose WHK checkouts live elsewhere (and for test isolation). Nothing
found → exit 7 with the full search trail in the message.
The docker-only contract: profile: null
Rig verbs are the first commands with no gateway dependency: no
profile, secret, or client is resolved, and the envelope echoes
profile: null on both success and error. --profile has no effect
on rig.
rig up semantics
- compose version gate (fail fast, exit 7 + install hint);
- port pre-flight — a host port held by ANOTHER project's container
aborts before anything starts:
port 9088 in use by container X (rig Y)(same-project occupants are recreate-safe; a non-docker host process is attributed vialsofwhen available); up -d --wait --wait-timeout <N> --remove-orphans;- commissioned wait: the rig's own gateway port is derived from the
resolved mappings (first target 8088 →
http://localhost:<pub>, else target 443 → https) and its/StatusPingis polled header-less until RUNNING — same budget as--timeout.
Uncommissioned is data, not failure. A fresh-volume gateway
terminally 302s to /welcome — there is no headless commissioning
(verified: no commissioning endpoints exist). rig up then exits 0
with state: "uncommissioned" and the wizard URL inside warnings
(the version-command degradation precedent). A deadline reached while
still STARTING is a real exit-7 failure.
{"ok": true, "profile": null, "data": {"rig": "ignition-devops", "project": "ignition-devops",
"state": "uncommissioned", "gateway_url": "http://localhost:9088",
"warnings": ["gateway uncommissioned — open http://localhost:9088/welcome in a browser and complete the commissioning wizard (no headless commissioning exists)"]}}
Rig modules — opt-in, signed, never automatic
A rig can declare modules to provision. Nothing is installed unless you
ask for it: a rig with no modules table behaves exactly as it always
has.
[rigs.dev]
compose_file = "docker/compose.yml"
# Optional: only needed when the gateway service cannot be derived from
# the compose file (ign picks the first service publishing a port
# targeting 8088, then 443).
# module_service = "gateway"
[rigs.dev.modules.git]
version = "2.3.4"
[rigs.dev.modules.project-scan-endpoint]
version = "1.0.0"
Registered modules:
| Registry id | Source repository | Gateway module id | Min gateway |
|---|---|---|---|
git | WhiskeyHouse/ignition-git-module | com.axone_io.ignition.git | 8.3.1 |
project-scan-endpoint | bw-design-group/ignition-project-scan-endpoint | project-scan-endpoint | 8.3.0 |
The two rows differ in every column — including the gateway module id's
shape (reverse-DNS vs bare slug), which is why it is a separate field
rather than derived from the registry id. Only signed release
artifacts are ever installed; ign will not build, sign, or fetch an
unsigned module.
Trying one without editing config. --with-module ID@VERSION on
rig up and rig reset is repeatable and additive for that invocation
only. A flag wins over a config declaration for the same id. There is
deliberately no subtractive form — you cannot run without a
config-declared module; remove it from config instead.
ign rig up --with-module project-scan-endpoint@1.0.0
Fetch policy. A verified artifact is cached by version, and a cache hit makes no network request. Two flags change that:
| Flag | Effect |
|---|---|
--refresh | Re-checks the feed for the pinned version. If upstream now publishes a different digest than the one cached, this REFUSES (exit 6) and names both digests; the cached artifact is left untouched. |
--accept-upstream-change | Accepts a changed upstream digest and caches the new bytes — which are still verified against the newly published digest. Never implied by --refresh: accepting a re-released artifact is a deliberate act. |
The override file. ign writes compose.ign-modules.yml next to
the rig's compose file and passes it as a second -f. That file is
ign's: it is regenerated whole on every provisioning run, never
hand-merged. ign never writes to the compose file you wrote.
Deleting compose.ign-modules.yml stops the module being mounted, and the
next rig up with nothing declared removes the file for you.
It does not uninstall the module from the gateway. Ignition installs an accepted module into its own data directory, which lives in the rig's volume and outlives the mount — so a gateway that already loaded a module keeps reporting it after the override is gone (verified live, not inferred).
To remove it from the gateway, use the deliberate verb:
ign rig module uninstall git --yes
The id is a registry id — the same one --with-module and the config table
take, not the gateway's own module id. The verb needs IGNITION_TOKEN, the same
credential contract as rig snapshot and rig restore.
In the TUI it is the Rig screen's actions menu (a → module uninstall): a
form takes the registry id, an unknown id is refused locally against the same
registry, and a Confirm gate — the cockpit's --yes — states the
irreversibility before the write goes out.
The order matters, and the gateway enforces it. Undeclare the module, run
ign rig up so the mount goes, then uninstall. The gateway refuses to
uninstall a module whose .modl is still mounted. Once it succeeds the effect
is immediate — no gateway restart. ign refuses up front if the rig's config
still declares the module, rather than letting you get a confusing failure back
from the gateway.
This is not undone by re-declaring the module. A re-declared, re-mounted
module does not come back — verified live. Recovery today means ign rig reset,
which destroys the rig's whole data volume along with everything else in it.
That makes this the most severe of ign's guarded operations: the others cost
you a re-run, this one costs the volume. It refuses without --yes for that
reason, and it is a separate verb rather than something ign rig up offers,
because IGNITION_YES=1 would turn a mid-up prompt into no prompt at all.
When you stop declaring a module, ign rig up tells you. It drops the
mount and deletes the override, then reports the module rather than removing it
from the gateway:
$ ign rig up
...
orphaned: git (com.axone_io.ignition.git) — still installed on the gateway
remove with: ign rig module uninstall git --rig dev --yes
The same data is on the --json envelope, on an always-present key that is an
empty list when there is nothing orphaned:
{
"orphaned_modules": [
{
"id": "git",
"gateway_module_id": "com.axone_io.ignition.git",
"remove_with": "ign rig module uninstall git --rig dev --yes"
}
]
}
rig up does not remove it for you, for two reasons that are not style
preferences. IGNITION_YES=1 is a documented pattern for agents and CI, and it
merges into --yes — so a confirmation asked mid-up would be no confirmation
at all for exactly the callers least able to notice an irreversible act. And
rig up needs no gateway credential today; performing a gateway write would
change that contract for every user of the verb. Removal stays something you
type.
The report comes from ign's own override file, not the gateway's module
list. ign diffs what it provisioned last time against what you declare now,
so it can only ever report modules it installed itself. A module you installed
by hand is invisible to that diff and will never be named — which is the point:
the report leads to an irreversible command, so it must not point at something
ign did not put there.
Provisioning mounts a module; it does not commission one. No repository, credential, or module configuration is supplied — a provisioned module is present and accepted by the gateway, not set up.
rig status is an allowlist
Status NEVER passes through docker compose config/inspect output —
the resolved config contains GATEWAY_ADMIN_PASSWORD and friends.
The JSON data is exactly {rig, project, compose_file, services[]: {name, state, health, exit_code, publishers[]: {published_port, target_port, protocol}}, volumes: [names], ports_free} — all keys
always present (empty arrays when none). A down rig is exit-0 data
(ports_free: true, empty services).
rig reset semantics (the no-stale-state contract)
reset is the phase's destructive verb: it refuses without --yes
(exit 2, confirmation_required, hint names --yes and
IGNITION_YES=1) and the guard fires BEFORE any discovery runs — a
refusal costs nothing (no docker, no config scan; binary-pinned by
exiting 2 in a directory with no rig discoverable at all).
The cycle, in order:
- preview —
docker volume lslabel-filtered to the project: theremoved_volumesarray in the result data reports exactly what reset removes, as it acts; - compose version gate (exit 7 + install hint when absent);
down -v --remove-orphans— the LOCKED teardown:-vremoves the project's named AND anonymous volumes (gateway data, trial state, everything),--remove-orphanskills renamed-service strays —down && upwithout-vis the classic stale-state anti-pattern reset exists to kill;- port pre-flight with FRESH EYES — teardown frees the rig's own ports first; if another rig grabbed one mid-cycle, reset aborts with attribution and names the torn-down state in the hint;
up -d --wait(therig upinvocation verbatim);- commissioned wait — same semantics as
rig up: a fresh volume usually terminally reports the wizard, which is DATA (exit 0,state: "uncommissioned", wizard URL inwarnings); still-STARTING at the deadline is a real exit-7 failure.
The result data is {rig, project, removed_volumes, state, warnings} —
all keys always. No project, trial, or gateway state survives the
volume deletion; commission from scratch or restore a backup (04-04)
afterward.
rig logs is passthrough
rig logs streams docker compose logs output RAW: one line per
stdout line, no envelope in ANY mode — compose log lines are not
gateway JSON objects, and wrapping would corrupt them (rig logs --json is the same passthrough; contrast logs -f --json, whose
entries ARE gateway NDJSON). --tail N (default 200) bounds the
history; -f follows until Ctrl-C (default process kill, no
envelope); an optional SERVICE positional filters to one service.
Compose's own stderr diagnostics go to ign's stderr, never the
data stream.
rig trial — state, and the reset ladder
rig trial status is credential-free truth: both
GET /data/api/v1/trial and GET /data/api/v1/overview/banners
answer unauthenticated (live-verified on 8.3.3 and 8.3.6, expired AND
active states), so a fresh rig with no provisioned token reports its
trial state fine. The trial endpoint is the PRIMARY source
(trialState ∈ {AllInDemo, SomeInDemo, NoneInDemo},
trialSecondsLeft in seconds, the expired flag); the trial banner
is the cross-check — expireTime is epoch milliseconds or
null, and an EXPIRED trial shows severity:"warning" + null
(code expecting a future timestamp misreads expired as active), so
banners.active is computed as severity=="info" AND
expireTime>now_ms and never the reverse derivation.
rig trial reset runs the mechanism ladder (the spike-resolved
native approach — browser delegation rejected: it needs
Node+chromium, broke across 8.3.3's UI rewrite, and verifies via DOM
text):
- tier 0 — token-auth POST
POST /data/api/v1/trialwithX-Ignition-API-Token(token fromIGNITION_TOKEN; token mutations need no CSRF). One cheap call; on 2xx it wins. - tier 1 — native gateway login (the live-verified mechanism,
end-to-end on 8.3.3:
expired:true → false,0 → 7199s): the internal IdP's OIDC challenge dance (rotating tokens, ~4 captured cookies replayed by hand,webui-sid-<id>session cookie,csrfTokenfrom/data/app/session) → the reset POST with the session cookie +X-CSRF-Token. Credentials:--user(orIGNITION_USER) +IGNITION_PASSWORD— the password NEVER rides a flag, env only.
Success REQUIRES the read-back flip: after a 2xx the trial is
re-fetched and expired must be false — a bare 2xx never suffices
(the mutation-reads-back discipline). The result data is
{rig_url, mechanism: "token"|"login", expired_before, expired_after, trial_remaining_s}.
State gate (live-discovered): the gateway answers 403 to reset
attempts on a NON-expired trial (verified from the browser page with
the exact UI headers) — ign surfaces this honestly as exit 6
trial_not_expired naming the seconds left, instead of a misleading
auth error. Wait for expiry (watch rig trial status) or rig reset --yes for a completely fresh trial volume.
Verification note: the reset path is verified end-to-end against
8.3.3 (the git-module rig, by hand during 04-03) and the
login-flow machinery against 8.3.6 (ign-research; steps 1–5 +
bad-credential shapes). Repeatable live gates ship as #[ignore]
tests (cargo test -p ignition-core --test trial_contract -- --ignored with IGNITION_LIVE_URL + credentials +
IGNITION_LIVE_MUTATIONS=1 against an expired rig). A tier-2
Playwright fallback exists ONLY as this documented env contract —
ignition-trial-resetter / WHK-Global's e2e/reset_trial.mjs — and
is never shipped as ign's mechanism.
rig snapshot / rig restore — repeatable state
rig snapshot composes a rig's gateway state HONESTLY into one
directory (default ./ign-rig-snapshots/<rig>-<yyyyMMdd-HHmmss>/,
-o overrides; the stamp is std-only, no clock dependency):
<rig>.gwbk— the native roaming backup (GET /data/api/v1/backup?type=roaming,Accept: application/octet-stream), STREAMED to disk chunk-by-chunk (never buffered in memory — gwbks are tens of MB);projects/<name>.zip— one export per runnable project (theproject exportmachinery reused; file names percent-encode the project name injectively, soMy Project→My%20Project.zip). The gwbk should already contain projects (postman semantics, MEDIUM confidence), but the explicit exports are the honest redundancy that makes the manifest truthful either way;manifest.json— the composition record:{rig, taken_at (epoch s), ignition: {version}, gwbk, projects: [{name, file}], notes}. The notes name BOTH exclusions explicitly, verbatim: the trial clock is NOT captured by gwbk (its restore behavior is unknown — reset separately viarig trial reset), and tag-provider bulk export is Phase 5 scope (gwbk captures tag config via gateway data). No reader can mistake either for a silent drop.
rig restore --file <path.gwbk> is the guarded inverse
(--yes-refusal exit 2 before any discovery, the fifth destructive
verb). The POST is a RAW application/octet-stream body — NOT
multipart — with the four scope params (restoreDisabled,
disableTempProjectBackup, renameEnabled, restoreLocal) sent
explicitly as false. Restore is synchronous AND the gateway
restarts afterward, so BOTH wire directions ride a 300 s per-request
budget and success is a witnessed post-restore
StatusPing→RUNNING (the --timeout budget floors at 300 s — a short
explicit timeout cannot buy an unknown-state mid-restart report).
A bare 2xx never suffices.
⚠ API tokens may be clobbered (Pitfall 5). Tokens stored under
CORE config (data/config/CORE/ignition/api-token) are
"modified/cleared often by gwbk restores" (83-api) — after a
restore, stored profiles may 401. The restore data ALWAYS carries
"API tokens may have been reset by restore — re-provision via gateway UI, then ign doctor" as its first warning, in every render
mode. 83-api's recommendation: keep durable tokens in an EXTERNAL
location, not CORE config.
Both verbs address the rig's derived gateway URL and source their
credential from IGNITION_TOKEN (the rig-family chain — the backup
route 401s unauthenticated, unlike the trial endpoints; a missing
token is exit 3). The round-trip is pinned live by the opt-in e2e
gate:
IGNITION_LIVE_URL=http://localhost:9088 \
IGNITION_LIVE_TOKEN='name:key' \
IGNITION_LIVE_MUTATIONS=1 \
cargo test -p ignition-cli --test e2e_rig -- --ignored
(the rig must be discoverable — run from the rig's checkout, set
IGNITION_RIG, or configure [rig].default; verify with ign rig status first that the intended rig is the one UP — the gateway
verbs address the derived localhost:<port> URL, and a port
collision with another stack would silently point them at the WRONG
gateway). The gate snapshots a
pre-witness project, creates a post-snapshot marker, restores, and
asserts BOTH halves: witness SURVIVED, marker GONE.
Gateway backups (ign backup)
The standalone gwbk surface on ANY profiled gateway — the same wire
rig snapshot/rig restore ride (Phase 4), without the rig
machinery:
ign backup download [-o FILE] [--type roaming|all]— a streamed read (never buffered in memory; 300 s per-request timeout).roamingis the portable backup (cross-gateway — the default);allincludes gateway-specific state. Default filename follows the export convention: the gateway'sContent-Dispositionbasename when it sends one, else<profile>-backup.gwbk(streamed to a.partfirst, renamed on completion — a failed download leaves no half-written impostor).ign backup restore <FILE> --yes— REPLACES this gateway's state from the gwbk. Destructive: the 8th--yes-guarded verb (guard BEFORE resolution — a refusal does zero work). The restore POST is synchronous; the gateway then RESTARTS and blocks for minutes — the 2xx is acceptance, not readiness (ign wait restartis the readiness witness if you want one;rig restorebundles that wait for rigs). API tokens inside the backup may clobber current ones — see therig restoretoken warning, same mechanics here.- There is NO
ign backup list— no native listing endpoint exists (exactly GET/POST on/data/api/v1/backup). Honesty over verb theater: your filesystem is the list. The EAM archive store (storage/archived-backups) is a DIFFERENT thing — it belongs to the EAM controller's fleet backups, not this gateway's gwbk files.
The trial clock is NOT captured by a gwbk (see the rig snapshot exclusions — same mechanics).