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>
commandpurpose
planPrint the diff between config and state.
applyExecute the plan and save state.
statusPrint per-host resource usage and per-container stats.
state listList resources currently tracked in state.
state showPrint one resource's full state as JSON.
state mergeMerge two or more state files into one.

Global flags

These flags work on any subcommand (clap's global = true).

flagshortdefaultdescription
--env-file <PATH>noneLoad 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>-nnoneOperate 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>autoOverride 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:

  1. Locates the manifest. If --manifest PATH is set, that path is used. Otherwise ./stratum.strat is required to exist; if it doesn't, the command errors out (requires a manifest, but ./stratum.strat does not exist).
  2. Loads the manifest and looks up the named namespace. If it isn't declared, the error names every known namespace from the manifest.
  3. Resolves -c from the namespace's configs = [...]. The manifest itself is always the first file in the merged set, so its host / secret / provider blocks are visible to every per-namespace config.
  4. Resolves -s to (in priority order) the explicit -s on the command line, the namespace's body-level state = "...", 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_container binding the same (host, host_port). The check parses H:C and IP:H:C shapes; ranges and bare-port-random forms are skipped.
  • Container name — two namespaces declare a docker_container with the same (host, name). The default for name is 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-file is not passed at all, stratum auto-loads ./.env if it exists. Auto-load is silent on miss — no error if there's no .env.
  • Explicit list. If --env-file is passed one or more times, only those files are loaded. The auto-.env is 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 apply keeps FOO=x regardless of what .env says.
  • First-set wins among files. When --env-file a --env-file b is passed, a is parsed first; a variable set in a is not overwritten by b. 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
flagshortdefaultdescription
--config <PATH>-cstratum.stratPath 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.jsonPath to the JSON state file.
--refreshoffQuery live hosts via Provider::read and annotate drift between state and reality.
--allow-unresolved-secretsoffIf 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:

symbolaction
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
  • differread returned data that doesn't match state on at least one field.
  • missingread returned Absent (the resource is gone on the host but still in state).
  • unreadable — the provider returned Observed::Unknown (no read impl, or read errored). For system_ufw_rule and ssh_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
flagshortdefaultdescription
--config <PATH>-cstratum.stratPath to a .strat config file. Repeatable, like plan -c. See Multi-file configs.
--state <PATH>-s.stratum/state.jsonPath to the JSON state file.
--yes-yoffSkip the confirmation gate and execute. Without it, apply prints the plan and exits.
--allow-destroyoffPermit 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 -c is visually obvious),
  • the state file path (so a wrong -s is 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
flagshortdefaultdescription
--config <PATH>-cstratum.stratConfig file(s) to enumerate hosts from. Repeatable.
--host <FILTER>noneOnly 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 host block with no addr is skipped with a [status] skipping host warning.
  • An SSH failure on one host is logged with [status] <addr>: failed: <reason> but does not stop the loop — remaining hosts are still queried.
  • --host filtering 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
flagshortdefaultdescription
--path <PATH>-p.stratum/state.jsonPath 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:

argdescription
<addr>Resource address, <kind>.<name> (split on the first .).
flagshortdefaultdescription
--path <PATH>-p.stratum/state.jsonPath 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 / flagshortdefaultdescription
<INPUTS>...Source state files. At least two are required.
--out <PATH>-oOutput 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.