Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

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. 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), 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)