Gunnar

mikronova/draupnir

.nornir/build-thing-launch-design.md

<!-- nornir:class: design -->
# build-thing launch axis + the machine spine — design

> **Class:** `design` · **Home:** draupnir, because draupnir is the hub the whole
> machine spine hangs from. The *how-to* is in
> [`build-thing-launch-guide.md`](build-thing-launch-guide.md). This doc is the
> map for the deep-wiring that connects a **built artifact** to a **running,
> asserted machine**, all Rust, no shell.

![the two axes and the machine spine](assets/build-thing-launch.svg)

---

## 1. The thesis: two axes, not one list

A build-thing produces an **artifact** and then (optionally) **launches** it. Those
are two different things, and conflating them is what made `exe / container / kvm /
iso` read as one list when it is really a matrix:

| BUILD (what comes out) | LAUNCH (how you run it) | verdict channel |
|---|---|---|
| `exe` — cargo build | **run** — spawn the binary | exit code + stdout |
| `container` — `ContainerBuild` (bollard) | **deploy** — `podman run` (bollard) + **logs pane** | container stdout stream |
| `vm` — `VmBuild` (kernel+rootfs) | **boot console** — `tunnr_vm::boot_test` + **serial pane** | contract markers in serial |
| `iso` — bootloader-in-disk *(new)* | **boot console** · or → USB → bare metal | same serial contract |

`kvm` is **not** an artifact — it is a launch *target*. An artifact declares which
targets it supports; the UI renders the launch control that fits. This is the whole
correction: build-thing already has `ArtifactKind ∈ {Exe,Lib,Wasm,Container,Vm,Service}`
(edda `nornir-build-thing/src/variant.rs`); what it lacks is a first-class **launch**
axis beside the build one, and a **kind-aware** rendering of it.

## 2. The machine spine — who owns what

The launch axis only works because there is exactly one machine spine underneath,
and every layer has a single job:

- **tunnr** — the muscle. Owns the QEMU/OVMF/KVM primitive (`boot_test`,
  `src/boot_primitive.rs`) **and** the OS-less appliance semantics (boot contract,
  markers, PID-1 payloads). It already boots **both** models: direct-kernel when a
  kernel is set, firmware/bootloader-in-disk when it is empty.
- **draupnir** — the thin seam. The boot/provisioning library: one `BootSpec`,
  `ImageSource`, `Boot`/`Lifecycle`, fleet fan-out, the seed builder, three backend
  adapters (`kvm`→tunnr, `oci`, `redfish`). It is the **ruler from tunnr's thinnest
  appliance to the fattest full distro** — the same axis as *direct-kernel ↔
  bootloader-in-disk*.
- **Skidbladnir** — airgap-install application. Should **delete** its own qemu argv
  builder (`unfold::kvm` / `full_argv` / `Command::new("qemu-system-x86_64")`) and
  route through `draupnir::kvm::KvmBoot`. It already consumes `draupnir::seed`; the
  launcher is the last thing it twins.
- **dwarves** — the CI/CD forge. Pure orchestrator; it **never instantiates a VM
  itself**. It calls the ready-made tool (jera → draupnir → tunnr). Its `run_vm`
  today is a TODO stub — the wiring turns it into a real call.

**No cycle:** `draupnir → tunnr-vm` (feature `backend-tunnr`) is one-directional; the
`tunnr → draupnir` edge lives only in leaf crates (`tunnr-demo`, `payloads/*`), never
in `tunnr-vm`. Clean DAG, verified against the Cargo.tomls.

## 3. The keystone change — draupnir grows a fat-dist boot model

