<!-- nornir:class: design -->
# draupnir — design
**Draupnir is the low-level boot / provisioning library of the nordisk
constellation.** It fires up a runtime instance from one [`BootSpec`] across three
backends and drives its power lifecycle. Named for **Draupnir**, Odin's gold ring
that drips eight identical copies of itself every ninth night — here a *light nod*:
booting a **fleet of identical machines from one ISO** is the natural extension,
not the core. The core is **one library, three boot backends**.
## Charter
- **One `Boot` trait, three backends** — Draupnir's own code is the thin unifying
seam; each backend *delegates* to an existing engine (Brokkr's minimal-dependency
law: reuse, don't reinvent).
- **KVM** → drives **[tunnr](https://codeberg.org/mikronova/tunnr)**'s
`tunnr_vm::boot_test(BootSpec) -> BootHandle` primitive. Draupnir does **not**
reimplement VM boot.
- **Container** → an OCI runtime crate (podman/Docker REST).
- **Redfish** → a BMC's out-of-band Redfish REST API to boot **bare metal**:
insert a virtual-media ISO, set the one-time boot override, power on. This is
the capability Draupnir uniquely owns.
- **Honest stubs.** The trait wiring + the pure bookkeeping (spec validation, the
fleet fan-out) are real and unit-tested; every unimplemented backend internal is
an honest `todo!("draupnir: …")` — never a fake-green stub or a sentinel string.
- **Lean by default.** A plain `cargo build` is **pure std, zero dependencies**.
Each live backend sits behind a feature; a consumer pulls only what it drives.
## Ownership — one home per capability (the no-duplication rule)
Draupnir is the **shared low-level boot lib**. Two high-level consumers depend on
it **directly**; neither re-implements boot code. Draupnir knows nothing about
jobs or services — it only fires up and controls instances.
| Component | Layer | Owns | Depends on |
|---|---|---|---|
| **Draupnir** | low-level boot lib | the `Boot`/`Lifecycle`/`VirtualMedia` traits, `BootSpec`/`Machine` types, spec validation + fleet fan-out, the **NoCloud cloud-init seed builder** (`seed` — see [`nocloud-seed.md`](nocloud-seed.md)), and the **three backend adapters** (kvm/container/redfish) | tunnr (KVM); an OCI runtime crate; a Redfish client crate; `fatfs` (seed image) |
| **tunnr** | KVM boot backend | the QEMU + OVMF (UEFI) + KVM boot primitive (`BootSpec`/`boot_test`/`BootHandle`) | — (Draupnir's KVM backend drives it) |
| **jera** (edda job handler) | thin job policy | *job* semantics only — a job is `process \| VM \| container`; the durable job ledger | **Draupnir** (directly, for VM/container job instances) |
| **Skidbladnir** | high-level service/airgap layer | service/systemd lifecycle, airgap pack/move/unfold, orchestration | **Draupnir** (directly, for service instances) |
**Call direction** — two independent consumers, one shared engine:
```text
jera ─────────┐
├──▶ Draupnir ──▶ tunnr (KVM VM)
Skidbladnir ──┘ ├─▶ OCI runtime (container / redis service)
└─▶ Redfish BMC (bare-metal virtual-media)
```
**No-duplication rule.** Instance *booting* lives in exactly one place: Draupnir.
jera does not shell out to QEMU; Skidbladnir does not carry its own KVM/container
drivers; neither reimplements the other's boot path. tunnr is the sole KVM boot
primitive, reached only *through* Draupnir's KVM backend.
> **This revises the earlier "Skidbladnir is the KVM/container mechanism master"
> decision.** Skidbladnir now **delegates instance-firing to Draupnir** and
> becomes a *consumer* of it (service/systemd, airgap, orchestration stay in
> Skidbladnir). jera likewise depends on Draupnir directly rather than re-rolling
> VM/container boot.
## Types & traits (the real core)
- `enum Backend { Kvm, Container, Redfish }` — selects the driver.
- `enum ImageSource { KernelRootfs{kernel,rootfs}, Disk, OciImage, Iso }` — the
bootable payload; `suits(Backend)` gates which backend can boot which source.
- `struct BootSpec { name, backend, image, mem_mb, cores, cmdline, env, bmc }` —
one self-contained request; `validate()` rejects mismatches (ISO to KVM; a
Redfish spec with no BMC; a non-Redfish spec carrying a BMC) **and empty required
payload paths** (a blank kernel/rootfs/disk, a blank OCI image ref, a blank ISO
path — each an all-whitespace value too) **before** any backend is touched, so a
mistyped spec fails at `validate()` with a clear message rather than deep in a
backend call. The same up-front check covers the rest of the required surface:
a **blank instance `name`** (it is the `Machine`-id / fleet-member prefix — a
blank one drips `-1`/`-2` members) on every backend; each **BMC field**
(`host`/`username`/`system_id`, woven into the Redfish URLs) on a Redfish spec;
and **VM sizing** (`mem_mb`/`cores` > 0) on a KVM spec — a 0 there would launch a
0-RAM/0-CPU guest that dies at boot. Container/Redfish carry no VM sizing (both
default to 0 by design) and are exempt from the sizing check. Published container
**`ports`** are validated too: each must be **non-zero** and **listed at most
once** — the container backend exposes/host-binds every port verbatim, so a `0`
port (never a real published port) or a doubled port (a self-colliding host
binding) is rejected up front. Finally the **container-only knobs** —
`cmd` (entrypoint override), `ports` (published ports), and `net` (the container
**network mode** — `NetMode::None` = airgap `--network none`; see
[`oci-network-mode.md`](oci-network-mode.md)) — are rejected when set on a
**non-container backend**: KVM/Redfish silently ignore them, so a `cmd`/`port`/`net`
there is a misconfiguration that would vanish without a trace (parity with a `bmc`
on a non-Redfish backend). Every backend-exclusive field is thus policed: `bmc`
is Redfish-only, `cmd`/`ports`/`net` are container-only. (The KVM-only `cloud_init` is
the one deliberate exception — it is a graceful, optional provisioning payload the
container backend is documented to ignore, not a hard misconfig, so it is
tolerated rather than rejected.)
- `struct BmcEndpoint { host, username, system_id }` — the out-of-band Redfish
endpoint. **No credential field** — the secret is supplied at drive time, so a
spec never carries one.
- `trait Boot { fn boot(&self, &BootSpec) -> Result<Machine>; }`
- `trait Lifecycle { power_on / power_off / status }`
- `trait VirtualMedia { insert_media / eject_media / set_boot_override }` — Redfish
only.
- `trait ContainerControl { container_state / drain_logs / drain_logs_split / stop /
exec / exec_command }` — the container-specific seam one level below the generic
power `Lifecycle` (which collapses every non-running state to `Off`): an
exit-code-aware `ContainerState`, streamed-log drains, and — the piece a **live
start/stop/exec lifecycle** needs — `exec(&Machine, argv) -> Result<ExecOutcome>`,
the `podman exec <id> <argv…>` analogue over the ONE OCI engine. A detached
`Boot::boot` hands back a long-lived `Machine`; a surviving handle
(`(backend: impl ContainerControl, machine: Machine)`) then runs commands *into*
it (health probe / live reconfigure / drain) without tearing it down —
`run_to_completion`, which owns the whole container lifetime, cannot express that.
`exec` is **container-only** (a KVM/Redfish `Machine` is rejected with `Error::Spec`
before the engine is touched — parity with the container-only `net`/`cmd`/`ports`
`validate()` checks) and rejects an empty argv; a non-zero exit of the exec'd
process is the `ExecOutcome::exit_code`, never an `Err`. It splits into a **provided**
`exec` (guards + assembles the canonical command via the pure `exec_argv(id, argv)`
→ `["exec", id, argv…]`) delegating to a **defaulted** `exec_command` hook
(`ContainerBoot` overrides it to drive bollard's `/exec` REST op; a mock keeps the
default, so the guard + command-assembly path is fully mock-tested with no daemon).
See [`container-exec-control.md`](container-exec-control.md).
- `fn plan_fleet(&BootSpec, n) -> Vec<BootSpec>` — the ring drips N identically-
payloaded, distinctly-named specs (fleet-from-one-ISO).
- `fn await_power_state(&L: Lifecycle, &Machine, want, &WaitOptions) -> Result<()>`
+ `fn boot_and_await(&B: Boot+Lifecycle, &BootSpec, &WaitOptions) -> Result<Machine>`
— the cross-backend **boot-status readback**: `boot()` returns before an instance
is actually up (a KVM guest still booting, a Redfish node still POSTing), so a
consumer polls `Lifecycle::status` until it reports the wanted state. Pure std,
generic over the backend, bounded-or-forever like `run_to_completion`; a never-up
node is a timeout not a hang, a `status()` error propagates, and awaiting `Unknown`
is a `Spec` error. See [`boot-readback-design.md`](boot-readback-design.md).
- `fn boot_fleet_and_await(&BootSpec, n, &B: Boot+Lifecycle, &WaitOptions) ->
FleetReadback` — the **fleet-level** boot-readback rollup: `plan_fleet` + reuse
`boot_and_await` per member (no twin), classifying each into a `MemberOutcome`
(`Up(Machine)` | `Timeout` | `Error`) and returning `FleetReadback { members }` with
`ready()`/`failed()`/`all_ready()`. Infallible — a partial fleet is observable — and
a **bounded** per-member `WaitOptions` keeps one dead node from hanging the rollup.
The multi-machine provision-readback seam jera's dispatcher wants.
- `fn boot_fleet_and_await_parallel(&BootSpec, n, &B: Boot+Lifecycle+Sync,
&WaitOptions) -> FleetReadback` — the **concurrent** sibling: fans each member onto
the gatling fork-join pool (one worker per member — ROOT-LAW #0) so `n` nodes are
booted+awaited at once and the wall-clock collapses from ~`sum` of the per-member
waits to ~the slowest member's wait. Produces an **identical `FleetReadback`** to the
serial call (both classify through the one shared `classify_member` step; threads are
joined back in fleet order). Requires `B: Sync` (shared `&backend` across threads —
all three real backends satisfy it); a `!Sync` backend uses the serial
`boot_fleet_and_await`, which stays the fully-general fallback (L2). Benched
serial-vs-parallel in `benches/fleet_boot.rs`.
## Dependencies (reuse, don't reinvent — Brokkr's minimal-dependency law)
Draupnir's own code is the thin `Boot` glue; each backend delegates to an existing
crate. Chosen (and why):
- **`tunnr-vm`** (path/optional, feature `backend-tunnr`) — the KVM boot backend
*is* tunnr's primitive; wiring it as an optional path dep (interim until tunnr
publishes) is exactly how `jera` does it, so there is one KVM boot path in the
constellation, not two.
- **OCI runtime — `bollard`** (planned, feature `backend-oci`) — the async
podman/Docker REST client `jera` already uses for its zero-shell container path;
reusing it keeps a single container engine rather than a second bespoke one.
- **Redfish — `ureq` over rustls** (feature `backend-redfish`) — a thin pure-Rust
client (no openssl/native-tls, per the charter) over the handful of endpoints we
need (`VirtualMedia` insert/eject, `Systems/{id}` `Boot` override,
`ComputerSystem.Reset`). The request *shaping* (URLs + JSON bodies) is pure and
fixture-tested with no BMC; only the actual send needs a live endpoint.
- **TLS trust.** BMCs almost always ship a **self-signed** cert. The *secure* way
to accept it is [`RedfishBoot::pin_cert_pem`] — the cert is installed as the
**sole trusted root** with rustls verification left **ON**, so the connection
stays MITM-resistant. `pin_cert_pem` takes precedence over
[`RedfishBoot::insecure`], which disables verification entirely and remains only
as a **last-resort** fall-back. A malformed pinned PEM is a hard error at agent
build, never a silent fall-through to an unverified connection.
- **Secret redaction.** `RedfishBoot` hand-rolls `Debug` so the BMC password
(`<redacted>`/`<unset>`) and the pinned-cert bytes (`<pinned>`) never reach a
log — the derived `Debug` would print the credential verbatim (LENS-4 finding).
- **znippy** (nordisk, if/when an image/ISO content-cache is needed) — reuse
Skidbladnir's content-addressed archive rather than a new one.
The network-touching backends (OCI, Redfish) sit behind OFF-by-default features so
the default build stays pure-std and offline-green; only their justified crates are
pulled when a consumer enables them.
## Extraction plan — moving instance-firing out of Skidbladnir (staged)
Instance-firing code currently lives in **`Skidbladnir/src/machine.rs`** (behind
its `machine` / `kvm` / `container` features): the `MachineController` trait with
`kvm::KvmController` (qemu/KVM + cloud-init NoCloud seed) and
`container::ContainerController` (podman/docker), plus `MachineSpec` / `Handle` /
`ImageCache`. That is exactly Draupnir's remit. **Do not rip it now** — the
scaffold is created first; the move is staged:
1. **Land Draupnir** (this repo) as the `Boot`/`Lifecycle` seam + backend
adapters. *(done — this scaffold.)*
2. **Wire the KVM backend** to `tunnr_vm::boot_test` under `backend-tunnr`
(signature already mapped in `src/kvm.rs`); port Skidbladnir's cloud-init
NoCloud seed authoring into draupnir. *(done — now the owned, pure
`src/seed.rs` builder behind the `seed` feature; the KVM adapter attaches it.
Follow-up: dedup Skidbladnir's `machine.rs` copy onto it — see step 5 /
`nocloud-seed.md`.)*
3. **Wire the container backend** to `bollard` under `backend-oci`; fold in
`ContainerController`'s start/stop/exec/wait_ready readiness model.
4. **Add the Redfish backend** (`backend-redfish`) — net-new capability, no
Skidbladnir source to move.
5. **Repoint Skidbladnir** (and jera) at Draupnir: delete `machine.rs`'s duplicate
drivers, keep only the high-level service/airgap logic that *calls* Draupnir.
Skidbladnir's `ImageCache` either moves to Draupnir or is replaced by the
znippy-backed image store.
6. **Repoint jera**'s `vm`/`container` seams at Draupnir instead of its own
`tunnr-vm` path dep, so jera keeps only job policy.
Steps 5–6 are the actual code removal and happen only after Draupnir's backends are
real (green under their features) — never a half-landed swap.
## Why a separate repo
Booting/provisioning is a distinct, versionable capability with two independent
consumers (jera, Skidbladnir). Its own leaf workspace lets both depend on one lean
crate without dragging each other's dep trees, and lets the boot engine evolve on
its own cadence — every backend a unit-testable Rust seam, not an opaque script.
## Docs
This `.nornir/` is the **source** of the repo's docs (crisp holger-pattern):
`design.md` (this file) · `guide.md` (usage) · `for-idiots.md` (the beginner
walk-through).