Gunnar

nordisk/Skidbladnir

.nornir/install-design.md

# install — design

> **Class:** `design` · **Function axis:** `f6` (AIRGAP6 + the install tree). Sub-parts: `f6a`
> service tree · `f6b` systemd D-Bus · `f6c` product · `f6d` lifecycle · `f6e` identity. States
> only the shape we ship; the *why a tree*, the rejected backends, and the appliance arm are in
> [`install-reasoning.md`](install-reasoning.md).

![The install tree](assets/install-tree.svg)

## f6a — the neutral service tree (`service.rs`)

One declarative `ServiceSpec` renders to many init systems through one trait.

`pub struct ServiceSpec` (`service.rs:137`) — `name, description, exec, args, user, scope,
working_dir, env, after` (ordering), `needs` (hard require), `restart, restart_sec, sockets`.
Builder from `new(name, description, exec)` (`service.rs:169`) + chainers (`service.rs:191`–`:223`).

`pub enum Backend` (`service.rs:46`) — the eleven leaves; `Backend::all()` (`service.rs:81`) and
`slug()` (`service.rs:98`) drive the coverage matrix. `SystemdSystem`, `SystemdUser`, `OpenRc`,
`SysV`, `Runit`, `S6Rc`, `Dinit`, `Kubernetes`, `Oci`, `Quadlet` and **`WindowsService`** are ALL
implemented — **11 of 11, full coverage**; no backend is declared-only.

`pub trait ServiceRenderer { backend(); render(&spec) -> RenderedService }` (`service.rs:285`).
`renderer_for(backend) -> Option<Box<dyn ServiceRenderer>>` (`service.rs:292`) returns `Some` for
**every** backend (`Backend::Kubernetes` → `crate::k8s::KubernetesRenderer`, `Backend::Oci` →
`OciRenderer`, `Backend::Quadlet` → `QuadletRenderer`, `Backend::WindowsService` →
`WindowsServiceRenderer`; asserted by `renderer_for(…).is_some()` across `Backend::all()`) — the
`Option` return is retained so a future backend can land as an honest `None`, but none is `None` today.

`pub struct RenderedService` (`service.rs:258`) — the render output as **data**: `files:
Vec<RenderedFile>` (`service.rs:241`) plus the argv vectors `enable/start/status/stop`
(`service.rs:262`–`:268`). `disable_verb()` (`install.rs:328`) derives disable from enable.

The implemented renderers:
- `SystemdRenderer { scope }` (`service.rs:306`) — projects to `SystemdServiceSpec` and calls the
  existing `systemd_unit_scoped`; `after` → `After=`, `needs` → `After=`+`Wants=`; user scope drops
  `User=`/`Group=` and targets `default.target`; verbs are `systemctl [--user] <verb>`.
- `OpenRcRenderer` (`service.rs:397`) — emits `/etc/init.d/<name>` (`#!/sbin/openrc-run`,
  `supervise-daemon` for restart) + `/etc/conf.d/<name>` (env, 0600); verbs are `rc-update` /
  `rc-service`.
- `SysVRenderer` (`service.rs`) — the **universal fallback**: an LSB `/etc/init.d/<name>` script
  (`### BEGIN INIT INFO` header for insserv/`update-rc.d` ordering) driving the daemon via
  `start-stop-daemon` (`--chuid user:group`, `--chdir`), + `/etc/default/<name>` (env, 0600); verbs
  `update-rc.d` / the script itself. Classic SysV cannot respawn, so restart only shapes the stop
  retry cadence — a documented limit, not a fake.
- `RunitRenderer` (`service.rs`) — emits a runit **service directory** `/etc/sv/<name>/`: an
  executable `run` that sources `./conf`, guards deps with `sv check`, and `exec`s under
  `chpst -u user:group`; a `finish` that encodes restart (backoff `sleep restart_sec`, or `sv down .`
  for `RestartPolicy::No`, since runsv respawns on *any* exit); a mode-0600 `conf` env file; and a
  system-scope `log/run` svlogd logger. Verbs `ln`-symlink into the `/var/service` runsvdir + drive
  `sv`. User scope roots the dir home-relative under `.local/sv/<name>`, drops `chpst -u` + the
  logger (like the systemd `--user` renderer).
