airgap-install — design + guide (crate nornir-airgap, codename Skíðblaðnir)
What it is (one line): 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, cloud/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 Norse ship big enough to carry all the gods and their war-gear, yet folds up like cloth into a pouch. That is exactly the job: fold the entire stack (binaries, deps, even the "digger and cement-mixer" tooling) into one portable archive, carry it over the airgap, unfold it on the far side, and raise the house. Source analysis (Rickard's prompt) + prior-art repos are in the appendices — they are the normative source for this design.
Status: EPIC AIRGAP in nordisk_plan.md. Source repos: dist, airgap-kubernetes-main, airgap_llm, tunnr, msi-agent, bifrost.
1. The model — pack → move → unfold → install → start
[build host / internet] ║ AIRGAP ║ [target: bare metal / VM / cloud]
pack ──► archive (znippy, cache+delta) ─╫────────╫─► unfold ──► install ──► configure ──► start(ordered)
║ ║ │ │ │ │
║ ║ redfish/ per-product data path dep-ordered,
║ ║ terraform/ user+systemd + ports services may
║ ║ kvm +unpack point at each other
The "moving house" analogy (from airgap-kubernetes-main): load the truck with belongings and the tools to build the house (digger, cement mixer — slimmable later behind a feature), drive across the gap, dig the hole, pour the foundation, then unpack the belongings.
2. Phases (the dist contract, made pure-Rust)
Phase 0 — pack (build side). Collect each product's artifacts + all target-identical dependencies into a versioned bundle. Sometimes a component must be built so its deps are 100% target-alike → build in a per-target container (this is why dist builds in containers; the silly-looking wget-in-container is just dep capture). The bundle is a znippy archive with content cache + delta so the next version ships only what changed (the airgap-kubernetes speed story). A VERSION file lives outside the bundle for easy bumping (from dist).
Phase 1 — platform prep (target side). Per-platform specialization (e.g. optionally disable SELinux), fetch + verify the airgap tarball, unpack into a software directory, and:
- PKI: call an existing Vault if present, else generate the PKI ourselves (pure-Rust, or a spun-up temporary Vault). Pluggable
PkiSource. - Layout: prepare a data directory (fast-moving data) and a software directory; software is partitioned by airgap version (date-stamped, e.g.
software/20250505/), and each product gets its own subdir under the version. Future: znippy lets a product read straight from the archive — no full decompression.
Phase 2 — per-product install. For each product: create the service user, write systemd/init units, unpack its software from the decompressed software path (Phase 1), configure it to use the data path (Phase 1), and open its firewall ports.
Phase 3 — ordered start. Start all services in dependency order — products may point at each other, so this is a topo-sorted bring-up (nornir already owns dep-graph machinery — reuse it).
3. Unfold backends (the target abstraction)
One Unfold trait, three impls (from airgap-kubernetes):
redfish— provision/boot an existing physical server via its BMC (Redfish REST).terraform— drive IaC for cloud/VM fleets (wrap/emit plans; keep CLI parity).kvm— spin a local KVM VM (mostly test convenience;tunnralready has a pure-Rust KVM boot harness to reuse).
Targets also span bare-metal notebooks/servers AND a container reachable over RDP/Windows (tunnr + msi-agent) — the Target enum carries that breadth.
4. Crate module layout (nornir-airgap)
pack — collect artifacts + target-alike deps → manifest (container build when needed)
archive — znippy bundle writer/reader, content cache + delta (no full decompress on read)
fetch — move + verify the bundle across the gap (checksums, signatures)
platform — per-OS specialization (selinux toggle, prereqs)
pki — PkiSource: ExistingVault | SelfGenerated | TempVault
layout — data dir + version-dated software dir + per-product subdirs
product — Product spec: user, units (systemd/init), unpack, configure(data path), ports
unfold/ — Unfold trait + redfish · terraform · kvm
orchestrate— dependency-ordered install + start (reuse nornir dep graph / topo sort)
config — declarative lake/product descriptor (replaces the ansible playbooks)
5. Cross-cutting laws (apply here too)
- 100% Rust, no shell — replace
dist's ansible +airgap_llm's*.shwith Rust (LAW: containers/installers controlled in Rust, no*_up.sh). - znippy archive everywhere (pack + delta + read-without-full-decompress); zero-copy.
- CLI + MCP parity — every action (
nornir airgap pack|fetch|unfold|install|start) mirrored as an MCP tool. .nornirdesign + guide (this file) + an idiot guide to follow.- Persist install/airgap events to the warehouse (a
airgap_eventstable) — same DAG pattern asrelease_events.
6. Implementation status (AIRGAP1–9, 2026-06-15)
The pure-Rust offline core is implemented + inject-assert tested; the heavy external integrations are honest trait seams with explicit TODOs (a clean seam beats a hollow fake). Per item:
| item | status | where | test |
| AIRGAP1 pack | DONE | pack.rs Staging + pack + write_version_file (VERSION beside the bundle); real PodmanBuild: ContainerBuild (podman build → create → cp; renders the Containerfile, honest error if podman absent) |
pack_collects_staging_tree_and_version_file_is_outside, podman_build_renders_containerfile_and_is_honest_when_absent |
| AIRGAP2 archive | DONE (tar+zstd default; real znippy engine behind znippy) |
archive.rs Archive trait + TarZstd + BundleManifest; ZnippyArchive = content-addressed cache (dedup by SHA-256) + delta/apply (ship only changed blobs) + O(1) read_entry (footer→index→seek one blob, no full decompress). Patch type. Wired via the nornir airgap-znippy feature (nornir-airgap/znippy). |
pack_then_extract_roundtrip_with_manifest_checksums, tampered_manifest_fails_root_verify, znippy_content_cache_dedups_identical_entries, znippy_selective_read_does_not_decompress_whole_archive, znippy_delta_round_trip_is_byte_identical_and_ships_only_changes, znippy_full_extract_round_trips |
| AIRGAP3 platform | DONE | platform.rs Platform::detect/specialize (SELinux) + unpack; fetch.rs verify_on_receipt (zero network) |
os_release_detects_family_and_selinux, unpack_extracts_bundle_into_software_dir, faithful_bundle_verifies_on_receipt, tampered_manifest_is_rejected |
| AIRGAP4 PKI | DONE (SelfGenerated real; Vault seam) | pki.rs MachineCa (ed25519) issue_leaf/verify_leaf; VaultClient seam |
self_generated_leaf_is_a_real_verifiable_cert, vault_sources_are_explicit_seams |
| AIRGAP5 layout | DONE | layout.rs Layout version-dated dirs + atomic activate() symlink swap |
prepare_makes_version_dated_per_product_dirs, atomic_symlink_swap_on_upgrade |
| AIRGAP6 install | DONE | product.rs ComponentSpec + Installer/SystemdInstaller (reuses systemd::install_systemd, opens firewall ports) |
component_spec_renders_layout_driven_systemd_spec, component_spec_to_product_is_consistent |
| AIRGAP7 unfold | KVM real argv; Redfish/Terraform seams; MSI guid real | unfold.rs KvmTarget::qemu_argv (tunnr-identical), redfish/terraform seams; msi.rs WixBuilder seam + real guid_v5 |
kvm_qemu_argv_is_built_like_tunnr, terraform_resource_renders_nornir_component, external_backends_are_explicit_seams, guid_v5_is_deterministic_and_well_formed, sha1_matches_known_vector |
| AIRGAP8 ordered start | DONE | orchestrate.rs Kahn topo start_order/start_waves; events.rs AirgapEvent/JsonlSink/EventEmitter; warehouse airgap_events Iceberg table (src/warehouse/airgap_events.rs + iceberg_schema::airgap_events) |
topo_waves_from_fixture_dep_graph, cycle_is_rejected, emits_install_dag_to_warehouse_jsonl_and_reads_back, jsonl_decodes_airgap_events |
| AIRGAP9 CLI+MCP | DONE | nornir airgap pack|fetch|unfold|install|start|events (bin/nornir.rs run_airgap); MCP airgap_pack/airgap_verify/airgap_unfold_plan/airgap_events (bin/nornir-mcp.rs) |
mcp_every_tool_exercised_and_asserted (4 airgap rows) |
| INSTALL-3/4/5 adoption | DONE (holger/tunnr specs + nornir airgap component add|upgrade|remove|status); delta wired via the znippy engine (upgrade --delta --from <old> reports the diff savings; delta_supported() == the airgap-znippy feature) |
product.rs ComponentSpec::{holger,tunnr,nornir_server} + registry/lookup + delta_supported; bin/nornir.rs ComponentOp::{Add,Upgrade,Remove,Status} + run_install_* + report_delta_savings |
registry_enumerates_and_looks_up_components, holger_add_renders_exact_unit_text, tunnr_spec_renders_appliance_unit_and_delta_tracks_feature, upgrade_is_idempotent_at_spec_level |
Real now (this branch feat/airgap-archive-engine): the znippy archive engine (ZnippyArchive — content cache + delta + O(1) selective read, behind the znippy feature / nornir's airgap-znippy), the per-target container dep-capture (PodmanBuild: ContainerBuild — real podman subprocess), and the tunnr delta upgrade (airgap component upgrade --delta). The znippy format is self-contained (blob region + seekable index + footer), faithful to ../znippy's on-disk philosophy, with no rayon (the lean budget) — it does not link the znippy crate (which pulls znippy-cli).
Still seam + TODO (deliberately not faked): the Vault PKI backends (VaultClient), the Redfish + Terraform provisioning (their plans/bodies ARE rendered), and the WiX-in-podman MSI build (the UUID-v5 GUID logic IS real). KVM builds the real qemu argv but only boots when qemu-system-x86_64 is present (test/staging convenience).
7. Idiot guide (the five verbs)
# ── on the build host (internet OK) ────────────────────────────────
# 1. Lay out a staging tree exactly as it should appear in the bundle:
# stage/linux-x86_64/holger, stage/manifests/holger.json, …
# 2. Pack it into a signed bundle (+ a VERSION file beside it):
nornir airgap pack ./stage --out lake-20250505.tar.zst --version 20250505
# → writes lake-20250505.tar.zst + lake-20250505.tar.zst.manifest.json + VERSION
# ── carry lake-*.tar.zst + its .manifest.json over the gap (USB/relay/age) ──
# ── on the air-gapped target (ZERO network) ───────────────────────
# 3. Verify on receipt (recomputes manifest + every entry hash):
nornir airgap fetch lake-20250505.tar.zst # ✓ or refuses to install
# 4. (optional) inspect the unfold plan for a target:
nornir airgap unfold kvm oden-test --disk lake.img # prints the qemu argv
# 5. Install one component (root; reads its ComponentSpec JSON):
sudo nornir airgap install lake-20250505.tar.zst --spec holger.json \
--software-dir /opt/lake --data-dir /var/lib
# 6. Bring the lake up in dependency order + record the DAG to the warehouse:
nornir airgap start lake.json # lake.json = a LakeSpec
nornir airgap events # read the bring-up DAG back
Every verb has an MCP twin (airgap_pack, airgap_verify, airgap_unfold_plan, airgap_events) — CLI/agent parity (the LAW). PKI: pass "pki":"SelfGenerated" in the LakeSpec for pure-Rust ed25519 per-machine certs (no Vault needed).
7b. nornir airgap component — the registry-driven generic component path (INSTALL-3..6)
nornir airgap install (above) installs a component from a spec-JSON inside a bundle. nornir airgap component add/upgrade/remove/status is the friendlier twin: it resolves a canonical ComponentSpec by name from the built-in registry (nornir_airgap::product::registry() → holger · tunnr · nornir-server), so no hand-written JSON is needed. This is the adoption of nornir-airgap — ONE install system, not a second nornir-install crate.
nornir airgap component status # list the registry + active state
nornir airgap component status holger # one component
nornir airgap component add holger --bundle lake.tar.zst --dry-run # render the unit, no root
sudo nornir airgap component add holger --bundle lake.tar.zst # unpack + unit + ports
sudo nornir airgap component upgrade holger --bundle lake-new.tar.zst # atomic version swap
sudo nornir airgap component upgrade tunnr --bundle lake-new.tar.zst --delta --from lake.tar.zst # znippy delta
sudo nornir airgap component remove holger # stop + remove the unit (idempotent)
addandupgradeshare the same idempotent atomic path (SystemdInstaller::install→ layout version dir +currentsymlink swap); the rendered unit is version-independent (ExecStart goes throughcurrent), so an upgrade is just the swap.--deltarequests the binary-delta upgrade via the znippy zero-copy engine (archive::ZnippyArchive::{delta,apply}). Build nornir with--features airgap-znippy(→nornir-airgap/znippy) andproduct::delta_supported()istrue:upgrade --delta --from <old-bundle>reports how few bytes the diff ships (reuse N, ship M) before the atomic swap. Without the feature it prints a clear note and falls back to a full re-install.- holger/tunnr are NOT nornir code dependencies — they are described by registry specs, never linked. This keeps nornir lean while
nornir airgap component add holgerworks end-to-end. - nornir's own server has its bespoke
nornir install add/update/remove(the canonical path for the monitored server with its env file + SSH key + monitored-workspace flags). It renders the samenornir-server.serviceunit asairgap component … nornir-server— ONE unit name — butinstallis the everyday fast path; the generic component verbs are for the wider lake (holger/tunnr/bundles). -
holger self-installs too (2026-06-21). Two ways to put holger on a box:
- From nornir (top-down):
nornir airgap component add holger --bundle …— registry/bundle-driven, nornir orchestrates the whole lake (above). - From holger (self):
holger-server install {add | update | remove | status | render}— holger drives the samenornir-airgap::systemdengine itself, no bundle/registry needed (you just have the holger binary).renderis the no-root dry-run. holger owns its ownSystemdServiceSpec(verbstart --config, RON config) — the engine only applies it. This needed the newremove_systemd/status_systemdverbs so the spec layer has the full add·update·delete·status lifecycle (seeairgap-installer.md).
- From nornir (top-down):
Appendix A — SOURCE PROMPT (Rickard, normative; the earlier agent failed to filter this in)
nornir will have rust 100% framework/working crate for airgap and installation: redfish, terraform, kvm, existing servers. Here are fragments and repos touching this — perhaps different solutions but all similar: pack and move and install.
repo
dist— has airgap in containers. Why? Because sometimes it needs to build components that must be 100% target-dependencies-alike. Sometimes it's justwgetin the container — looks silly, yes, but container all over the board. A version file is also used outside so it's easy to bump version.distalso has installation ansible scripts for a lake which consists of many products; the story is the same for each product. 1) make some per-platform specialization, perhaps disable SELinux. 2) fetch airgap tarball, unpack it in a directory; generate a PKI — we must support an existing Vault to call, or generate the PKI ourselves (using Rust or calls to a temporary Vault); also prepare a data directory (fast-moving data) and a software directory (software dir has a subdir per software-generation/airgap version, e.g.20250505like a date), where each product creates a subdir for itself. 3) install each product: create the service user, create systemd/init, unpack the software from the airgap directory decompressed into the software path (step 2), and configure the product to use the data path (step 2); open firewall ports per product. In the end start all services in correct order — note they might point at each other. (Using znippy here in future removes the need for total decompression.)repo
airgap-kubernetes-main— the essence by analogy: imagine moving in the future — get a big-ass truck, pack it with all your belongings and furniture, also pack sand, cement, a digger, a cement-mixer, all kinds of tools (might be slimmed as a feature later, but now all machines needed). Drive away, roll out the digger, dig a hole, fill it with cement, build the house, then lastly unpack your belongings. So airgap-kubernetes does airgap stuff (VERY QUICKLY using cache + delta for next versions — this will be a znippy archive), then it unfolds by selecting redfish, terraform, or kvm (mostly for test convenience).repo
tunnr— a startup trying to distribute a Rust application without an OS: works for bare-metal notebooks/servers AND a container solution reachable from RDP/Windows.
Appendix B — prior-art map
| repo | contributes | note |
dist |
3-phase install, ansible lake, container dep-capture, VERSION file | replace ansible/shell with Rust |
airgap-kubernetes-main |
pack-everything, cache+delta speed, redfish/terraform/kvm unfold | → znippy archive |
airgap_llm |
prepare_airgap.sh/verify_airgap.sh workflow |
port to Rust pack/fetch |
tunnr |
Rust-as-PID1, distroless/bare-metal, KVM boot harness, RDP/container | reuse KVM + Target breadth |
msi-agent |
Windows MSI (wix) packaging | the Windows/RDP target |
bifrost |
pure-Rust deploy/rollback companion (nornir-server on oden) | the Rust-deploy precedent |