system
Ansible-shape system bootstrap: install packages, manage systemd units, drop files and directories, configure ufw. Operates against a remote host via the system ssh binary (-o BatchMode=yes -o StrictHostKeyChecking=accept-new). Targets Debian/Ubuntu — package management is apt-only.
All apt invocations use DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confold, so package installs never hang on prompts and keep existing config files on upgrade.
The provider takes no configuration block.
Kinds
system_package
An apt-managed package, present or absent.
resource "system_package" "docker" {
host = host.primary.addr
name = "docker.io"
state = "present"
}
| attr | required | type | default | description |
|---|---|---|---|---|
host | yes | string | — | SSH target in user@host form. |
name | yes* | string | resource label | apt package name. Falls back to the resource label if omitted. |
state | no | string | present | present or absent. |
*name is technically optional in the source — it defaults to the resource label — but documenting it explicitly is the convention so the apt package name doesn't depend on what you called the resource.
On present, the provider runs apt-get update once per (process, host) before the first install, then apt-get install <name>. On absent, it runs apt-get remove <name>.
Stored state: { host, name, state }. read runs dpkg-query -W -f='${Status}' <name> and returns Present { state: present|absent }. There is no version field in observed state — version drift is not surfaced.
Delete is best-effort apt-get remove (errors swallowed so a missing package doesn't fail the apply).
system_service
A systemd unit, started/stopped with enabled/disabled independently controlled.
resource "system_service" "docker" {
host = host.primary.addr
name = "docker"
enabled = true
state = "started"
}
| attr | required | type | default | description |
|---|---|---|---|---|
host | yes | string | — | SSH target. |
name | yes* | string | resource label | systemd unit name. Falls back to the resource label. |
enabled | no | bool | false | If true, runs systemctl enable <name>. |
state | no | string | stopped | started or stopped. |
Order of operations:
(enabled=true, state=started)→enable, thenstart, then pollis-activefor up to 10s.(enabled=true, state=stopped)→enable, thenstop.(enabled=false, state=stopped)→stop(best-effort), thendisable.(enabled=false, state=started)→disable(best-effort), thenstart, then pollis-activefor 10s.
If the 10s is-active poll times out, the provider collects systemctl status --no-pager -n 20 (and journalctl -u <svc> --no-pager -n 50 if the status output is sparse) and includes both in the error message.
Stored state: { host, name, enabled, state }. read runs systemctl is-enabled + systemctl is-active in one round-trip and returns Present { enabled, state }.
Delete runs systemctl disable --now <name> best-effort.
system_file
Drops a file on the remote host. Auto-creates parent directories via install -D.
resource "system_file" "traefik-config" {
host = host.primary.addr
path = "/etc/traefik/traefik.yml"
content_file = "files/traefik.yml"
mode = "0644"
owner = "root"
group = "root"
}
| attr | required | type | default | description |
|---|---|---|---|---|
host | yes | string | — | SSH target. |
path | yes | string | — | Absolute destination path. Parent dirs are created. |
content | yes* | string | — | File contents, written verbatim. |
content_file | yes* | string | — | Path to a local file, resolved relative to the .strat file's directory. Inlined at config-load time into content. |
mode | no | string | 0644 | File mode, passed to install -m. |
owner | no | string | root | File owner, passed to install -o. |
group | no | string | root | File group, passed to install -g. |
*Exactly one of content or content_file must be set. Both → EvalError::ContentConflict at config-load time. See content_file for the full semantics.
Upload uses install -D -m <mode> -o <owner> -g <group> /dev/stdin <path> with the content streamed via SSH stdin. The -D flag creates intermediate directories.
The provider sha256-hashes the content; on update, if the new sha matches what's in prior state, the upload is skipped and the resource logs unchanged (sha256 match).
Stored state: { host, path, content, mode, owner, group, sha256 }. Persisting content means a re-plan with the same config is a no-op (no spurious updates from "content field appeared").
read runs a single-roundtrip probe that prints either MISSING or <mode>|<owner>|<group>|<sha256>. Returns Absent for missing files, Present { host, path, mode, owner, group, sha256 } otherwise. Note that observed state does not include content — drift surfaces as a sha256 mismatch.
Delete runs rm -f -- <path>.
system_secret_file
Like system_file, but the content is treated as a whole-file secret. The plaintext is streamed via SSH stdin (never argv) and never persists in state. State stores sha256 plus the file permissions only — enough to detect drift, not enough to recover the value.
secret "firebase_sa" {
from_file = "~/.config/app/firebase-sa.json"
}
resource "system_secret_file" "firebase-sa" {
host = host.primary.addr
path = "/etc/app/firebase-sa.json"
content = secret.firebase_sa.value
mode = "0400"
owner = "root"
group = "root"
}
| attr | required | type | default | description |
|---|---|---|---|---|
host | yes | string | — | SSH target. |
path | yes | string | — | Absolute destination path. Parent dirs are created via install -D. |
content | yes | string | — | File contents. Typically secret.<name>.value — see Secrets. Unlike system_file, there is no content_file attribute; whole-file secrets are sourced through a secret { from_file = ... } block. |
mode | no | string | 0400 | File mode, passed to install -m. Default is stricter than system_file (0400 vs 0644) — secret files default to owner-only read. |
owner | no | string | root | File owner, passed to install -o. |
group | no | string | root | File group, passed to install -g. |
Upload uses install -D -m <mode> -o <owner> -g <group> /dev/stdin <path> with the content streamed via SSH stdin. The provider hashes the content before sending; on update, if the new sha matches the prior state's sha and all permissions match, the upload is skipped and the resource logs unchanged (sha256 + perms match).
State shape: { host, path, mode, owner, group, sha256 }. The content field is omitted — recovering the plaintext from state is impossible by design. Plan diff is sha-to-sha: the build_plan normalizer (SECRET_CONTENT_TO_SHA) drops content from desired and substitutes sha256: hash(content) before diffing, so plans never echo plaintext into a content: null -> "<plaintext>" change. See Architecture: secret-content normalization.
Apply log: byte length only — never the content, never the sha.
[system] SECRET_FILE `firebase-sa` -> root@192.0.2.10:/etc/app/firebase-sa.json (1842 bytes, mode=0400 root:root)
[system] SECRET_FILE `firebase-sa` -> root@192.0.2.10:/etc/app/firebase-sa.json unchanged (sha256 + perms match)
Drift detection: read runs the same probe shape as system_file (<mode>|<owner>|<group>|<sha256> or MISSING). Returns Present { host, path, mode, owner, group, sha256 } with no content field. Drift surfaces as sha256 / mode / owner / group mismatch.
Delete runs rm -f -- <path> best-effort.
system_dir
Manages a directory on the remote host. Two modes, selected by whether source_dir is set:
- Upload mode (
source_dirset) — tars + gzips a local tree in memory, streams it over one SSH connection, extracts on the host, applieschown -R+ recursivechmod. Used to ship static assets (anmdbook buildoutput, a static-site bundle) to a host where another container will serve them. - Empty-dir mode (
source_diromitted) — justmkdir -p+chown+chmodon the host. No upload. Used to pre-create directories a daemon expects but won't create itself (e.g./srv/repos,/var/lib/<daemon>).
resource "system_dir" "book" {
host = host.primary.addr
source_dir = "../book/book"
path = "/srv/stratum-book"
mode = "0644"
dir_mode = "0755"
owner = "root"
group = "root"
delete_extra = true
}
| attr | required | type | default | description |
|---|---|---|---|---|
host | yes | string | — | SSH target. |
path | yes | string | — | Absolute remote destination. Created with mkdir -p. |
source_dir | no | string | — | If set, local directory whose contents are tarred + uploaded. Resolved relative to the .strat file's directory and canonicalized at config-load time. Must exist and be a directory. If omitted, the resource is in empty-dir mode. |
mode | no | string | 0644 | Mode applied to every regular file under path (via find -type f -exec chmod). Has no effect in empty-dir mode. |
dir_mode | no | string | 0755 | Mode applied to every directory under path (via find -type d -exec chmod). In empty-dir mode, applied once to path itself. |
owner | no | string | root | Recursive owner, applied with chown -R <owner>:<group>. In empty-dir mode, applied once to path. |
group | no | string | root | Recursive group. |
delete_extra | no | bool | false | If true, files in prior state's manifest but absent from the new manifest are rm -f'd on the host after the upload. Keeps the remote tree in sync as local files are removed. No effect in empty-dir mode (the manifest is always empty). |
The provider walks source_dir with walkdir, sha256-hashes every regular file, and stores the result as { relpath -> sha256 } (POSIX / separators even on Windows). The manifest is digested into a single manifest_sha256; on update, if both the manifest digest and every permission attr match prior state, the upload is skipped and the resource logs unchanged (... manifest match).
source_dir is resolved at config-load time: the value the provider sees is the canonicalized absolute path of <dir-of-.strat-file>/<source_dir>. Using system_dir with source_dir via stratum_config::load_str (no base dir) errors with EvalError::SourceDirNoBaseDir. A missing or non-directory path errors with EvalError::SourceDirMissing.
Stored state: { host, source_dir, path, mode, dir_mode, owner, group, delete_extra, file_count, manifest_sha256, manifest }. The manifest map is persisted in full so delete_extra can diff prior keys against the new manifest.
read runs find . -type f -print0 | sort -z | xargs -0 sha256sum on the remote tree, returns Absent if the directory is missing, otherwise Present { host, path, file_count, manifest_sha256, manifest }. Drift surfaces as a manifest_sha256 mismatch (or, with delete_extra off, extra keys observed on the host that aren't in state).
File-count cap. If state's file_count exceeds 200, read returns Observed::Unknown("system_dir read skipped: file_count N > cap 200") instead of doing the remote sha256 sweep. Drift detection on large trees needs a smarter strategy; today they show up as unreadable in --refresh output. Apply itself is not capped — uploads of any size work.
Delete runs rm -rf -- <path> best-effort.
Empty-dir mode (no source_dir)
Omit source_dir to skip the upload entirely. The provider runs only:
mkdir -p <path>; chown <owner>:<group> <path>; chmod <dir_mode> <path>
The stored state's file_count is 0, the manifest is {}, and manifest_sha256 is the digest of the empty manifest. delete_extra is recorded but has no effect — there are no manifest entries to diff. read returns Absent if the directory is missing on the host, otherwise Present with file_count: 0 (the host's tree is also expected to be empty as far as stratum is concerned; any files placed there by other processes don't drift the manifest).
# Pre-create directories the daemon expects.
resource "system_dir" "etc-app" {
host = host.primary.addr
path = "/etc/app"
}
resource "system_dir" "srv-repos" {
host = host.primary.addr
path = "/srv/repos"
}
Use this in place of an ssh_exec "mkdir -p ..." chain when a daemon needs the directories to exist before it starts: empty-dir mode is idempotent, drift-detectable (the daemon won't recreate the dir if path is missing on the host), and the owner/group/mode become declarative.
system_ufw_rule
A single ufw allow/deny rule. Idempotent via ufw itself — adding the same rule twice is harmless.
resource "system_ufw_rule" "allow-ssh" {
host = host.primary.addr
port = "22/tcp"
rule = "allow"
}
| attr | required | type | default | description |
|---|---|---|---|---|
host | yes | string | — | SSH target. |
port | yes | string | — | Port specifier, e.g. 22/tcp, 443/tcp, 8080. Passed verbatim to ufw. |
rule | yes | string | — | allow or deny. |
Runs ufw <rule> <port> on create/update. Delete runs ufw delete <rule> <port> best-effort.
Stored state: { host, port, rule }. read returns Observed::Unknown("system_ufw_rule read not implemented (ufw status parsing punted)") — ufw rules always show up as unreadable in drift summaries. This is intentional for v1.
Lockout warning: systemctl start ufw does NOT activate the firewall ruleset. The ruleset becomes active only when you run ufw --force enable (typically via an ssh_exec resource, after the 22/tcp allow rule is in state). If you later remove the 22/tcp allow rule with ufw enabled while you're sshing in, you'll lock yourself out. See the bootstrap tutorial for the recommended ordering.
Apply trace
Each resource logs one line to stderr:
[system] PACKAGE `docker.io` present on root@192.0.2.10
[system] SERVICE `docker` enabled=true state=started on root@192.0.2.10
[system] FILE `traefik-config` -> root@192.0.2.10:/etc/traefik/traefik.yml (188 bytes, mode=0644 root:root)
[system] FILE `traefik-config` -> root@192.0.2.10:/etc/traefik/traefik.yml unchanged (sha256 match)
[system] SECRET_FILE `firebase-sa` -> root@192.0.2.10:/etc/app/firebase-sa.json (1842 bytes, mode=0400 root:root)
[system] DIR `site` -> root@192.0.2.10:/srv/site (42 files, mode=0644 dir_mode=0755 root:root)
[system] DIR `site` -> root@192.0.2.10:/srv/site unchanged (42 files, manifest match)
[system] UFW allow 22/tcp on root@192.0.2.10
Notes
apt-get updateis memoized once per(process, host). A separatestratuminvocation re-runs it.- All shell quoting is internal (
shell_quotehelper) — package names with spaces are correctly quoted, but in practice apt names don't contain them. - There is no
system_userkind. Usessh_execif you need to create users.