- `DinitRenderer` (`service.rs`) — emits a textual `key = value` **service description**
  `/etc/dinit.d/<name>`: `type = process` + `command = <command line>`, `working-dir`, `run-as <user>`
  (dinit has no separate group directive), a `/var/log/<name>.log` `logfile`, and restart as
  `restart = yes` + `restart-delay = <restart_sec>.0` (or `restart = no` for `RestartPolicy::No`;
  dinit has no exit-code awareness, same honest limit as runit). `needs` → `depends-on`, `after` →
  `after`; env goes to a mode-0600 `env-file` (`/etc/dinit.d/config/<name>.env`, literal
  `NAME=VALUE`) the description points at. Verbs are `dinitctl [--user] <enable|start|status|stop>`.
  User scope roots the description home-relative under `.config/dinit.d/<name>`, drops `run-as` + the
  logfile. Like runit, no synthetic network dep (dinit's network service name is distro-specific).
- `S6RcRenderer` (`service.rs`) — emits a compiled-**source service-definition directory**
  `/etc/s6-rc/source/<name>/`: a `type = longrun` file, a `#!/bin/sh` `run` script whose single
  `exec` chainloads `s6-envdir ./env` → `cd <wd>` → `s6-setuidgid <user>` → the exact neutral command
  line (envdir applied from the service dir *before* the `cd`, exactly as an execline run would; no
  separate group directive — `s6-setuidgid` resolves the account, same honest limit as dinit). Restart
  maps to longrun supervision: on-failure/always ⇒ a `restart_sec` backoff `sleep` in `finish` + a
  `timeout-finish = 0` (so s6-supervise's default 5 s finish-kill does not truncate it); `RestartPolicy::No`
  ⇒ a `finish` that `exit 125`s — the one code that tells s6-supervise NOT to restart. `needs`/`after`
  both become an (empty) `dependencies.d/<dep>` file (s6-rc's single hard-dependency notion; current
  non-deprecated form; no synthetic network dep). Env goes to one mode-0600 `env/<KEY>` file per variable
  (the recognised `env/` dir s6-rc-compile copies verbatim, read at runtime by `s6-envdir`). Verbs:
  `s6-rc-bundle-update … add default <name>` (enable into the boot bundle), `s6-rc -u/-d change <name>`
  (start/stop), `s6-svstat <servicedir>` (status). User scope roots the source dir home-relative under
  `.config/s6-rc/source/<name>`, drops `s6-setuidgid`, and points the compiled db + scandir home-relative.
- **`KubernetesRenderer`** (`src/k8s.rs:19`, `impl ServiceRenderer` at `:142`): projects the neutral
  `ServiceSpec` onto k8s manifests **as data** — a Deployment or StatefulSet (`spec.stateful`,
  `k8s.rs:33`), a Service, and a PVC when the spec declares storage; tested
  (`renderer_for_returns_kubernetes` plus Deployment/StatefulSet/PVC manifest-content assertions).
- **`OciRenderer`** (`service.rs`) — projects the neutral `ServiceSpec` onto a **`podman`-style
  container invocation** (the constellation's one container engine). A container runtime IS the
  supervisor, so there is no unit *file*: the whole spec is the `podman run -d --name <name>` argv —
  `--restart=<no|on-failure|always>` (the container twin of supervision *and* enable-at-boot),
  `--user user[:group]` (system scope; user scope runs rootless), `-w <wd>`, `--requires <dep>` for
  every `needs`/`after` (podman's single ordering notion), and `--entrypoint <exec>` + `<image>
  <args…>` carrying the exact command split (image defaults to `<name>:latest`, k8s parity). Env goes
  to a mode-0600 `--env-file` (`/etc/<name>/<name>.env`, literal `NAME=value`; home-relative
  `.config/<name>/<name>.env` for user scope). Verbs: `podman update --restart=<policy> <name>`
  (enable = re-assert the boot restart policy), `podman run …` (start), `podman container inspect -f
  {{.State.Status}} <name>` (status), `podman stop <name>` (stop). `podman run` has no per-restart
  delay, so `restart_sec` is not expressible here — a documented limit, not a fake.
- **`QuadletRenderer`** (`service.rs`) — projects the neutral `ServiceSpec` onto a systemd
  **`.container` quadlet unit** (`/etc/containers/systemd/<name>.container`; home-relative
  `.config/containers/systemd/<name>.container` for user scope). A hybrid limb that REUSES the OCI
  image-ref handling (`container_image` — explicit `image`, else `<name>:latest`) for the
  `[Container]` body **and** the systemd renderer's `[Install]`/`[Unit]` conventions for boot wiring:
  `[Container] ContainerName/Image/Exec=<command line>/WorkingDir/User[:Group]/Environment=`,
  `[Service] Restart=<as_systemd>`/`RestartSec=` (systemd supervises the generated `.service`, so
  unlike raw `podman run` **`restart_sec` IS expressible**), `[Unit] After=`/`Wants=`
  (`network-online.target` system-only, `<dep>.service` deps), and `[Install] WantedBy=` (`multi-user`
  / `default.target`). Verbs: `systemctl [--user] daemon-reload` — the `podman-systemd` generator
  materialises `<name>.service` **and** installs the `WantedBy` symlink on reload, so daemon-reload
  IS the enable-at-boot step (a quadlet has no separate `systemctl enable`; documented, not faked) —
  then `start`/`is-active`/`stop <name>.service`.
- **`WindowsServiceRenderer`** (`service.rs`) — projects the neutral `ServiceSpec` onto a **Windows
  SCM service**, REUSING the MSI limb's `msi::WindowsServiceSpec` vocabulary + the SHARED
  `msi::render_service_install` (the exact `<ServiceInstall>`/`<ServiceControl>` fragment the MSI
  builder embeds, factored out of `msi::render_service`, L5). File: a standalone WiX `.wxs` at
  `C:\ProgramData\<name>\<name>.wxs` (delayed-auto `Type=ownProcess`, the account, env in a
  `ServiceConfig`, + a `util:ServiceConfig` first/second/third-failure **restart** recovery after
  `restart_sec` s — so `restart_sec` IS expressible, unlike OCI). Windows SCM is **machine-scoped**
  (no per-user service), so `scope` does not branch; the identity is `user` (an `obj=`/`Account=`) or
  the built-in `LocalSystem`. Verbs: `sc.exe create <name> binPath= <exec+args> start= auto obj=
  <account> depend= <a>/<b> DisplayName= <desc>` (create = enable), `sc.exe start`/`query`/`stop
  <name>`. `sc.exe` cannot set env or the working dir — env lives in the `.wxs`; `working_dir` is not
  expressible on the SCM path (documented limit, same honest class as OCI's `restart_sec`).

`ServiceSpec` re-uses `systemd::{Scope, RestartPolicy}` (`service.rs:38`) as shared vocabulary — the
tree is a strict **generalization** of the systemd renderer, not a fork.

## f6b — systemd over D-Bus (`systemd.rs`)

`pub struct Systemd` (`systemd.rs:472`, unix) is a pure-Rust zbus client over
`org.freedesktop.systemd1` — **no `systemctl` subprocess**. `connect`/`connect_scoped`
(`systemd.rs:478`,`:486`) pick the system or session bus; methods `reload, enable, disable, start,
stop, try_restart, reset_failed, is_active` (`systemd.rs:512`–`:543`) map to Manager calls.

Pure generators: `systemd_unit_scoped(spec, scope) -> String` and `env_file(spec)`.
`SystemdServiceSpec` (`systemd.rs`) is the systemd-specific spec. Privileged install lives in
`install_systemd_scoped(spec, scope)`: `spec.validate()` guard, root/user guard,
`ensure_system_user` (a pure-Rust `useradd --system` replacement), write env-file (0600) + unit,
reload/enable/start, then `identity::ensure` (best-effort).

**Secret-env / world-readable-unit hardening.** The `.service` unit is written to a
**world-readable** path (`/etc/systemd/system/<svc>.service`, 0644), so it must NEVER carry a
secret inline. The spec has TWO env axes: `environment` (`with_env`) is rendered inline as
`Environment=KEY=VALUE` AND into the env file; `secret_environment` (`with_secret_env`) is written
**only** into the mode-0600 env file and is *deliberately never* emitted inline. Setting any secret
makes an `env_file` mandatory — `SystemdServiceSpec::validate()` (pure, no root) returns `Err` for a
secret-without-env_file spec, and `install_systemd_scoped` runs it before writing anything.
Both secret sinks — the systemd env file and the ed25519 `node_key` — are persisted through the one
shared `systemd::write_private_file(path, bytes)` helper, which CREATES the file at 0600 via
`OpenOptions::mode` (closing the write-then-chmod window where a naïve `fs::write` + `set_permissions`
leaves the secret briefly world-readable) and re-tightens a pre-existing looser file. RED-when-broken
tests: `secret_env_never_leaks_into_world_readable_unit`, `secret_without_env_file_is_rejected`,
`write_private_file_is_0600_even_over_a_loose_preexisting_file`, + the `secret-env-hardening`
testmatrix row.

## f6c — the product unit-of-installation (`product.rs`)

`pub struct ComponentSpec` (`product.rs:40`) — `name, description, bundle_binary, exec_args, user,
ports, after, restart, env, health`. `HealthCheck` (`product.rs:27`) is `None | Tcp(port) |
Http(path)`. Bridges:
- `to_service_spec(layout, backend)` (`product.rs:174`) — the **neutral** bridge: exec/working_dir
  through `current` using the **full** `bundle_binary` path (the tree is unfolded whole), env
  `DATA_DIR`/`LOG_DIR`, ports → sockets, scope from backend.
- `to_systemd_spec(layout)` (`product.rs:126`) — the systemd-only bridge (legacy AIRGAP6 path).
- `as_product()` (`product.rs:103`) — the dep-graph view `orchestrate` (f8) consumes.

Canonical specs: `holger()` (`product.rs:432`), `tunnr()` (`product.rs:454`), `nornir_server()`
(`product.rs:470`); `registry()`/`lookup()` (`product.rs:490`,`:499`).

## f6d — the product lifecycle (`install.rs`)

Install ≠ update ≠ uninstall, each **verbs-as-data** over the neutral tree. `InstallOutcome`
(`install.rs:57`) — `op, product, backend, version, dirs, removed, files_written, identity,
rendered, verbs` — is fully-inspectable serde data; the argv in `verbs` is the only side effect.

| fn | signature (`install.rs`) | does | verbs |
|---|---|---|---|
| `install_product` | `(spec, layout, bundle, archive, backend, root)` (`:88`) | prepare_product → `archive.extract_all` (whole tree) → `+x` binary → `activate` → `identity::ensure` (mint) → render → write unit files under `root` | `[enable, start]` |
| `update_product` | `(spec, layout, bundle, archive, backend, root)` (`:140`) | new-version generation, **preserve** data+log, identity **loaded** not re-minted, atomic activate | `[stop, start]` |
| `uninstall_product` | `(spec, layout, backend, root, purge)` (`:193`) | remove unit/script/env files; if `purge`, remove data+log | `[stop, disable]` |
| `apply` | `(&InstallOutcome)` (`:241`) | the thin privileged driver — the **only** place verbs run | — |

`root` (`under_root`, `install.rs:270`) re-roots absolute unit paths (`/etc/...` → `<root>/etc/...`)
so writes are assertable without privilege — `/` in production, a tempdir in tests.

## f6e — install identity (`identity.rs`)

`identity::ensure(data_dir, app, scope, user) -> Identity` (`identity.rs:135`) load-or-mints
`<data_dir>/identity.toml` (0600). `Identity` (`identity.rs:42`) carries a reinstall-stable
`machine_fp` (first 8 bytes of `BLAKE3(machine_id ++ domain ++ scope ++ uid)`, `identity.rs:192`)
and a fresh UUIDv7 `install_id` (`Uuid::now_v7`). The wire form is `nid1:<machine_fp>:<install_id>`
(`wire()`, `identity.rs:72`; parsed by `WireId`, `identity.rs:107`). Identity **references**
credentials (ssh key / node cert) by fingerprint; it is not itself a credential.

## Contract (inject-assert tests)

`one_spec_renders_to_systemd_and_openrc` (`service.rs:506`),
`registry_resolves_only_implemented_backends` (`service.rs:607`),
`install_product_on_systemd_system` (`install.rs:450`), `install_product_on_openrc` (`install.rs:455`),
`update_product_preserves_data_and_log_and_swaps_version` (`install.rs:478`),
`uninstall_product_purge_flag_controls_data_retention` (`install.rs:526`),
`fresh_dirs_mint_different_install_ids` (`identity.rs:518`).

Edge coverage added for the previously under-tested pure (daemon-free) paths — every
one asserts rendered CONTENTS / spec invariants, never "didn't panic":
- **HTTP health probe** (`service.rs`): `lifecycle_http_health_reads_status_line` drives a
  real one-shot localhost server and proves a `2xx` is healthy while a `5xx` is UNHEALTHY (a
  running-but-erroring service is never a false success); `http_health_without_socket_errors`
  pins the explanatory error when `HealthProbe::Http` has no listening socket.
- **AgentMode invariance** (`service.rs`): `agent_mode_is_metadata_only_same_rendered_output`
  proves Fat vs Thin render byte-identical files+verbs on every implemented backend (mode is node
  classification, not unit shape).
- **Backend matrix** (`service.rs`): `backend_coverage_matrix_is_honest_and_self_consistent`
  walks `Backend::all()`, checks each resolved renderer's `backend()` matches its lookup key
  (guards mis-wiring), and that ALL eleven backends resolve `Some` (full coverage, no `None` left).
- **runit / dinit / s6-rc renderers** (`service.rs`): `runit_renders_a_service_dir_with_run_finish_conf_and_log`
  + `runit_no_restart_self_downs_and_user_scope_drops_privs_and_logger`,
  `dinit_renders_a_service_description_and_env_file` + `dinit_needs_no_restart_and_user_scope_edges`, and
  `s6rc_renders_a_source_definition_dir_with_run_finish_deps_and_env` +
  `s6rc_no_restart_needs_and_user_scope_edges` pin each backend's exact rendered command/restart/deps/
  0600-env and `sv`/`dinitctl`/`s6-rc` verbs (the s6-rc pair also pins the `exit 125` no-restart finish
  and the `s6-envdir`-before-`cd` chainload).
- **OpenRC supervision** (`service.rs`): `openrc_supervision_details_and_no_env_single_file`
  pins `respawn_delay`/`command_background`/pidfile and the no-env ⇒ single-file case.
- **OCI renderer** (`service.rs`): `oci_renders_a_podman_run_invocation_and_env_file` pins the exact
  `podman run` argv (`--restart`/`--user user:group`/`-w`/`--env-file`/`--requires`/`--entrypoint` +
  defaulted `<name>:latest` image + args-as-CMD), the 0600 literal env-file, the `podman
  update`/`stop`/`inspect` verbs, and that a set `.image` is used verbatim;
  `oci_no_restart_no_env_and_user_scope_rootless` pins `--restart=no`, the zero-file no-env case, and
  the rootless (no `--user`, home-relative env-file) user scope.
- **Quadlet renderer** (`service.rs`): `quadlet_renders_a_container_unit_and_verbs` pins the exact
  `.container` unit (`[Container] ContainerName/Image=<name>:latest fallback/Exec=<command line>/
  WorkingDir/User:Group/Environment=`, `[Service] Restart=on-failure`/`RestartSec=2`, `[Unit]
  After=`/`Wants=` with `<dep>.service`, `[Install] WantedBy=multi-user.target`), the 0644 file, the
  `systemctl daemon-reload`(=enable)/`start`/`is-active`/`stop <name>.service` verbs, and a verbatim
  `.image`; `quadlet_no_restart_needs_and_user_scope_edges` pins `Restart=no`, `needs` →
  `After=`+`Wants=`, and the rootless (no `User=`, no `network-online`, `default.target`,
  home-relative, `systemctl --user`) user scope.
- **Windows service renderer** (`service.rs`): `windows_service_renders_a_wix_fragment_and_sc_verbs`
  pins the exact WiX `.wxs` fragment (via the shared `msi::render_service_install` — `<ServiceInstall>`
  account/`Start="auto"`/`DelayedAutoStart`, the `restart` recovery block with
  `RestartServiceDelayInSeconds="2"`, the env `ServiceConfig`, `<ServiceControl>`) and the full
  `sc.exe create … binPath=/obj=/depend=/DisplayName=` + `start`/`query`/`stop` verbs;
  `windows_service_no_restart_no_env_and_default_account` pins the no-recovery / no-env / no-`depend=`
  edges and the `LocalSystem` default account. The shared seam is guarded from below by
  `msi::render_wxs_emits_real_wix_sources` (unchanged — the MSI path passes `None` recovery).
- **k8s renderer** (`k8s.rs`): `default_namespace_and_image_fallback_and_replicas` (defaults
  `default`/`<name>:latest`/`replicas:1` + path/verb namespacing), `no_sockets_omits_service_manifest`
  (headless workload emits only the workload), and `deployment_yaml_is_byte_exact` — enforces the
  module's byte-stability (GitOps-diffable) promise so key/indent reordering is a hard regression.
- **Public API seam** (`tests/public_api.rs`, 6 tests): exercises the consumer-facing surface
  (`service`/`systemd`/`k8s`/`config` exports incl. `nornir_spec`/`holger_spec`) through ONLY
  public paths — `one_public_spec_renders_across_every_implemented_backend` renders the OCI, Quadlet
  and Windows-SCM backends too and asserts full 11/11 coverage — so a downstream-breaking export
  change fails to compile here.