Gunnar

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`](airgap-design.md); the *why* in [`airgap-reasoning.md`](airgap-reasoning.md); every keystroke in [`airgap-for-idiots.md`](airgap-for-idiots.md).

![seal → carry → import → offline nornir](assets/airgap-flow.svg)

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

```bash
# ── 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|cp`s `/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:

- **Generic / registry-driven** — `nornir airgap component add <name> --bundle <bundle>` (`InstallComponentArgs`, `src/bin/nornir.rs:865`). Resolves the canonical `ComponentSpec` for `holger | tunnr | nornir-server` from the built-in registry, so no hand-written JSON. `--dry-run` prints the systemd unit it *would* install with no privilege. `--software-dir` / `--data-dir` / `--log-dir` default to `/opt/lake` · `/var/lib` · `/var/log`.
- **Spec-from-bundle** — `nornir airgap install <bundle> --spec <path>` (`AirgapInstallArgs`, `src/bin/nornir.rs:748`) reads a `ComponentSpec` JSON (typically `manifests/<name>.json` from inside the bundle) and installs it.

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`:
```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.