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

nordisk/Skidbladnir

.nornir/airgap-design.md

Airgap (Skíðblaðnir) — design

Class: design · Function axis: f0 (the pipeline) + f1 seal bundle · f2 offline build (net-scan off) · f3 install-system on the target · f4 verification. Strict: this file states only what is built, every claim source-backed. Every why, every rejected alternative, lives in airgap-reasoning.md. How to drive it: airgap-guide.md; every keystroke: airgap-for-idiots.md.

Airgap is the crate skidbladnir (codename Skíðblaðnir), a lean sibling repo (../Skidbladnir, skidbladnir/src/*.rs) that nornir consumes as a path dependency (Cargo.toml:247) and drives through the nornir airgap … CLI (src/bin/nornir.rs:673 AirgapOp, :3915 run_airgap) and four MCP tools (src/bin/nornir-mcp.rs:2551+). The event stream lands in nornir's warehouse (src/warehouse/airgap_events.rs, Iceberg schema src/warehouse/iceberg_schema.rs:309 airgap_events).

f0 — airgap as ONE thing: offline install / sealed import

Airgap is a single motion: bring nornir plus a whole workspace/product lake into a network-isolated environment, then install and start it there with zero network. One archive folds the entire stack (binaries + target-alike deps), crosses the gap once, and unfolds on the far side into running, dependency-ordered services.

The pipeline is fixed (skidbladnir/src/lib.rs:11):

pack → archive → fetch ║AIR GAP║ → unfold → install → start(ordered)
stage function crate seam lands as
pack collect artifacts + deps into a staging tree pack::Staging (pack.rs:31), pack::pack (pack.rs:95) PackManifest
archive staging tree → one signed bundle archive::TarZstd::pack (archive.rs:204) lake.tar.zst + .manifest.json + VERSION
fetch verify every byte on the target, no network fetch::verify_on_receipt (fetch.rs:42) VerifyReport
unfold (optional) provision the target machine unfold::Target (unfold.rs:22) qemu argv / terraform / redfish plan
install per-product: user, unit, unpack, ports product::SystemdInstaller::install (product.rs:266) a live systemd service
start bring the lake up in dependency order orchestrate::start_waves (orchestrate.rs:42) airgap_events DAG

The whole lake is one declarative value — config::LakeSpec { version, products: Vec<ComponentSpec>, pki } (lib.rs:129) — whose start_waves() (lib.rs:137) derives the ordered bring-up from the products' declared after edges. Nothing in the pipeline reaches the network after pack; the target side is fetch → install → start only.

Reuse map (what airgap uses): the lean core (pack·archive·fetch·platform·pki SelfGenerated·layout·product·orchestrate·events·msi::guid_v5) is pure-Rust and always compiles offline. Heavier capabilities sit behind features (znippy archive engine, pki Vault, machine/matrix KVM, the Redfish/Terraform backends) so a leaf consumer (holger, tunnr) can adopt it without nornir's stack (lib.rs:17, Cargo.toml:241). nornir provides the warehouse sink and the CLI/MCP surface; skidbladnir owns the mechanics.

f1 — the sealed bundle / import format

A bundle is a tar stream piped through zstd (archive::TarZstd, archive.rs:192), packed atomically (temp → rename, archive.rs:214). Beside it live two sidecars, never inside it:

The staging tree that becomes a bundle is assembled by Staging (pack.rs:31): add_artifact (pack.rs:50) copies a product binary under a relative bundle path (e.g. linux-x86_64/holger); add_component_spec (pack.rs:63) writes each product's ComponentSpec to manifests/<name>.json so the target reads its install detail back out of the bundle.

The Archive trait (archive.rs:86) is the engine seam: pack · list · extract_one · extract_all · read_entry (archive.rs:102, the O(1) selective-read primitive) · verify_entries (archive.rs:117) · delta/apply (archive.rs:143,:151). TarZstd is the always-offline default. Behind the znippy feature, ZnippyArchive (archive.rs:494) is a content-addressed engine — one blob per unique content hash (dedup), a seekable index + footer for O(1) read_entry without decompressing the whole archive, and a binary Patch (archive.rs:161: kept reused by hash / added blobs shipped) so an upgrade ships only the changed blobs. Patch::shipped_bytes (archive.rs:179) / reused (archive.rs:183) are the delta metrics; product::delta_supported() (product.rs:481) is cfg!(feature = "znippy").

f2 — offline build (net-scan off)

The airgap core builds and runs with no network: the lean feature set pulls only always-on deps (skidbladnir/Cargo.toml, anyhow·serde·tar·zstd·sha2·chrono·uuid·blake3·hex). Two consequences are load-bearing for airgap:

Platform prep on the target is offline: platform::Platform::detect (platform.rs:23) reads /etc/os-release (from_os_release, platform.rs:29); platform::specialize (platform.rs:56) sets SELinux permissive by writing /sys/fs/selinux/enforce directly (no setenforce subprocess), non-fatal when absent.

f3 — the install-system on the target

Per-product install is product::SystemdInstaller::install (product.rs:266), which runs the AIRGAP contract in order:

  1. Layout — layout::Layout (layout.rs:18) is version-dated: software at software_dir/<YYYYMMDD>/<product> (product_software, layout.rs:53), data at data_dir/<product> (product_data, layout.rs:58), logs at log_dir/<product> (product_log, layout.rs:64). Layout::dated (layout.rs:34) stamps today. prepare/prepare_product (layout.rs:79,:96) are idempotent.
  2. Verify — fetch::fetch_and_verify (fetch.rs:74) runs before a single byte is trusted (product.rs:285).
  3. Unpack — archive.extract_one streams just this product's binary to the version dir (product.rs:291); Layout::activate (layout.rs:115) atomically swaps the current symlink (temp symlink → rename, single rename(2)), so an upgrade is one atomic step and the old generation is retained for rollback.
  4. Configure — a minimal, data-path-aware config the unit's ExecStart --config points at is materialized (idempotent, product.rs:304).
  5. Unit — ComponentSpec::to_systemd_spec (product.rs:126) → systemd::install_systemd (systemd.rs:590), which writes the unit and enables it over systemd's D-Bus API (systemd::Systemd::connect, systemd.rs:478), no systemctl subprocess.
  6. Firewall — product::open_ports (product.rs:357) opens the product's ports over firewalld D-Bus, falling back to ufw; a missing firewall daemon is a non-fatal skip.

The unit of installation is ComponentSpec (product.rs:40: name · description · bundle_binary · exec_args · user · ports · after · restart · env · HealthCheck). A built-in registry (product::registry, product.rs:490; lookup, product.rs:499) carries the canonical specs ComponentSpec::holger (product.rs:432), tunnr (product.rs:454), nornir_server (product.rs:470), knut_flight (the N5 knut Arrow Flight server — env-only config KNUT_FLIGHT_ADDR/KNUT_FLIGHT_SCALE, port 50055, TCP health) — the app products are described, never linked as code deps. Installer::remove/status (product.rs:316,:327) complete the lifecycle over the same D-Bus seam.

Dependency-ordered bring-up is orchestrate::start_waves (orchestrate.rs:42), a Kahn topological sort over Product (product.rs:235, derived by ComponentSpec::as_product, product.rs:103): wave i is every product whose depends_on all appear in earlier waves; a cycle is a hard error (orchestrate.rs:87). start_order (orchestrate.rs:23) flattens it; teardown_order (orchestrate.rs:31) reverses it. Each product's after edges become systemd After= lines.

A backend-neutral install tree also exists — service::ServiceSpec (service.rs:137) rendered through service::ServiceRenderer (service.rs:285) with SystemdRenderer (service.rs:306) and OpenRcRenderer (service.rs:397) — plus a full install/update/uninstall lifecycle returning install::InstallOutcome data (install.rs:57, install_product/update_product/uninstall_product at install.rs:88/:140/:193, applied by install::apply, install.rs:241).

Unfold backends (the target abstraction)

unfold::Target (unfold.rs:22) and the Unfold trait (unfold.rs:34) provision the machine before install:

backend symbol offline status
KVM kvm::KvmTarget::qemu_argv (unfold.rs:64), kvm_available (unfold.rs:86) real q35/accel=kvm:tcg argv (tunnr-shaped); boots when qemu present
libvirt libvirt::LibvirtTarget (unfold.rs:138): connect_uri + domain_xml pure functions rendered; RPC drive is a pure-Rust seam
terraform terraform::render_config (unfold.rs:673), write_config (unfold.rs:681) plan rendered
redfish redfish::reset_body/power_body/insert_media_body (unfold.rs:479+) request bodies rendered; drive behind the redfish feature

f4 — verification

Every byte that crosses the gap is verified before it is trusted, with zero network IO (fetch.rs:1). fetch::verify_on_receipt (fetch.rs:42) recomputes three things into a VerifyReport (fetch.rs:20):

VerifyReport::is_ok (fetch.rs:30) is the AND of all three; fetch::fetch_and_verify (fetch.rs:74) refuses to return a Bundle on any failure, so a corrupt bundle never installs. The CLI shapes the verdict into a uniform CommandOutcome via airgap_events::verify_outcome (src/warehouse/airgap_events.rs:176), red on failure, and still exits non-zero for pipelines.

PKI on the target is offline-first: pki::PkiSource (pki.rs:28) is ExistingVault | SelfGenerated | TempVault; SelfGenerated (pki::MachineCa, pki.rs:72 behind the pki feature) issues a genuine ed25519 self-signed X.509 CA + SAN leaf (LeafCert, pki.rs:41) with a wide 10-year validity window (no NTP on target), verifiable offline (verify_leaf, pki.rs:253). Vault backends are explicit seams (VaultClient, pki.rs:306; provision, pki.rs:524).

The bring-up is recorded: events::AirgapEvent (events.rs:49) rows (run_id · seq · ts · product · op · phase · status · detail · depends_on) are appended by events::EventEmitter::emit (events.rs:132) to events::JsonlSink (events.rs:76) under the warehouse root, then ingested into the Iceberg airgap_events table (airgap_events::append_airgap_events, src/warehouse/airgap_events.rs:81; schema iceberg_schema.rs:309) and read back for verification (events_outcome, src/warehouse/airgap_events.rs:139).