Gunnar

nordisk/Skidbladnir

.nornir/install-reasoning.md

# install — reasoning

> **Class:** `reasoning`. Why the install layer is a backend-neutral tree, what we rejected, and
> the road here. The descriptive shape is in [`install-design.md`](install-design.md). This
> chapter absorbs the reasoning from the former `install-tree-design.md`.

## Why a tree, not just systemd

`systemd.rs` is excellent — but it is **one backend**. Its spec renders straight to a systemd
`.service` unit and nothing else. Yet Skidbladnir's whole pitch is "airgap a whole system with no
network, no shell, no manual steps" — and the systems you airgap onto in 2026 are not all systemd:
Alpine/postmarketOS/embedded → OpenRC (and dinit); Void/appliances → runit; Gentoo/hardened → s6;
Artix/Devuan/Chimera → OpenRC|runit|s6|dinit; ancient RHEL/busybox → SysV; container hosts →
OCI/quadlet; Windows → the SCM. Hard-coupling to systemd re-creates exactly the per-init bespoke
install scripts Skidbladnir set out to delete (the "x14-datalabb Ansible problem"). The fix is a
**tree**: one declarative spec at the root, one renderer per backend at the leaves.

Strip the syntax and every backend is the same six facts plus verbs — what to run, as whom, scope,
env, ordering vs hard-need, restart — with a status verb. That table *is* the `ServiceSpec`: one
declarative spec carries the left column; each renderer knows its own row.

## Why verbs are data, not an `apply()` that hides them

`RenderedService` carries `enable/start/status/stop` as argv `Vec<String>`, not buried inside a
method that performs them. This makes the whole tree unit-testable at **zero privilege** ("OpenRC
enables with `rc-update add holger default`" is an assertion, not a side effect), and lets one
privileged driver run them uniformly across every backend. systemd's verbs additionally route
through the D-Bus `Systemd` client rather than a `systemctl` subprocess — so there is no shell on
the target at all. We rejected the hidden-`apply` shape precisely because it would make the tree
untestable without root and re-introduce shell-outs.

## Why `renderer_for` returns `Option`, not a fake

Declared-but-unrendered backends resolve to `None` — an honest "not yet", never a faked success.
`Backend::all()` + `slug()` give the coverage matrix its backend axis, so completeness is a
mechanically-checked datum (see [`matrix-reasoning.md`](matrix-reasoning.md)), not a claim. A fake
renderer would let the matrix report green for a backend that does not actually install anything —
the opposite of the honesty the whole project is built on.

## Why OpenRC was the chosen second backend (not systemd-user)

The spike could add "systemd-user *or* OpenRC". systemd-user is nearly free — the same INI minus
`User=`, already handled by the existing `Scope::User` — so choosing it would prove little.
**OpenRC is a genuinely different format** (a shell service script, not INI; `depend()` not
`After=`; `rc-update` not `systemctl`), so it *proves the abstraction is init-neutral* rather than
"systemd with a coat of paint". The spike therefore lands three renderers — systemd-system,
systemd-user (free via the bridge), and OpenRC — the strongest demonstration per unit of code. The
next-highest-value renderers, in order, are: **dinit** (richest 3-tier dep model, modern,
embedded-relevant), **runit** (Void/appliances), **quadlet** (reuses the systemd verbs), then s6-rc
(needs a compile step), then SysV (the universal fallback).

## Why extract_all (whole tree), not extract_one (flattened)

The lifecycle path unfolds the **whole** product tree with `extract_all`, so the exec is the full
`bundle_binary` path under `current/<name>`. The legacy AIRGAP6 `SystemdInstaller` flattens with
`extract_one` to `current/<name>/<binfile>`. Both are correct for their own unpack and are
deliberately **not** merged: a product that ships more than a single binary (libs, assets, a config
tree) needs its tree preserved, which is why the neutral lifecycle uses `extract_all`.

## Why identity references credentials, and is not one

An install identity distinguishes "the same machine", "a reinstall on the same machine", and "a
different machine". It does this with a reinstall-stable derived `machine_fp` plus a fresh UUIDv7
`install_id` — the time-ordered id is the reinstall signal (a data purge removes `identity.toml`,
so the next `ensure` mints a *later* id while `machine_fp` stays equal). Identity references the
ssh key / node cert **by fingerprint** rather than embedding them, so credentials can rotate
without changing identity. Making identity a credential would couple the "who is this install"
question to key material that legitimately churns.

## Rejected: absorbing tunnr as a service

tunnr is an OS-less UEFI **appliance** — its "binary" in the bundle is a disk image, not an ELF —
so it does not fit the service tree. The right shape is a sibling `ApplianceSpec` arm (write image
to block dev / loop, register with a KVM supervisor, health via `tunnr status == BOOT-OK`), owned
by tunnr and *rendered* by Skidbladnir — the same "product-owns-its-spec, engine-applies-it" split
holger follows. Absorbing tunnr would re-couple what the leaf-repo extraction decoupled and drag a
UEFI image toolchain into the lean core. The dependency stays one-way: tunnr → Skidbladnir.

## Known seams (honest)

- **No native `StateDirectory=`/`LogsDirectory=`.** `SystemdServiceSpec` has no such field, so
  data + log dirs are surfaced as `DATA_DIR`/`LOG_DIR` env + `WorkingDirectory=` and provisioned
  explicitly by `prepare_product`. Nothing is lost; a future pass could add the native directives.
- **No firewall step in `install_product`.** Ports are declared as `Socket`s on the neutral spec;
  the legacy `SystemdInstaller` opened them (firewalld over D-Bus, ufw fallback). A privileged
  driver can wire the same `open_ports` helper when needed.