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"
}
attrrequiredtypedefaultdescription
hostyesstringSSH target in user@host form.
nameyes*stringresource labelapt package name. Falls back to the resource label if omitted.
statenostringpresentpresent 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"
}
attrrequiredtypedefaultdescription
hostyesstringSSH target.
nameyes*stringresource labelsystemd unit name. Falls back to the resource label.
enablednoboolfalseIf true, runs systemctl enable <name>.
statenostringstoppedstarted or stopped.

Order of operations:

  • (enabled=true, state=started)enable, then start, then poll is-active for up to 10s.
  • (enabled=true, state=stopped)enable, then stop.
  • (enabled=false, state=stopped)stop (best-effort), then disable.
  • (enabled=false, state=started)disable (best-effort), then start, then poll is-active for 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"
}
attrrequiredtypedefaultdescription
hostyesstringSSH target.
pathyesstringAbsolute destination path. Parent dirs are created.
contentyes*stringFile contents, written verbatim.
content_fileyes*stringPath to a local file, resolved relative to the .strat file's directory. Inlined at config-load time into content.
modenostring0644File mode, passed to install -m.
ownernostringrootFile owner, passed to install -o.
groupnostringrootFile 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"
}
attrrequiredtypedefaultdescription
hostyesstringSSH target.
pathyesstringAbsolute destination path. Parent dirs are created via install -D.
contentyesstringFile 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.
modenostring0400File mode, passed to install -m. Default is stricter than system_file (0400 vs 0644) — secret files default to owner-only read.
ownernostringrootFile owner, passed to install -o.
groupnostringrootFile 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_dir set) — tars + gzips a local tree in memory, streams it over one SSH connection, extracts on the host, applies chown -R + recursive chmod. Used to ship static assets (an mdbook build output, a static-site bundle) to a host where another container will serve them.
  • Empty-dir mode (source_dir omitted) — just mkdir -p + chown + chmod on 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
}
attrrequiredtypedefaultdescription
hostyesstringSSH target.
pathyesstringAbsolute remote destination. Created with mkdir -p.
source_dirnostringIf 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.
modenostring0644Mode applied to every regular file under path (via find -type f -exec chmod). Has no effect in empty-dir mode.
dir_modenostring0755Mode applied to every directory under path (via find -type d -exec chmod). In empty-dir mode, applied once to path itself.
ownernostringrootRecursive owner, applied with chown -R <owner>:<group>. In empty-dir mode, applied once to path.
groupnostringrootRecursive group.
delete_extranoboolfalseIf 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"
}
attrrequiredtypedefaultdescription
hostyesstringSSH target.
portyesstringPort specifier, e.g. 22/tcp, 443/tcp, 8080. Passed verbatim to ufw.
ruleyesstringallow 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 update is memoized once per (process, host). A separate stratum invocation re-runs it.
  • All shell quoting is internal (shell_quote helper) — package names with spaces are correctly quoted, but in practice apt names don't contain them.
  • There is no system_user kind. Use ssh_exec if you need to create users.