CLI
Single binary: stratum. All commands operate against one or more config files (default stratum.strat) and a state file (default .stratum/state.json).
stratum <COMMAND>
| command | purpose |
|---|---|
plan | Print the diff between config and state. |
apply | Execute the plan and save state. |
status | Print per-host resource usage and per-container stats. |
state list | List resources currently tracked in state. |
state show | Print one resource's full state as JSON. |
state merge | Merge two or more state files into one. |
Global flags
These flags work on any subcommand (clap's global = true).
| flag | short | default | description |
|---|---|---|---|
--env-file <PATH> | none | Load env vars from a .env-style file before resolving secret { from_env } refs. Repeatable to layer multiple files. See --env-file and .env auto-load below. | |
--namespace <NAME> | -n | none | Operate within the named namespace declared in the manifest. Resolves -c from the namespace's configs = [...] and -s to .stratum/<name>.json. See Namespace mode below. |
--manifest <PATH> | auto | Override the manifest path used by -n. Default: ./stratum.strat if it exists. Only consulted when -n is set. |
Namespace mode (-n and --manifest)
A namespace is a deployable slice declared in a top-level manifest. When -n NAME is set, the CLI:
- Locates the manifest. If
--manifest PATHis set, that path is used. Otherwise./stratum.stratis required to exist; if it doesn't, the command errors out (requires a manifest, but ./stratum.strat does not exist). - Loads the manifest and looks up the named namespace. If it isn't declared, the error names every known namespace from the manifest.
- Resolves
-cfrom the namespace'sconfigs = [...]. The manifest itself is always the first file in the merged set, so itshost/secret/providerblocks are visible to every per-namespace config. - Resolves
-sto (in priority order) the explicit-son the command line, the namespace's body-levelstate = "...", or.stratum/<name>.json.
stratum -n infra plan # uses ./stratum.strat, configs from `namespace "infra"`
stratum -n app apply -y # state at .stratum/app.json
stratum --manifest deploy/manifest.strat -n web apply -y
-n and -c are mutually exclusive. Passing both errors out — the namespace's configs = [...] is the config list, and a -c override would silently shadow the manifest's intent. Drop the -n to operate in bundle mode, or remove the -c to let the namespace pick.
-s is not mutually exclusive with -n. The CLI default is .stratum/state.json; the namespace's default is .stratum/<name>.json. If you want to point a namespace's apply at a custom state file (typically during migration from bundle mode), pass -s explicitly and it wins.
Split state
In namespace mode, state writes to two files instead of one:
<state_path>(e.g..stratum/<name>.json) — every user-declared resource.<state_path's directory>/_shared.json— every implicit per-host_stratum_*resource (auto-injected swap, sshd OOM tuning, sshd reload).
The shared file is what lets multiple namespaces target the same host without each one trying to recreate the tuning resources. The first namespace's apply creates them; subsequent namespaces see them as no-op. See Architecture: split state for the merge rules.
Cross-namespace conflict checks
Before classification, plan and apply in namespace mode re-load every sibling namespace's configs (with unresolved secrets tolerated) and walk every docker_container they declare. Two collision classes are caught:
- Host port — two namespaces declare a
docker_containerbinding the same(host, host_port). The check parsesH:CandIP:H:Cshapes; ranges and bare-port-random forms are skipped. - Container name — two namespaces declare a
docker_containerwith the same(host, name). The default fornameis the resource's label.
Both error messages name the offending resource and the sibling that already claims the port or name:
cross-namespace port conflict on host `root@192.0.2.10`:
- app::docker_container.web (current) wants 80
- infra::docker_container.traefik (sibling) already claims it
The check runs at plan time and is skipped entirely in bundle mode (no -n).
--env-file and .env auto-load
secret { from_env = "X" } reads std::env::var("X") at config-load time. To avoid having to export X=... (and to keep credentials out of shell history), stratum can load env files before resolving secrets.
stratum --env-file .env.prod plan -c bootstrap.strat
stratum --env-file base.env --env-file overrides.env apply -y
Rules:
- Auto-load. If
--env-fileis not passed at all, stratum auto-loads./.envif it exists. Auto-load is silent on miss — no error if there's no.env. - Explicit list. If
--env-fileis passed one or more times, only those files are loaded. The auto-.envis not consulted. Every listed path must exist and parse, or the command errors before doing any work. - Process env wins. A variable already set in the process environment is never overwritten by a file. This is the 12-factor rule —
FOO=x stratum applykeepsFOO=xregardless of what.envsays. - First-set wins among files. When
--env-file a --env-file bis passed,ais parsed first; a variable set inais not overwritten byb. The CLI prints[env] loaded <path>to stderr for every file successfully loaded.
Useful with secret { from_env = ... }:
# .env
PG_PASSWORD=correcthorsebatterystaple
GH_PAT=ghp_...
secret "pg_password" { from_env = "PG_PASSWORD" }
secret "gh_pat" { from_env = "GH_PAT" }
The .env file is not committed (add it to .gitignore). For team workflows, check in a .env.example with placeholder values instead.
plan
Print what would change if applied. Read-only — never writes state. By default never contacts a host either; pass --refresh to query live state.
stratum plan
stratum plan --refresh
stratum plan -c infra.strat -c app.strat -s .stratum/host.json
stratum plan --allow-unresolved-secrets
stratum -n app plan # namespace mode
| flag | short | default | description |
|---|---|---|---|
--config <PATH> | -c | stratum.strat | Path to a .strat config file. Repeatable: every -c file is merged into one document and evaluated together. See Multi-file configs. |
--state <PATH> | -s | .stratum/state.json | Path to the JSON state file. |
--refresh | off | Query live hosts via Provider::read and annotate drift between state and reality. | |
--allow-unresolved-secrets | off | If a secret block's source is missing (env unset, file unreadable), substitute a <unresolved-secret:NAME> placeholder instead of failing. Plan-only — apply refuses any plan containing placeholders. See Secrets: --allow-unresolved-secrets. |
Output is a list of resources prefixed with an action symbol:
| symbol | action |
|---|---|
| no change |
+ | create |
~ | update (with field-by-field diff) |
- | delete |
Followed by a summary line: N create, N update, N delete, N no-op.
Secret rendering
A leaf value that is a secret marker (in either prior or desired) prints as <secret:NAME sha:abc123>, where abc123 is the first six hex chars of the SHA-256 of the plaintext. Enough to spot a rotation; not enough to attack offline. See Secrets.
~ docker_container.stratum-postgres
~ env.POSTGRES_PASSWORD: <secret:pg_password sha:f7c3bc> -> <secret:pg_password sha:9a1e44>
--refresh: live drift detection
When --refresh is set, stratum calls each provider's read method for every step that has prior state (i.e. not Create), compares the result against what's recorded, and prints per-resource annotations:
docker_container.traefik
! DRIFT: image: state="traefik:v2.11" observed="traefik:v3.0"
system_service.docker
! DRIFT: resource missing on host (state says exists)
A footer line summarizes:
drift: clean
Or, when there's drift:
drift: 2 differ, 1 missing, 4 unreadable
- differ —
readreturned data that doesn't match state on at least one field. - missing —
readreturnedAbsent(the resource is gone on the host but still in state). - unreadable — the provider returned
Observed::Unknown(noreadimpl, orreaderrored). Forsystem_ufw_ruleandssh_exec, this is always the case by design.
Drift detection is one-sided: fields that exist in state but not in the observed data are ignored (providers don't surface every field). It is also marker-aware — a state-side secret marker is hashed against an observed plaintext, so secret-bearing fields don't perpetually drift on every refresh. See Architecture: drift detection and Secrets: drift detection.
Create steps are skipped during refresh — there's no prior state to compare.
apply
Compute the plan, print it, and — if confirmed with -y — execute side effects against remote hosts.
stratum apply -y
stratum apply -y -c infra.strat -c app.strat -s .stratum/host.json
stratum apply -y --allow-destroy
stratum -n app apply -y # namespace mode
| flag | short | default | description |
|---|---|---|---|
--config <PATH> | -c | stratum.strat | Path to a .strat config file. Repeatable, like plan -c. See Multi-file configs. |
--state <PATH> | -s | .stratum/state.json | Path to the JSON state file. |
--yes | -y | off | Skip the confirmation gate and execute. Without it, apply prints the plan and exits. |
--allow-destroy | off | Permit Delete steps. Required whenever the plan would remove a resource that's in state but absent from config. See --allow-destroy below. |
apply refuses to run a plan containing any <unresolved-secret:NAME> placeholder — those only appear via plan --allow-unresolved-secrets, and they're a plan-only construct. Resolve the secret's source and retry.
Confirmation gate: without -y, apply prints the plan and stops with Apply? Re-run with -y to execute against remote hosts. There is no interactive prompt — re-run with -y to proceed.
When -y is set, you'll see this banner before the work starts:
!! Applying: side effects WILL execute on remote hosts.
State is written to --state after every successful apply.
Post-apply self-check
After a successful apply, stratum re-loads the config, rebuilds the plan against the freshly updated state, and runs refresh_plan against the live host. The result is one summary line:
post-apply drift: clean
Or, when something is off:
post-apply drift: 1 differ, 4 unreadable — run 'stratum plan --refresh' to see details
This catches resources that "applied successfully" according to the provider but don't actually match reality (rare, but possible — e.g. a systemd service that exits zero from start but immediately crashes). Unreadable counts are expected when your config includes system_ufw_rule or ssh_exec resources; both return Unknown from read by design.
If the plan was a no-op (Nothing to do.), no apply runs and no post-apply check happens.
--allow-destroy: the destruction guard
This flag exists because of a failure shape that's easy to hit by accident. Apply a config against a state file that holds resources the config doesn't declare — by forgetting a -c, by pointing -s at the wrong file, by reusing a bundle state for one slice of a larger deployment — and build_plan will emit a Delete step for every resource in state but not in config. Without a guard, apply executes those deletes, and a single mis-typed command tears down the whole host: containers, networks, services, packages, ufw rules, in roughly the order BTreeMap iteration of <kind>.<name> produces.
The fix is two-part. The structural fix depends on how you organize configs: one state file per host with multi-file configs, or one state file per namespace. The tactical fix is the destruction guard: if a plan contains any Delete step, apply refuses to run unless --allow-destroy is set.
refusing to apply: plan would delete 9 resources not in config:
- system_ufw_rule.allow-ssh
- system_service.ufw
- system_service.docker
- system_package.ufw
- system_file.traefik-config
- docker_network.edge
- docker_container.traefik
- ...
loaded configs: app.strat
state file: .stratum/host.json
If this is intended, re-run with --allow-destroy. If not, you may be applying against the wrong state file (-s), or have forgotten a -c flag.
The check fires when PlanSummary.delete > 0 — the list is every step with Action::Delete. The error names:
- the resources slated for deletion,
- the loaded config files (so a missing
-cis visually obvious), - the state file path (so a wrong
-sis too), - three likely causes.
The guard runs before the -y confirmation gate, so you'll hit the same bail with or without -y.
When to pass --allow-destroy:
- You really did remove a resource from config and want it gone on the host.
- You're tearing down a whole config (commented out resources, intend a full sweep).
When not to pass it: any time the delete list surprises you. Re-check -s and your -c set.
status
Snapshot per-host resource usage. For each unique host declared in the loaded configs, stratum ssh's the host and prints uptime + free memory + root-disk usage + per-container CPU/RAM/IO from docker stats --no-stream. One section per host.
stratum status
stratum status -c infra.strat -c app.strat -s .stratum/host.json
stratum status --host root@192.0.2.10
| flag | short | default | description |
|---|---|---|---|
--config <PATH> | -c | stratum.strat | Config file(s) to enumerate hosts from. Repeatable. |
--host <FILTER> | none | Only query the matching host. Compared against both the host's addr and the block label (host "name" {...}). |
The state file is not consulted — status doesn't read or write state. It only needs host block declarations.
Output shape:
=== root@192.0.2.10 ===
up: 17:48:12 up 3 days, 4:21, 1 user
load: 0.42, 0.31, 0.19
mem: Mem: 1.9Gi 1.3Gi 85Mi 19Mi 559Mi 625Mi
swap: Swap: 4.0Gi 500K 4.0Gi
disk: /dev/vda1 79G 18G 61G 23% /
CONTAINER CPU% MEM USAGE / LIMIT MEM% NET I/O BLOCK I/O
traefik 0.18% 34.2MiB / 1.9GiB 1.78% 1.2MB / 4.5MB 0B / 12.3kB
web 0.04% 12.1MiB / 1.9GiB 0.62% 245kB / 89kB 0B / 0B
db 0.21% 112MiB / 256MiB 43.75% 332kB / 412kB 2.4MB / 1.8MB
cache 0.08% 8.4MiB / 64MiB 13.13% 189kB / 167kB 0B / 0B
The probe is a single composite shell snippet (one ssh round-trip per host) with sentinel headers (===UPTIME===, ===STATS===) to keep parsing simple. docker stats --no-stream is one snapshot, not the streaming default. If docker is absent or there are no running containers, the section prints (no docker stats available) and moves on.
Errors:
- A
hostblock with noaddris skipped with a[status] skipping hostwarning. - An SSH failure on one host is logged with
[status] <addr>: failed: <reason>but does not stop the loop — remaining hosts are still queried. --hostfiltering with no match is a hard error:no host matched filter `<arg>`.
status is a between-applies diagnostic — not a replacement for a real monitoring stack. The headline use case is spotting RAM/CPU pressure (a container creeping toward its memory limit, a host swap-thrashing) without leaving stratum's CLI.
state list
List every resource currently in state.
stratum state list
stratum state list --path infra-state.json
| flag | short | default | description |
|---|---|---|---|
--path <PATH> | -p | .stratum/state.json | Path to the JSON state file. |
Output is one line per resource: <kind>.<name> [<provider>]. Empty state prints (empty state).
state show
Print the full JSON for a single resource.
stratum state show docker_container.traefik
stratum state show ssh_exec.uptime --path infra-state.json
Positional argument:
| arg | description |
|---|---|
<addr> | Resource address, <kind>.<name> (split on the first .). |
| flag | short | default | description |
|---|---|---|---|
--path <PATH> | -p | .stratum/state.json | Path to the JSON state file. |
Prints the pretty-printed ResourceState JSON, or not found if the address is not in state.
state merge
Merge two or more state files into one. Used to consolidate per-config state files (e.g. .stratum/infra.json, .stratum/app.json) into one state per host.
stratum state merge \
-o .stratum/host.json \
.stratum/infra.json \
.stratum/app.json
| arg / flag | short | default | description |
|---|---|---|---|
<INPUTS>... | — | Source state files. At least two are required. | |
--out <PATH> | -o | — | Output path. Must not already exist — refuses to overwrite. Remove the file or pick a different -o to retry. |
On success:
merged 2 state files into .stratum/host.json (13 resources)
Failure modes:
- The output path already exists →
refusing to overwrite existing state file .... - Any
<kind>.<name>key appears in more than one input →key ... present in both X and Y — refusing to merge. There is no last-writer-wins. Resolve the collision (rename one resource, or remove it from one state) and retry.
After merge, verify by running stratum plan against the consolidated state with the full -c set — a non-zero diff means something is off and the old per-config files should not be deleted yet.