install — design
Class:
design· Function axis:f6(AIRGAP6 + the install tree). Sub-parts:f6aservice tree ·f6bsystemd D-Bus ·f6cproduct ·f6dlifecycle ·f6eidentity. States only the shape we ship; the why a tree, the rejected backends, and the appliance arm are ininstall-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:
SystemdRenderer { scope }(service.rs:306) — projects toSystemdServiceSpecand calls the existingsystemd_unit_scoped;after→After=,needs→After=+Wants=; user scope dropsUser=/Group=and targetsdefault.target; verbs aresystemctl [--user] <verb>.OpenRcRenderer(service.rs:397) — emits/etc/init.d/<name>(#!/sbin/openrc-run,supervise-daemonfor restart) +/etc/conf.d/<name>(env, 0600); verbs arerc-update/rc-service.SysVRenderer(service.rs) — the universal fallback: an LSB/etc/init.d/<name>script (### BEGIN INIT INFOheader for insserv/update-rc.dordering) driving the daemon viastart-stop-daemon(--chuid user:group,--chdir), +/etc/default/<name>(env, 0600); verbsupdate-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 executablerunthat sources./conf, guards deps withsv check, andexecs underchpst -u user:group; afinishthat encodes restart (backoffsleep restart_sec, orsv down .forRestartPolicy::No, since runsv respawns on any exit); a mode-0600confenv file; and a system-scopelog/runsvlogd logger. Verbsln-symlink into the/var/servicerunsvdir + drivesv. User scope roots the dir home-relative under.local/sv/<name>, dropschpst -u+ the logger (like the systemd--userrenderer).DinitRenderer(service.rs) — emits a textualkey = valueservice 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>.loglogfile, and restart asrestart = yes+restart-delay = <restart_sec>.0(orrestart = noforRestartPolicy::No; dinit has no exit-code awareness, same honest limit as runit).needs→depends-on,after→after; env goes to a mode-0600env-file(/etc/dinit.d/config/<name>.env, literalNAME=VALUE) the description points at. Verbs aredinitctl [--user] <enable|start|status|stop>. User scope roots the description home-relative under.config/dinit.d/<name>, dropsrun-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>/: atype = longrunfile, a#!/bin/shrunscript whose singleexecchainloadss6-envdir ./env→cd <wd>→s6-setuidgid <user>→ the exact neutral command line (envdir applied from the service dir before thecd, exactly as an execline run would; no separate group directive —s6-setuidgidresolves the account, same honest limit as dinit). Restart maps to longrun supervision: on-failure/always ⇒ arestart_secbackoffsleepinfinish+ atimeout-finish = 0(so s6-supervise's default 5 s finish-kill does not truncate it);RestartPolicy::No⇒ afinishthatexit 125s — the one code that tells s6-supervise NOT to restart.needs/afterboth 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-0600env/<KEY>file per variable (the recognisedenv/dir s6-rc-compile copies verbatim, read at runtime bys6-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>, dropss6-setuidgid, and points the compiled db + scandir home-relative.KubernetesRenderer(src/k8s.rs:19,impl ServiceRendererat:142): projects the neutralServiceSpeconto 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_kubernetesplus Deployment/StatefulSet/PVC manifest-content assertions).OciRenderer(service.rs) — projects the neutralServiceSpeconto apodman-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 thepodman 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 everyneeds/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, literalNAME=value; home-relative.config/<name>/<name>.envfor 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 runhas no per-restart delay, sorestart_secis not expressible here — a documented limit, not a fake.QuadletRenderer(service.rs) — projects the neutralServiceSpeconto a systemd.containerquadlet unit (/etc/containers/systemd/<name>.container; home-relative.config/containers/systemd/<name>.containerfor user scope). A hybrid limb that REUSES the OCI image-ref handling (container_image— explicitimage, 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 rawpodman runrestart_secIS expressible),[Unit] After=/Wants=(network-online.targetsystem-only,<dep>.servicedeps), and[Install] WantedBy=(multi-user/default.target). Verbs:systemctl [--user] daemon-reload— thepodman-systemdgenerator materialises<name>.serviceand installs theWantedBysymlink on reload, so daemon-reload IS the enable-at-boot step (a quadlet has no separatesystemctl enable; documented, not faked) — thenstart/is-active/stop <name>.service.WindowsServiceRenderer(service.rs) — projects the neutralServiceSpeconto a Windows SCM service, REUSING the MSI limb'smsi::WindowsServiceSpecvocabulary + the SHAREDmsi::render_service_install(the exact<ServiceInstall>/<ServiceControl>fragment the MSI builder embeds, factored out ofmsi::render_service, L5). File: a standalone WiX.wxsatC:\ProgramData\<name>\<name>.wxs(delayed-autoType=ownProcess, the account, env in aServiceConfig, + autil:ServiceConfigfirst/second/third-failure restart recovery afterrestart_secs — sorestart_secIS expressible, unlike OCI). Windows SCM is machine-scoped (no per-user service), soscopedoes not branch; the identity isuser(anobj=/Account=) or the built-inLocalSystem. 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.execannot set env or the working dir — env lives in the.wxs;working_diris not expressible on the SCM path (documented limit, same honest class as OCI'srestart_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 throughcurrentusing the fullbundle_binarypath (the tree is unfolded whole), envDATA_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 vieworchestrate(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_linedrives a real one-shot localhost server and proves a2xxis healthy while a5xxis UNHEALTHY (a running-but-erroring service is never a false success);http_health_without_socket_errorspins the explanatory error whenHealthProbe::Httphas no listening socket. -
AgentMode invariance (
service.rs):agent_mode_is_metadata_only_same_rendered_outputproves 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_consistentwalksBackend::all(), checks each resolved renderer'sbackend()matches its lookup key (guards mis-wiring), and that ALL eleven backends resolveSome(full coverage, noNoneleft). -
runit / dinit / s6-rc renderers (
service.rs):runit_renders_a_service_dir_with_run_finish_conf_and_logrunit_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, ands6rc_renders_a_source_definition_dir_with_run_finish_deps_and_env+s6rc_no_restart_needs_and_user_scope_edgespin each backend's exact rendered command/restart/deps/ 0600-env andsv/dinitctl/s6-rcverbs (the s6-rc pair also pins theexit 125no-restart finish and thes6-envdir-before-cdchainload).
-
OpenRC supervision (
service.rs):openrc_supervision_details_and_no_env_single_filepinsrespawn_delay/command_background/pidfile and the no-env ⇒ single-file case. -
OCI renderer (
service.rs):oci_renders_a_podman_run_invocation_and_env_filepins the exactpodman runargv (--restart/--user user:group/-w/--env-file/--requires/--entrypoint+ defaulted<name>:latestimage + args-as-CMD), the 0600 literal env-file, thepodman update/stop/inspectverbs, and that a set.imageis used verbatim;oci_no_restart_no_env_and_user_scope_rootlesspins--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_verbspins the exact.containerunit ([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, thesystemctl daemon-reload(=enable)/start/is-active/stop <name>.serviceverbs, and a verbatim.image;quadlet_no_restart_needs_and_user_scope_edgespinsRestart=no,needs→After=+Wants=, and the rootless (noUser=, nonetwork-online,default.target, home-relative,systemctl --user) user scope. -
Windows service renderer (
service.rs):windows_service_renders_a_wix_fragment_and_sc_verbspins the exact WiX.wxsfragment (via the sharedmsi::render_service_install—<ServiceInstall>account/Start="auto"/DelayedAutoStart, therestartrecovery block withRestartServiceDelayInSeconds="2", the envServiceConfig,<ServiceControl>) and the fullsc.exe create … binPath=/obj=/depend=/DisplayName=+start/query/stopverbs;windows_service_no_restart_no_env_and_default_accountpins the no-recovery / no-env / no-depend=edges and theLocalSystemdefault account. The shared seam is guarded from below bymsi::render_wxs_emits_real_wix_sources(unchanged — the MSI path passesNonerecovery). -
k8s renderer (
k8s.rs):default_namespace_and_image_fallback_and_replicas(defaultsdefault/<name>:latest/replicas:1+ path/verb namespacing),no_sockets_omits_service_manifest(headless workload emits only the workload), anddeployment_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/configexports incl.nornir_spec/holger_spec) through ONLY public paths —one_public_spec_renders_across_every_implemented_backendrenders the OCI, Quadlet and Windows-SCM backends too and asserts full 11/11 coverage — so a downstream-breaking export change fails to compile here.