# 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`](airgap-reasoning.md). How to drive it: [`airgap-guide.md`](airgap-guide.md); every keystroke: [`airgap-for-idiots.md`](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`** — a `BundleManifest` (`archive.rs:52`): the airgap generation `version`, a per-entry `Vec<EntryDigest>` (`archive.rs:41`: path · sha256 · len, sorted by path), and a `root_sha256` — a SHA-256 over the sorted `path\0sha256\n` lines (`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 by `pack::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`, a `ContainerBuild` impl, `pack.rs:107`) renders a `Containerfile` (`pack::PodmanBuild::containerfile`, `pack.rs:157`) whose base image *is* the target's userland, then `podman build`/`create`/`cp` extracts `/out` into 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-scan` off and no sibling checkout, unresolved path-deps surface as `docs render for <repo>: unresolved path-dep sibling(s) …` rather than an opaque `cargo_metadata` failure (see [docs-generation.md](docs-generation.md) "Sibling path-deps"). The airgap doc-export path passes `None` for `image_cache_dir` so 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:
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`):
- **`manifest_root_ok`** — the manifest's own `root_sha256` recomputes (`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`).