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

nordisk/Skidbladnir

.nornir/airgap-guide.md

Airgap (Skíðblaðnir) — guide

Class: guide · audience: an operator sealing a workspace on a connected box and importing it on a network-isolated one. The what is in airgap-design.md; the why in airgap-reasoning.md; every keystroke in airgap-for-idiots.md.

The whole airgap surface is six CLI verbs under nornir airgap (each with an MCP twin) plus the registry-driven nornir airgap component:

verb does crate call
airgap pack staging tree → signed bundle + VERSION skidbladnir::archive::TarZstd::pack
airgap fetch verify a received bundle (zero network) skidbladnir::fetch::verify_on_receipt
airgap unfold print/drive a target-provision plan skidbladnir::unfold::*
airgap install install one component from a bundle skidbladnir::product::SystemdInstaller
airgap component add/upgrade/remove/status registry-driven component lifecycle skidbladnir::product::{registry,SystemdInstaller}
airgap start dependency-ordered bring-up + events skidbladnir::config::LakeSpec::start_waves
airgap events read the bring-up DAG back nornir::warehouse::airgap_events

f0 — the whole session: seal on a connected box → carry → import on the sealed box

# ── on the CONNECTED build host (internet OK) ─────────────────────────
# 1. Lay out a staging tree exactly as it should appear in the bundle.
mkdir -p stage/linux-x86_64 stage/manifests
cp target/release/holger        stage/linux-x86_64/holger
cp target/release/nornir-server stage/linux-x86_64/nornir-server
#    (component specs are written into manifests/<name>.json — see f4)

# 2. Pack it into a signed bundle (+ a VERSION file BESIDE it).
nornir airgap pack ./stage --out lake-20250505.tar.zst --version 20250505
#    → lake-20250505.tar.zst  +  lake-20250505.tar.zst.manifest.json  +  VERSION
#    prints: ✓ packed N entries → … (root sha256 <16hex>, VERSION beside)

# ── CARRY lake-*.tar.zst + its .manifest.json across the gap ──────────
#    USB stick · one-time relay drop · age-encrypted courier.
#    Note the printed root sha256 on the label for an out-of-band check.

# ── on the SEALED target (ZERO network) ──────────────────────────────
# 3. Verify on receipt — recomputes the manifest chain + every entry hash.
nornir airgap fetch lake-20250505.tar.zst --sha256 <root-or-file-sha>
#    ✓ green outcome, or RED + non-zero exit → refuses to go further.

# 4. (optional) inspect the unfold plan for a fresh machine.
nornir airgap unfold kvm oden-test --disk lake.img      # prints the qemu argv

# 5. Install components (root). Either the generic registry path…
sudo nornir airgap component add nornir-server --bundle lake-20250505.tar.zst
sudo nornir airgap component add holger        --bundle lake-20250505.tar.zst
#    …or a hand-written spec from inside the bundle:
sudo nornir airgap install lake-20250505.tar.zst --spec manifests/holger.json \
     --software-dir /opt/lake --data-dir /var/lib --log-dir /var/log

# 6. Bring the lake up in dependency order + record the DAG to the warehouse.
nornir airgap start lake.json          # lake.json = a LakeSpec (version+products+pki)
nornir airgap events                   # read the bring-up DAG back

The far side is fetch → install → start only — no network, no shell, no package manager. Everything a product needs arrived in the bundle.

f1 — pack: sealing the bundle

nornir airgap pack <staging> --out <bundle> [--version YYYYMMDD] (AirgapPackArgs, src/bin/nornir.rs:703). The staging tree's directory shape is the bundle's layout: linux-x86_64/<binary> for artifacts, manifests/<name>.json for each ComponentSpec. Pack tracks itself as a nornir jobs row (kind airgap, crates/nornir-jobs/src/jobs.rs:132) so a slow pack shows in the ledger, and writes the VERSION sidecar beside the bundle. Version defaults to today (%Y%m%d).