Today `draupnir::ImageSource` (`src/lib.rs:124`) can express direct-kernel
(`KernelRootfs`) and kernel+disk (`Disk`) — but **`validate()` requires a kernel on
both** (`src/lib.rs:622-624`), so it cannot express the **kernel-less,
bootloader-in-disk** boot that a fat distro (and Skidbladnir's airgap install) needs.
That single gap is why `jera_vm_routable()` is always `false`.

The five focused edits (all in draupnir; **zero in tunnr** — the engine already does it):

1. `src/lib.rs:124` — add `ImageSource::BootloaderDisk { disk: String }` (self-booting
   disk, no `-kernel`).
2. `src/lib.rs:154` — `suits()`: `BootloaderDisk` suits `Backend::Kvm`.
3. `src/lib.rs:622` — `validate()`: require only the disk path, **not** a kernel.
4. `src/lib.rs:~463` — a `bootloader_disk` constructor peer.
5. `src/kvm.rs:74` — `to_tunnr_spec`: map `BootloaderDisk` to an **empty-kernel**
   `tunnr_vm::BootSpec` (tunnr firmware-boots the disk). And, while here, fix the
   **seed drift** (`kvm.rs:104`): use tunnr's native `BootSpec::with_seed` instead of
   hand-rolling the seed `-drive` into `extra_qemu_args`, so the seed argv is authored
   in one place.

This unlocks **both** downstream moves at once: the fat-dist launch, and Skidbladnir
routing onto draupnir.

## 4. The launch axis — declared in the model, rendered elsewhere

The wiring adds an explicit **launch** action, distinct from **build**, to the
build-thing **data model** (`edda/crates/nornir-build-thing/src/surface.rs`,
`state_json`) — kind-aware:

- **Container** → **DEPLOY** (a future bollard `podman run`).
- **Vm / iso** → **BOOT CONSOLE** (a future `tunnr_vm::boot_test`, serial + contract
  markers).
- **Exe** → **RUN** (spawn + exit code).

This is a **declaration** of what launch a cell supports, carried in `state_json` for a
consumer (dwarves, or a future UI) to render. Two deliberate scope lines:

- **nornir is out of scope.** nornir is huge and its viz (`src/viz/build_tab.rs`,
  `test_boot.rs`) is *not* touched by this wiring. The model declares the axis; where
  it renders is a separate decision.
- **logs get their own framework.** The surface exposes a typed **seam**
  (`LaunchOutput { Logs, Serial }`) as the hand-off point — it does **not** implement
  log/serial streaming. That is a separate framework, wired later.

No shell — when the executor is built, container work is the bollard Rust API and VM
work is `boot_test`; neither lives in this model phase.

## 5. Caching & sealing

- **holger** is the artifact cache/store: built container/VM artifacts are content-
  addressed and stored via dwarves' existing `HolgerArtifactStore` seam, so a launch
  can pull a prior build instead of rebuilding.
- **znippy** can seal/package an artifact set, but is **not required** for the launch
  axis today — noted as optional, not wired in this pass.

## 6. The shell island — ported to Rust

tunnr's outer Buildroot orchestration **was** `variants/buildroot/build.sh` +
`board/tunnr/post-build.sh` (shell). Both are now **deleted and ported to pure Rust**:

- **`tunnr-buildroot`** (`tunnr-vm::buildroot`) — the orchestrator: reuses
  `build_rust_init` for the inner PID-1, clones Buildroot via **gix** (behind the
  `buildroot-fetch` feature — no shelled `git`, law L9), and runs Buildroot `make`
  via a direct `Command::new("make")` (a sanctioned build-tool exec, not shell glue).
- **`tunnr-postbuild`** — the `BR2_ROOTFS_POST_BUILD_SCRIPT` hook as a **compiled Rust
  binary**. Because it is compiled it is executable by construction, so the
  `post-build.sh` mode-100644 / Error-126 bug is gone rather than patched.

`BuildShVmBuilder` calls the Rust path; the defconfigs point at the compiled hook. No
shell remains in the Buildroot path — the last island is drained.

## 7. What the wiring touches (the deep pass)

| Repo | Change | Gate |
|---|---|---|
| draupnir | `ImageSource::BootloaderDisk` + validate + `to_tunnr_spec` + seed-drift fix | `cargo test --features backend-tunnr,seed` |
| Skidbladnir | route `machine::kvm` through `draupnir::kvm`; enable `draupnir/backend-tunnr`; delete own launcher | `cargo build --features kvm,matrix` |
| tunnr | `tunnr-demo` adopts draupnir fleet fan-out for the two-node OS-less boot | `cargo build -p tunnr-demo` |
| dwarves | `run_vm` real spec via jera→draupnir; new `vm-boot` feature enabling `jera/vm-tunnr`; `VmBoot` step carries a real spec | `cargo build` + `cargo build --features vm-boot` |
| edda | launch axis **declared** in build-thing surface (kind-aware) + `LaunchOutput` logs seam | `cargo build -p nornir-build-thing` |
| ~~nornir~~ | out of scope — huge; viz rendering + logs are a separate framework | — |

Every row is compile-gated; nothing is reported wired that does not build.