Airgap (Skíðblaðnir) — design
Class:
design· Function axis:f0(the pipeline) +f1seal bundle ·f2offline build (net-scan off) ·f3install-system on the target ·f4verification. Strict: this file states only what is built, every claim source-backed. Every why, every rejected alternative, lives inairgap-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:
<bundle>.manifest.json— aBundleManifest(archive.rs:52): the airgap generationversion, a per-entryVec<EntryDigest>(archive.rs:41: path · sha256 · len, sorted by path), and aroot_sha256— a SHA-256 over the sortedpath\0sha256\nlines (BundleManifest::compute_root,archive.rs:69).manifest_path(archive.rs:338) /load_manifest(archive.rs:343) resolve it.VERSION— the generation stamp written outside the bundle bypack::write_version_file(pack.rs:84), so bumping a generation is a one-line edit.
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:
- Dep capture happens on the connected build host, never on the target. When a product's runtime deps must be 100% target-alike,
pack::PodmanBuild(pack.rs:124, aContainerBuildimpl,pack.rs:107) renders aContainerfile(pack::PodmanBuild::containerfile,pack.rs:157) whose base image is the target's userland, thenpodman build/create/cpextracts/outinto the staging tree.runtime_available(pack.rs:170) gates it; absent podman is an honest error, never a faked capture (pack.rs:184). - nornir's own depgraph/security scan degrade to a named error under airgap. With
net-scanoff and no sibling checkout, unresolved path-deps surface asdocs render for <repo>: unresolved path-dep sibling(s) …rather than an opaquecargo_metadatafailure (see docs-generation.md "Sibling path-deps"). The airgap doc-export path passesNoneforimage_cache_dirso remote images are skipped, not fetched (docs-generation.md "Export pipeline" step 5).
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:
- Layout —
layout::Layout(layout.rs:18) is version-dated: software atsoftware_dir/<YYYYMMDD>/<product>(product_software,layout.rs:53), data atdata_dir/<product>(product_data,layout.rs:58), logs atlog_dir/<product>(product_log,layout.rs:64).Layout::dated(layout.rs:34) stamps today.prepare/prepare_product(layout.rs:79,:96) are idempotent. - Verify —
fetch::fetch_and_verify(fetch.rs:74) runs before a single byte is trusted (product.rs:285). - Unpack —
archive.extract_onestreams just this product's binary to the version dir (product.rs:291);Layout::activate(layout.rs:115) atomically swaps thecurrentsymlink (temp symlink →rename, singlerename(2)), so an upgrade is one atomic step and the old generation is retained for rollback. - Configure — a minimal, data-path-aware config the unit's
ExecStart --configpoints at is materialized (idempotent,product.rs:304). - 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), nosystemctlsubprocess. - Firewall —
product::open_ports(product.rs:357) opens the product's ports over firewalld D-Bus, falling back toufw; 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):
manifest_root_ok— the manifest's ownroot_sha256recomputes (BundleManifest::verify_root,archive.rs:65), so the entry list wasn't tampered.bundle_hash_ok— when an out-of-band whole-file SHA is supplied (--sha256),sha256_file(archive.rs:355) confirms the archive is byte-identical.bad_entries—Archive::verify_entries(archive.rs:117) re-derives every entry's content hash in a single streaming pass. The set must match both ways: a manifest entry absent from the bundle (truncated transfer) and an archive entry not listed in the manifest (smuggled file) both count as bad (archive.rs:126).
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).