Capturing 100%-target-alike deps (when a product links against the target's userland) is a build-host step: construct a PodmanBuild with the target's base image and .step(...) capture lines; it renders a Containerfile and podman build|create|cps /out into the staging tree. This never runs on the target.

f2 — fetch: verifying on the far side

nornir airgap fetch <bundle> [--sha256 <hex>] [--json] (AirgapBundleArgs, src/bin/nornir.rs:715). The sidecar <bundle>.manifest.json must sit beside the bundle. The verdict is a CommandOutcome (green/red) built by airgap_events::verify_outcome; without --sha256 only the internal chain is attested (manifest root + every entry hash, both-ways set match), which already catches tampering and truncation. Supply the out-of-band --sha256 (from the label / secure channel) to also confirm the whole file is byte-identical. Any failure exits non-zero — gate your install script on it.

f3 — install: one component at a time

Two ways to install onto the target, both root-only on Linux:

Either path runs the same sequence: prepare version-dated dirs → verify the bundle again → stream just this binary out (extract_one) → atomic current symlink swap → write a data-path-aware config → install + enable the systemd unit over D-Bus → open firewall ports. Re-running is idempotent.

Upgrades — nornir airgap component upgrade <name> --bundle <new> (InstallUpgradeArgs, src/bin/nornir.rs:887) shares the same atomic path (a version-dir swap; the unit's ExecStart goes through current, so an upgrade is just the swap). --delta --from <old-bundle> requests a binary-delta apply via the znippy engine — build nornir --features airgap-znippy so product::delta_supported() is true and only the changed blobs ship; without it, a clear note and a full re-install. nornir airgap component remove <name> stops + removes the unit (idempotent); status [<name>] shows one unit or lists the whole registry.

f4 — start & events: ordered bring-up

nornir airgap start <lake.json> (AirgapStartArgs, src/bin/nornir.rs:767) reads a LakeSpec — { "version": "...", "products": [ ...ComponentSpec... ], "pki": "SelfGenerated" } — and derives the dependency-ordered waves from each product's after edges. It prints the waves and appends one airgap_events row per product to a JSONL sink under the warehouse, then ingests them into the Iceberg airgap_events table (non-fatal if the warehouse can't open). Set "pki":"SelfGenerated" for pure-Rust per-machine ed25519 certs (no Vault). nornir airgap destroy <lake.json> tears the lake down in reverse dependency order.

nornir airgap events [--warehouse <root>] [--json] (AirgapEventsArgs, src/bin/nornir.rs:777) reads the DAG back — one row per product×op×phase with its depends_on edges — as a uniform CommandOutcome (empty ⇒ red).

Worked example — a two-product lake

lake.json:

{
  "version": "20250505",
  "pki": "SelfGenerated",
  "products": [
    { "name": "nornir-server", "description": "nornir server", "bundle_binary": "linux-x86_64/nornir-server",
      "exec_args": [], "user": "nornir", "ports": [7878], "after": [],
      "restart": "OnFailure", "env": [], "health": { "Tcp": 7878 } },
    { "name": "holger", "description": "holger service", "bundle_binary": "linux-x86_64/holger",
      "exec_args": ["serve","--config","/etc/holger/holger.toml"], "user": "_holger", "ports": [7070],
      "after": ["nornir-server"], "restart": "OnFailure", "env": [], "health": { "Tcp": 7070 } }
  ]
}

nornir airgap start lake.json derives wave 1 = nornir-server, wave 2 = holger (it declares after: ["nornir-server"]). The same specs are what nornir airgap component add resolves from the registry.

MCP parity

Every read/plan verb has an MCP twin (src/bin/nornir-mcp.rs): airgap_pack (:2551), airgap_verify (:2580), airgap_unfold_plan (:2607), airgap_events (:2630) — so an agent drives the same pipeline as the CLI.