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

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.

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:

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:

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":