src/lib.rs
// Höjd rekursionsgräns för `serde_json::json!` i `provision.rs`: postgres-
// StatefulSetet är ett stort literal, och när bitnamis egna env-variabler lades
// till (POSTGRESQL_* bredvid POSTGRES_*) slog makroexpansionen i standardgränsen
// på 128. Gränsen påverkar bara kompileringen, inte körningen.
#![recursion_limit = "512"]
//! # skidbladnir — *Skíðblaðnir*
//!
//! A **100% Rust** framework to **pack** a product (or a whole product lake) into an
//! air-gapped bundle, **move** it across the gap, **unfold** the target (existing server
//! via Redfish, IaC via Terraform, or a KVM VM for test), and **install + configure +
//! start** every product in dependency order — no shell glue.
//!
//! Motif: *Skíðblaðnir*, the ship that carries every god + their war-gear yet folds into a
//! pouch — fold the whole stack into one archive, carry it over the gap, unfold, raise the house.
//!
//! Pipeline: `pack → archive → fetch ║airgap║ → unfold → install → start(ordered)`.
//!
//! Design + source prompt + prior-art map: `nornir/.nornir/airgap-install.md`.
//! Plan: EPIC AIRGAP in `nornir/.nornir/nordisk_plan.md`. Prior art: `dist`,
//! `airgap-kubernetes-main`, `airgap_llm`, `tunnr`, `msi-agent`, `bifrost`.
//!
//! ## What's always-on vs feature-gated (honest)
//! - **Real, offline, pure-Rust:** [`pack`] (staging tree + VERSION-outside),
//! [`archive`] (tar+zstd bundle + content-addressed manifest), [`fetch`]
//! (verify-on-receipt, zero network), [`platform`] (os-release detect +
//! unpack), [`pki`] `SelfGenerated` (ed25519 self-signed certs that *verify*),
//! [`layout`] (version-dated dirs + atomic symlink swap), [`product`]
//! (ComponentSpec + the generalized systemd [`Installer`](crate::product::Installer)), [`orchestrate`]
//! (dependency-ordered start waves), [`events`] (airgap_events → warehouse JSONL),
//! [`msi::guid_v5`] (UUID-v5 GUID stability).
//! - **Feature-gated, fully implemented (a real call when the feature is on):** the
//! znippy archive engine ([`archive`] `znippy` feature — the REAL znippy stack:
//! seal + selective read + delta), Vault PKI ([`pki::VaultClient`]), Redfish /
//! Terraform unfold ([`unfold`]), the WiX-in-podman MSI build ([`msi::WixBuilder`]),
//! the per-target container dep-capture ([`pack::ContainerBuild`]).
use serde::{Deserialize, Serialize};
/// **Introspection / emit marker** — record one functional-status row for the
/// nornir test matrix (the constellation-wide introspection-coverage gate).
/// Wraps `nornir_testmatrix::functional_status` behind the optional `testmatrix`
/// feature: ON, it emits a real matrix row nornir reads back; OFF (prod builds),
/// it is a `tracing` event with NO nornir dependency — WARN when `ok` is false,
/// DEBUG otherwise — so a RED surface is never silently dropped. `component` is the reporting
/// unit (e.g. `"skidbladnir/install"`), `check` what it verified, `ok` the
/// verdict, `detail` a short human note. Mirrors the sibling constellation crates
/// (ordning-core, korp-collectors).
#[inline]
pub fn functional_status(component: &str, check: &str, ok: bool, detail: &str) {
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(component, check, ok, detail);
#[cfg(not(feature = "testmatrix"))]
trace_status(component, check, ok, format_args!("{detail}"));
}
/// The `testmatrix`-off sink of [`functional_status`] and `surface_status!`: one
/// `tracing` event on target `skidbladnir::surface`, RED as WARN. `detail` is
/// formatted only if a subscriber wants the event.
#[cfg(not(feature = "testmatrix"))]
#[inline]
pub(crate) fn trace_status(component: &str, check: &str, ok: bool, detail: std::fmt::Arguments<'_>) {
if ok {
tracing::debug!(target: "skidbladnir::surface", component, check, ok, "{detail}");
} else {
tracing::warn!(target: "skidbladnir::surface", component, check, ok, "{detail}");
}
}
/// Read back (drain + clear) the functional-status rows this process emitted — the
/// read-back seam a red-when-broken test uses to assert that a lifecycle surface
/// (pack · fetch · unfold · install · start · pki · systemd) reported RED. Only
/// present with the `testmatrix` feature (the process-global buffer only exists
/// then); the lean build never compiles a read-back path.
#[cfg(feature = "testmatrix")]
pub fn drain_functional_rows() -> Vec<nornir_testmatrix::TestResultRow> {
nornir_testmatrix::drain_functional_rows()
}
/// **Implanted lifecycle-surface emitter** — record ONE `functional_status` row for
/// a real lifecycle surface (pack · fetch · unfold · install · start · pki · systemd)
/// straight from the runtime path that just ran, so the matrix sees the surface's
/// *actual* verdict (`ok`/RED) with real detail, not just a test's re-derivation.
///
/// With the `testmatrix` feature it is a matrix row via the [`functional_status`]
/// shim (→ `nornir_testmatrix`). Without it (prod builds) it is a `tracing` event
/// (RED = WARN, see `trace_status`): no `format!` allocation and no `nornir`
/// dependency, the detail is formatted only when a subscriber takes the event.
macro_rules! surface_status {
($component:expr, $check:expr, $ok:expr, $fmt:literal $(, $arg:expr)* $(,)?) => {{
#[cfg(feature = "testmatrix")]
{
let _ok: bool = $ok;
$crate::functional_status($component, $check, _ok, &::std::format!($fmt $(, $arg)*));
}
#[cfg(not(feature = "testmatrix"))]
$crate::trace_status($component, $check, $ok, ::std::format_args!($fmt $(, $arg)*));
}};
}
pub(crate) use surface_status;
#[cfg(all(test, not(feature = "testmatrix")))]
mod surface_fallback_tests {
use std::sync::{Arc, Mutex};
use tracing::field::{Field, Visit};
use tracing::span::{Attributes, Id, Record};
use tracing::{Event, Level, Metadata, Subscriber};
#[derive(Default)]
struct Seen(String);
impl Visit for Seen {
fn record_debug(&mut self, f: &Field, v: &dyn std::fmt::Debug) {
self.0.push_str(&format!("{}={:?};", f.name(), v));
}
fn record_str(&mut self, f: &Field, v: &str) {
self.0.push_str(&format!("{}={};", f.name(), v));
}
}
#[derive(Clone, Default)]
struct Capture(Arc<Mutex<Vec<(Level, String)>>>);
impl Subscriber for Capture {
fn enabled(&self, _: &Metadata<'_>) -> bool {
true
}
fn new_span(&self, _: &Attributes<'_>) -> Id {
Id::from_u64(1)
}
fn record(&self, _: &Id, _: &Record<'_>) {}
fn record_follows_from(&self, _: &Id, _: &Id) {}
fn event(&self, e: &Event<'_>) {
let mut seen = Seen::default();
e.record(&mut seen);
self.0.lock().unwrap().push((*e.metadata().level(), seen.0));
}
fn enter(&self, _: &Id) {}
fn exit(&self, _: &Id) {}
}
fn captured(f: impl FnOnce()) -> Vec<(Level, String)> {
let cap = Capture::default();
tracing::subscriber::with_default(cap.clone(), f);
cap.0.lock().map(|rows| rows.clone()).unwrap()
}
#[test]
fn a_red_surface_is_a_warning_in_a_build_without_testmatrix() {
let rows = captured(|| {
crate::surface_status!("skidbladnir/install", "unit", false, "exit {}", 3);
crate::functional_status("skidbladnir/pki", "issue", false, "vault 503");
});
assert_eq!(rows.len(), 2, "{rows:?}");
for (level, _) in &rows {
assert_eq!(*level, Level::WARN, "{rows:?}");
}
let all = format!("{rows:?}");
for want in ["component=skidbladnir/install", "check=unit", "exit 3", "component=skidbladnir/pki", "check=issue", "vault 503"] {
assert!(all.contains(want), "{want} missing in {all}");
}
}
#[test]
fn a_green_surface_is_only_debug() {
let rows = captured(|| crate::surface_status!("skidbladnir/start", "up", true, "pid {}", 7));
assert_eq!(rows.len(), 1, "{rows:?}");
assert_eq!(rows[0].0, Level::DEBUG);
assert!(rows[0].1.contains("pid 7") && rows[0].1.contains("ok=true"), "{rows:?}");
}
}
/// Reusable systemd-service installer — the extracted, parameterized form of
/// `nornir install systemd` (`SystemdServiceSpec` + pure `systemd_unit`/`env_file`
/// generators + privileged `install_systemd`). Holger and other nordisk products
/// reuse this to install themselves; nornir/holger are NOT yet swapped over.
/// Design + guide: `.nornir/airgap-installer.md`.
pub use skidbladnir_systemd::systemd;
/// **An install that is silently overridden must not report success.** systemd
/// resolves a unit as the fragment PLUS `<unit>.service.d/*.conf`, and the drop-in
/// wins. Measured on oden 2026-08-03: the fragment said `User=korp`, the install
/// printed `user korp`, and `systemctl show -p User` answered `alice` — a
/// months-old `rootless-podman.conf` had decided it, and the service therefore
/// resolved its `$HOME`-derived data root into `/home/alice/.korp`. [`dropin`]
/// reads that directory before the install claims anything, names every directive
/// that contradicts the spec, and never deletes one — a drop-in is systemd's own
/// extension seam and skidbladnir does not own it. See [`dropin`].
pub use skidbladnir_systemd::dropin;
/// **Is the RUNNING process the binary on disk?** `--version` reads the FILE, not
/// the process, so a service that was rebuilt-but-not-restarted reports the new
/// hash while the old code serves (observed live on oden: `korp-ui --version` said
/// `fda8c87` while `/proc/<pid>/exe` said `(deleted)`). This answers it honestly
/// from `/proc` — deleted-inode check plus start-time-vs-mtime — and treats
/// "undetermined" as NOT a pass. See [`running::Freshness`].
pub use skidbladnir_systemd::running;
/// **Bifröst — the deploy bridge to production for `nornir-server`.** Folded in
/// from the standalone `bifrost` crate, whose binary collided on `$PATH` with
/// knut's `bifrost` (rel2graph). Reached as `skidbladnir bifrost deploy|rollback`;
/// knut keeps the standalone binary. The fold PORTED NO SHELLING — the old tool's
/// `sudo -n systemctl`, `cp …prev` and `cargo build` are all replaced by machinery
/// this crate already had (D-Bus restart, generation flip, auto-rollback on a
/// failed health probe). See [`bifrost`].
pub mod bifrost;
/// **THE UNINSTALL LAW** — `uninstall` ALWAYS kills the DATA and ALWAYS keeps the
/// SSH KEYS. Not behind a flag, not opt-in, not `--purge`: plain
/// [`service::Lifecycle::uninstall`] does both halves, every time. Holds the ONE
/// declared [`uninstall::DataRoot`] (one absolute path per product, the same whoever
/// owns the process — never `$HOME`, never scope-branched, refused at declaration
/// time if it is or contains a real user's home), the single resolver+eraser pair
/// that reads it ([`uninstall::plan_state_erase`] → [`uninstall::execute_state_erase`]),
/// the identity-sparing walk, the explicit `--wipe-identity` opt-in, and the honest
/// [`uninstall::UninstallReport`]. Design: `.nornir/uninstall-design.md`.
pub use skidbladnir_systemd::uninstall;
/// INSTALL IDENTITY — a record minted at install time (per `install_systemd_scoped`)
/// that distinguishes the same machine, a reinstall on the same machine, and a
/// different machine. A reinstall-stable host-derived `machine_fp` plus a fresh
/// UUIDv7 `install_id`. Identity references credentials (ssh key / node cert) by
/// fingerprint; it is not itself a credential. See [`identity::ensure`].
pub use skidbladnir_systemd::identity;
pub mod k8s;
/// Async k8s applier — the network half of the k8s-unfold target, gated behind
/// the `kubernetes` feature (kube+tokio) so the core stays sync.
#[cfg(feature = "kubernetes")]
pub mod k8s_apply;
/// INSTALL-TREE — the backend-neutral service-install abstraction: ONE
/// declarative [`service::ServiceSpec`] (exec · user · scope · env · deps ·
/// restart · sockets) rendered to many init/service systems through the
/// [`service::ServiceRenderer`] trait. Generalizes the systemd-only [`systemd`]
/// limb into a tree that targets modern (2015+) init systems. Spike ships two
/// real renderers — systemd (system + `--user`) and OpenRC. Design + the full
/// landscape survey: `.nornir/install-tree-design.md`.
/// Runtime readiness: what a managed service needs before it works
/// ([`readiness::await_network`]) and what it does when its work dies
/// ([`readiness::report_work_status`]). One implementation for every product.
pub mod readiness;
/// The lifecycle crate's `service` (re-exported) plus the renderers that are not a
/// systemd host's concern: Kubernetes ([`k8s`]) and the Windows SCM.
pub mod service;
mod service_windows;
/// **The `service` kind's drivable seam** — install a unit, start it, wait for a
/// NAMED READINESS MARKER the service printed, ask it a real question, tear it down.
/// The systemd half of what `draupnir::exe::ExeBoot` does for a plain process, living
/// HERE because `Cargo.toml:87` already points Skidbladnir → draupnir and cargo
/// forbids the reverse. Reuses `draupnir::Seen` so a `service` row and an `exe` row
/// report readiness in one vocabulary.
///
/// Behind `service-drive` (OFF by default) because it is the one thing in this crate
/// that pulls the `draupnir` dep for the LEAN core rather than for a heavy backend.
#[cfg(feature = "service-drive")]
pub mod service_drive;
/// Reusable **postgres (StatefulSet backend) provisioner** — stands a postgres
/// primary (Secret + Services + StatefulSet + ServiceAccount) up over kube-rs,
/// modelled field-for-field on the bitnami `postgresql` OCI subchart's render, so
/// a consumer need not shell `helm` to fetch + render that subchart. Extracted
/// from the Njord deployer (#170); gated with the async applier on `kubernetes`.
/// **Not feature-gated (2026-08-25).** The three tests here pin the RENDERED
/// bitnami-compatible postgres manifests (StatefulSet / Service / labels) as
/// `serde_json::Value` — pure shaping, no cluster. Only the two async `kube::Client`
/// appliers, `postgres` and `postgres_service_account`, still carry
/// `#[cfg(feature = "kubernetes")]`, along with the `kube`/`k8s_openapi` imports.
pub mod provision;
/// Talos apid gRPC push — the async half of the bare-metal rebirth target,
/// gated behind the `talos` feature (tonic+prost+tokio) so the core stays sync.
#[cfg(feature = "talos")]
pub mod talos_apid;
/// AIRGAP1 — collect a product's artifacts + target-identical deps into a
/// versioned staging tree; VERSION file outside the bundle for easy bumping.
pub mod pack;
/// AIRGAP-RUST (pack side) — the `cargo vendor` runner + `.cargo/config` generator
/// for the airgapped-Rust bundle. Connected-host only (runs `cargo vendor` at pack
/// time); the pure config generator is offline-testable. See [`vendor`].
pub mod vendor;
/// AIRGAP-RUST (pack side) — `pack_toolchain`, the bundle-assembly orchestrator: it
/// stages the `toolchain/` + `vendor/` + `cargo-config/` + `manifest/{bundle,
/// toolchain.pins,vendor.lock}` tree the `rust-toolchain` classifier (znippy
/// `type_id = 41`) recognises, writes the sha256+blake3 `toolchain.pins.json` (pinned
/// like the ISO kernel), and SEALS it into the signed `.znippy` via the [`archive`]
/// seam. The unfold half is [`install::install_prefix_payload`]. See [`pack_toolchain`].
pub mod pack_toolchain;
/// AIRGAP2 — the airgap bundle writer/reader. Real default: `tar + zstd`
/// ([`archive::TarZstd`]); behind the `znippy` feature, [`archive::ZnippyArchive`](crate::archive::ZnippyArchive)
/// is a thin adapter over the REAL znippy stack (`../znippy`: seal via
/// znippy-compress, selective read/verify/unpack via znippy-common) — one
/// canonical `.znippy` archive, not a twin (L5), with a content-addressed delta.
pub mod archive;
/// AIRGAP3 (transfer) — move + verify the bundle across the gap (zero network).
pub mod fetch;
/// The CONNECTED side's transfer: resumable HTTP download (`Range` resume,
/// verify-on-partial, atomic rename) for the collector that assembles a bundle.
/// No URLs live here — what/where is the consumer's knowledge (e.g. moln's pins).
#[cfg(feature = "download")]
pub mod download;
/// The CONNECTED side's vocabulary: [`coords::Arch`], [`coords::ReleaseArtifact`],
/// [`coords::ImageRef`]. Types only — the inventory (which artefacts, which
/// versions) is always the consumer's.
pub mod coords;
/// The CONNECTED side's store: ONE content-addressed blob pool keyed by sha256
/// (dedup by construction), stable name→digest bindings, snapshots, and the
/// set-difference delta.
pub mod pool;
/// Pool → bundle: select bindings, stage under human names beside the caller's
/// metadata, seal through [`archive::TarZstd`]. Partial packs are cut loudly.
pub mod cut;
/// The CONNECTED side's harvest: the retry/backoff/resume loop that lands a
/// [`coords::ReleaseArtifact`] in the [`pool`], driven by [`download`]'s engine.
/// Cancellation and logging are caller-supplied callbacks — no daemon framework.
#[cfg(feature = "download")]
pub mod harvest;
#[cfg(all(test, feature = "download"))]
pub(crate) mod testserv;
/// AIRGAP3 (target) — per-OS specialization (SELinux toggle) + unpack.
pub mod platform;
/// **What KIND of host is this** — the distro FAMILY (packaging lineage) parsed
/// from `/etc/os-release`, the init actually running as PID 1, and whether a login
/// manager exists at all. The concept the crate lacked: `Backend` modelled the
/// SUPERVISOR, `Platform` modelled only "does SELinux apply" (and puts Alpine in
/// `Other`), and `OsImage.name` was a free reporting string. Family says what to
/// INSTALL; init and logind are PROBED, because Devuan is Debian-with-sysvinit and
/// Alpine-with-elogind can do what stock Alpine cannot.
pub mod hostfamily;
/// **Rootless-container HOST SETUP** — linger, `/etc/subuid`+`/etc/subgid`, the
/// `newuidmap` helpers and `/run/user/<uid>`, as an INSPECTABLE report first and a
/// mutation second. Pure Rust: subuid files are parsed and rewritten atomically
/// (never `echo >>`), linger is set over `org.freedesktop.login1` (never
/// `loginctl`).
pub mod rootless;
/// AIRGAP4 — PKI: `PkiSource` ExistingVault | SelfGenerated | TempVault.
pub mod pki;
/// Shared **Vault-REST-with-token** HTTP transport — the ONE blocking `reqwest`
/// POST to `{addr}/v1/{path}` (+ `X-Vault-Token`) returning `(StatusCode, String)`,
/// consumed by BOTH [`pki::vault::HttpVaultClient`] and [`openbao::OpenBao`] (reuse-law
/// L5). Gated on the union of the two `reqwest`-pulling features (`vault` | `openbao`).
#[cfg(any(feature = "vault", feature = "openbao"))]
pub(crate) mod vault_http;
// The one reqwest TLS setup (stinky #26), compiled exactly when a reqwest user is.
#[cfg(any(feature = "download", feature = "vault", feature = "redfish", feature = "openbao"))]
pub(crate) mod http_tls;
/// OpenBao structure provisioner (k8s auth method + per-env read policy/role)
/// behind the `openbao` feature — the platform's half of the secret split
/// (values are written by tillsynia-admin). See [`openbao`].
#[cfg(feature = "openbao")]
pub mod openbao;
/// AIRGAP5 — data dir + version-dated software dir + atomic symlink upgrade swap.
pub mod layout;
/// AIRGAP6 — per-product install: `ComponentSpec` + the generalized `Installer`
/// (service user, systemd unit, unpack, configure data path, open ports).
pub mod product;
/// TIER-0.5 common-infra — production-shaped k8s manifest SETS for the platform
/// substrate (e.g. the full ingress-nginx object graph: SA + RBAC + ConfigMap +
/// Deployment + LoadBalancer Service + IngressClass), rendered as data.
pub mod infra;
/// PRODUCT-INSTALL LIFECYCLE — install (create service + unpack product + provision
/// data & log dirs) vs update (swap software, preserve data/log) vs uninstall
/// (remove the service AND erase data + log, keeping the ssh key + token — THE
/// UNINSTALL LAW, [`uninstall`]) over the neutral service tree. Every op
/// returns an [`install::InstallOutcome`] (dirs · files · verbs as data); a thin
/// [`install::apply`] runs the privileged verbs. Built on [`layout`] (per-product
/// data+log provisioning), [`product::ComponentSpec::to_service_spec`], [`service`]
/// renderers, [`archive`] (znippy unfold) and [`identity`].
pub mod install;
/// THE CLI DOOR — install ONE already-built **command** into a bin directory
/// (`~/.cargo/bin`, `/usr/local/bin`, a scratch prefix) and PROVE the bytes landed.
///
/// [`install`]'s three doors all lead to a *service*; a bare command has no unit, no
/// PID and no port, so the constellation's fat CLIs had no door at all — and
/// `/usr/local/bin/dwarves` sat eleven days behind the schema it validates. This one
/// stages beside the destination and `rename`s over it (the only write that survives
/// the command being RUN), then asserts the destination's own sha256 and its
/// `--version` line. Never the exit status.
pub mod cli_install;
/// CONFIG MIGRATION — the stamped, ordered schema **chain** [`install::refresh_product`]
/// replays between installing a new bundle and flipping `current`. Skidbladnir owns the
/// stamp + the chain diff + the replay ORDER; the product owns the migration itself
/// (its own `--migrate-config from=X to=Y` verb, run by the [`migrate::ProductBinRunner`]).
pub mod migrate;
/// WATCH-REFRESH — the "always-fresh oden" verb: poll git `HEAD` through **gix**
/// (zero-shell) and, when it has MOVED since the last refresh, run the existing
/// [`install::refresh_product`] engine; an unchanged `HEAD` is an honest no-op. Owns
/// only the poll/diff/stamp DECISION (testable offline via an injected HEAD + trigger);
/// the build-from-HEAD → refresh work lives behind [`watch::RefreshTrigger`]. The
/// recurrence is a systemd timer + `Type=oneshot` (rendered here), with a bounded
/// in-process [`watch::watch_refresh_loop`] as a dev alternative. The gix-backed
/// [`watch::GitHead`](crate::watch::GitHead) reader is behind the `watch-refresh` feature.
///
/// It is also **the crate's ONE timer engine**, and watch-refresh is now its default
/// shape rather than its whole purpose: [`watch::WatchTimerSpec`] renders any
/// `Type=oneshot` + `.timer` pair — its own unit name, a CHAIN of `ExecStart=` steps
/// with per-step failure tolerance ([`watch::OneshotStep`]), inline env, and a
/// wall-clock `OnCalendar=` cadence, which is the only kind of timer systemd honours
/// `Persistent=` on. [`watch::install_timer_units`] / [`watch::remove_timer_units`] /
/// [`watch::timer_is_active`] are its install, remove and status verbs, and
/// [`watch::WatchTimerSpec::validate`] is the pure gate, mirroring
/// [`systemd::SystemdServiceSpec`]'s. Every field added for the general case defaults
/// to rendering nothing, so a watch-refresh spec is byte-identical to what it always
/// was (asserted by a golden in this module's tests).
pub mod watch;
/// KUBERNETES REFRESH — [`kube_runtime::KubeRuntime::refresh`], the K8s parity of the
/// systemd [`install::refresh_product`] / [`service::Lifecycle::refresh`]: the SAME
/// stamped, ordered [`migrate`] model, one verb over two substrates. Migrate is a
/// pre-upgrade **Job** running the product's own `--migrate-config from=X to=Y` on the
/// new image; deliver+swap is the Deployment **RollingUpdate**; health is the
/// readiness gate; rollback re-applies the prior revision. The pure engine
/// ([`kube_runtime::kube_refresh`]) + its manifest renderers + the injected-mock tests
/// (including the unready-rollout rollback) compile in the lean core with NO `kube`;
/// only the ONE production [`kube_runtime::live::KubeRuntime`] impl (kube-rs over the
/// existing [`k8s_apply`] engine, zero-shell) is behind the `kubernetes` feature.
pub mod kube_runtime;
/// SERVICEBACKEND — the unified refresh verb across substrates (first slice of the
/// `.nornir/servicebackend-design` seam). One [`service_backend::ServiceRefresh`]
/// trait sitting above the backends: the systemd impl delegates to
/// [`service::Lifecycle::refresh`], the kubernetes impl to
/// [`kube_runtime::kube_refresh`] (the engine [`kube_runtime::live::KubeRuntime`]
/// wraps) — refresh parity for a product driving the seam. The remaining verbs
/// (install/upgrade/start/stop/status/uninstall) are deferred to the full landing.
pub mod service_backend;
/// AIRGAP7 — the target abstraction + three unfold backends (Redfish · Terraform · KVM).
pub mod unfold;
/// MACHINE — reusable VM/container **lifecycle control** for downstream test suites
/// (holger · nornir · tunnr): boot a real machine to test systemd/PID1/init, true
/// airgap egress isolation, kernel behaviour. Server-side only (behind `machine`);
/// never compiled into the lean embedded client. Impls behind `kvm` / `container`.
///
/// **The DATA MODEL is not feature-gated (2026-08-25).** `machine = []` pulls no
/// dependency at all — the gate was pure layering, and it hid 19 `#[test]` fns from
/// every default run for it. `NetMode`, `Mount`, `Provision`, `Readiness`, `ImageRef`,
/// `MachineSpec` and the content-addressed `ImageCache` are dep-free types and now
/// always compile. What still carries the `machine` gate is the CONTROLLER seam
/// (`MachineController`, `Handle`, `Output`); the backends behind it were already
/// gated on `kvm` / `container` / `jera` and are untouched.
pub mod machine;
/// INSTALL-MATRIX — the live multi-OS `(os × backend × scope)` install test matrix
/// (design `.nornir/install-tree-design.md` Part C): boot a KVM guest per OS, write
/// the rendered unit, run the renderer's enable/start verbs, and assert via the
/// machine-injected `skidbladnir-probe` that the service installed + started +
/// stayed healthy — emitting one [`matrix::ProbeReport`] row per cell. Heavy /
/// server-side (behind `matrix`, implies `kvm`).
/// **The MODEL and the JUDGES are not feature-gated (2026-08-25).** Only the live
/// KVM driver is: `run_cell` and `run_matrix` here, `boot_once`/`run_suite` in
/// [`vmsuite`], `boot_once`/`run_korp_suite` in [`korpprov`], plus the three
/// `crate::machine` imports they need. Everything else — the plan wire, the report
/// parser, every judge — is pure string and serde logic that never touches a VM.
pub mod matrix;
/// VM-SYSTEMD-SUITE — assert what systemd **resolved**, inside a real KVM guest.
///
/// Skidbladnir owns service creation, so a unit renderer tested by string-comparing
/// its own output is the emptiest kind of green: it asserts what it *wrote*, never
/// what systemd *did* with it after merging every drop-in on the machine. This suite
/// boots a guest, drives a plan of install/override/uninstall/reinstall cases through
/// a real init, and grades the **applied** state — `systemctl show`'s resolved
/// properties, `systemctl cat` (fragment plus drop-ins), `/proc/<MainPID>/environ`,
/// and a fresh boot's activation ordering. Several cases are engineered to FAIL, so a
/// guard that cannot say no is caught by the suite itself. Heavy / server-side
/// (behind `matrix`, implies `kvm`).
/// **Not feature-gated (2026-08-25).** This is the module whose `judge_active`
/// graded systemd's transitional `deactivating` as *stopped* (fixed `4be0017`).
/// Its 23 judge/wire tests are pure and had never run in a default build; only
/// `boot_once` and `run_suite` still carry `#[cfg(feature = "matrix")]`.
pub mod vmsuite;
/// KORP-PROVISION-SUITE — a *fresh* `korp-server --install --system` in a real KVM
/// guest, and then **nothing else**.
///
/// Where [`vmsuite`] asks "did systemd resolve what the renderer wrote?", this asks the
/// question one layer out: **does the installed service finish its own job?** It boots
/// a guest holding nothing but a binary, a thin client and a map source, runs the one
/// install verb, and then only observes — waiting for the service to cut a region, tile
/// it, upload it, and become visible to a gRPC client **without a restart**. That last
/// step is the 2026-08-03 defect: korp cut and uploaded 28 974 tiles and then answered
/// `0 region asset(s)` against its own store, because the roster is resolved once at
/// construction. Every host-side check was green throughout.
///
/// Skidbladnir does not depend on korp — the guest binaries, the map and the polygons
/// are handed in as paths, so this grades a product it does not link. Heavy /
/// server-side (behind `matrix`, implies `kvm`).
/// **Not feature-gated (2026-08-25).** 31 pure judge/plan tests; only `boot_once`
/// and `run_korp_suite` still carry `#[cfg(feature = "matrix")]`.
pub mod korpprov;
/// AIRGAP8 — dependency-ordered install + start (topo sort).
pub mod orchestrate;
/// RON ORCHESTRATION — the declarative provisioning layer on top of the imperative
/// core: a typed [`orchestration::Step`] vocabulary (serde/RON), a `.ron`
/// [`orchestration::Flow`] parser, the [`orchestration::SteeringModule`] plugin seam
/// + name→module [`orchestration::Registry`], and the [`orchestration::run_flow`]
/// interpreter that dispatches each step to its module — RON drives infra setup →
/// application install. Native (in-tree) modules now; WASM/znippy packaging later.
/// Design: `.nornir/ron-orchestration-design.md`.
pub mod orchestration;
/// Build Thing SOCKETS — the bulb→socket deployment/topology foundation (feature
/// `sockets`): the type→socket registry, the `Socket` seam + `TunnrTestSocket`
/// composition, and the WireGuard keys-panel model. Skidbladnir owns the sockets.
/// See `.nornir/socket-deployment.md`.
///
/// **NOT feature-gated (2026-08-25).** It was, and the gate hid 61 `#[test]` fns from
/// every default run — the largest dark surface in the repo — for the sake of exactly
/// THREE dep-bound items: `validate_wg_pubkey` (`base64`) and `AppAliveProbe`'s
/// `report`/`run` (`app_heartbeat`). No core type is dependency-bound: the registry,
/// the plan/topology model, the whole evidence classifier and the keys panel AS DATA
/// are pure. Those three items, their small call cone and the 14 tests that reach them
/// carry `#[cfg(feature = "sockets")]` individually now; the other 47 tests run on a
/// plain `cargo test`.
pub mod socket;
/// PROMOTE — moving an application between the slots it already lives in
/// (feature `promote`): the public, versioned **environment-ladder schema**
/// (sites · roles · guard · promotion edges · consumer `meta`), the un-lowerable
/// production fence with its two-key override, blue/green generations, release
/// operations over kube-rs (never a shell), the payload-agnostic bundle
/// envelope, and the [`promote::AppData`] seam behind which every
/// application-specific fact stays. Strictly additive and default-off: the rest
/// of the ship is unchanged with the feature off. See `.nornir/promote-design.md`.
/// **Not feature-gated (2026-08-25).** 99 library tests were dark behind `promote`,
/// a feature whose entire dependency is ONE `serde_yaml::from_str` in
/// [`promote::schema::Ladder::from_yaml`]. Every kube/tokio/flate2/base64 item in
/// this module was already behind `promote-k8s` (`release::kube_ops`). `promote`
/// now gates the YAML reader and nothing else; the schema, the slot registry, the
/// prod guard, generations, the app seam, the bundle envelope and the topology view
/// are pure serde/state logic and always compile.
pub mod promote;
/// AIRGAP9 (demo) — the korp-demo + facett-demo tunnr container + the DATA
/// components it ships (the Europe basemap): pack binaries + data into one
/// content-addressed bundle, and place the data on the target via tunnr's PID-1
/// install-on-boot. Pure Rust; the KVM boot is an honest seam. See
/// [`demo_container::DemoContainer`].
pub mod demo_container;
/// AIRGAP8 — `airgap_events` telemetry stream (warehouse JSONL sink + emitter).
pub mod events;
/// INSTALL-2 (Windows) — MSI builder seam + real UUID-v5 GUID stability.
pub mod msi;
/// UPSTREAM-REGISTRY FEDERATION — the *ship* that sails between the registry/cloud
/// **harbours** (Artifactory · Nexus · Azure Artifacts · AWS CodeArtifact) behind
/// one [`federation::UpstreamRegistry`] trait. The reusable library expert a
/// consumer (holger) HIRES to mirror/clone artifacts out of an upstream; the
/// consumer keeps the orchestration/policy, this keeps the mechanism. Gated behind
/// `federation` (+ `upstream-azure` / `upstream-aws` for the two clouds) so the
/// lean airgap core — and holger's minimal install — never pull it. See
/// `.nornir/upstream-registry-federation.md`.
/// **Not feature-gated (2026-08-25).** 36 tests were dark behind `federation`
/// (`ureq` + `base64`). The shared transport is FOUR functions in `mod.rs`
/// (`agent`, `apply_auth`, `get_bytes`, `head_meta`) and one negotiation helper in
/// `oci.rs`; every harbour client is a pure config struct plus a pure inherent impl
/// of URL/coordinate building, and only its `impl UpstreamRegistry` block reaches
/// the wire. The `UpstreamRegistry` trait itself names no ureq type.
pub mod federation;
/// Declarative lake/product descriptor (replaces the `dist` ansible playbooks).
pub mod config {
use super::pki::PkiSource;
use super::product::ComponentSpec;
use super::*;
/// A whole lake of products to airgap + install, with shared PKI + version.
/// Carries [`ComponentSpec`]s (the install detail) — the dep-graph view is
/// derived via `ComponentSpec::as_product` for [`crate::orchestrate`].
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LakeSpec {
pub version: String,
pub products: Vec<ComponentSpec>,
pub pki: PkiSource,
}
impl LakeSpec {
/// The dependency-ordered start waves for this lake (AIRGAP8).
pub fn start_waves(&self) -> anyhow::Result<Vec<Vec<String>>> {
let products: Vec<_> = self.products.iter().map(|c| c.as_product()).collect();
crate::orchestrate::start_waves(&products)
}
}
}
/// A tiny, dependency-free mock HTTP(S-less) server for the external-integration
/// tests (Vault PKI · Redfish BMC). It captures the exact request line, headers
/// and body the integration sent — so the tests assert the **request shape** (the
/// inject-assert law), not just "didn't crash" — and replays a canned response.
///
/// Hand-rolled over `std::net::TcpListener` (no test-only HTTP dep) so the lean
/// crate pulls nothing extra. Single-threaded, one request per route registered.
// Compiled only with the feature-gated tests that use it (Vault PKI, OpenBao,
// Redfish BMC), so nothing in it can go unused unnoticed.
#[cfg(all(test, any(feature = "vault", feature = "openbao", feature = "redfish")))]
pub(crate) mod mockhttp {
use gatling::background::Job;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
use std::sync::mpsc::{Receiver, channel};
/// A request the mock captured.
#[derive(Debug, Clone)]
pub struct CapturedRequest {
pub method: String,
pub path: String,
pub headers: Vec<(String, String)>,
pub body: String,
}
impl CapturedRequest {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_str())
}
}
/// A running mock server. `base_url()` is what the client connects to; on
/// drop the worker thread is joined. Serves `expect` requests then stops.
pub struct MockServer {
base_url: String,
rx: Receiver<CapturedRequest>,
handle: Option<Job<()>>,
}
impl MockServer {
/// Bind to an ephemeral localhost port; each incoming request is answered
/// with `status` + `body` (JSON), and the captured request is sent back
/// over the channel. Serves `count` requests, then the thread exits.
pub fn start(count: usize, status: u16, body_response: &'static str) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock");
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel();
// ROOT-LAW-#0: the loopback accept-loop is a long-lived background IO
// thread — route it through gatling's ONE sanctioned background spawn
// (`Job`), joined on drop below, instead of a bare std::thread::spawn.
let handle = Job::spawn(move || {
for _ in 0..count {
let (mut stream, _) = match listener.accept() {
Ok(s) => s,
Err(_) => break,
};
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut request_line = String::new();
if reader.read_line(&mut request_line).is_err() {
break;
}
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("").to_string();
let path = parts.next().unwrap_or("").to_string();
let mut headers = Vec::new();
let mut content_length = 0usize;
loop {
let mut line = String::new();
if reader.read_line(&mut line).is_err() {
break;
}
let trimmed = line.trim_end();
if trimmed.is_empty() {
break;
}
if let Some((k, v)) = trimmed.split_once(':') {
let k = k.trim().to_string();
let v = v.trim().to_string();
if k.eq_ignore_ascii_case("content-length") {
content_length = v.parse().unwrap_or(0);
}
headers.push((k, v));
}
}
let mut body = vec![0u8; content_length];
if content_length > 0 {
let _ = reader.read_exact(&mut body);
}
let _ = tx.send(CapturedRequest {
method,
path,
headers,
body: String::from_utf8_lossy(&body).to_string(),
});
let reason = if (200..300).contains(&status) {
"OK"
} else {
"ERR"
};
let resp = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{response}",
len = body_response.len(),
response = body_response
);
let _ = stream.write_all(resp.as_bytes());
let _ = stream.flush();
}
});
MockServer {
base_url: format!("http://{addr}"),
rx,
handle: Some(handle),
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
/// Block for the next captured request.
pub fn next_request(&self) -> CapturedRequest {
self.rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("mock server received a request")
}
}
impl Drop for MockServer {
fn drop(&mut self) {
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// LAW 1 (inject-assert): a whole LakeSpec round-trips through JSON AND its
/// derived start-waves respect the dependency edges declared in the specs —
/// the end-to-end "config → ordered bring-up" contract.
#[test]
fn lakespec_roundtrips_and_derives_ordered_waves() {
let lake = config::LakeSpec {
version: "20250505".into(),
pki: pki::PkiSource::SelfGenerated,
products: vec![
product::ComponentSpec::new("holger", "linux-x86_64/holger", "_holger"),
product::ComponentSpec::new(
"nornir-server",
"linux-x86_64/nornir-server",
"nornir",
)
.after("holger"),
],
};
let back: config::LakeSpec =
serde_json::from_str(&serde_json::to_string(&lake).unwrap()).unwrap();
assert_eq!(back, lake);
let waves = lake.start_waves().unwrap();
assert_eq!(waves[0], vec!["holger"], "root first");
assert_eq!(waves[1], vec!["nornir-server"], "dependent after");
}
}