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
Boottrait, 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's
tunnr_vm::boot_test(BootSpec) -> BootHandleprimitive. 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.
- KVM → drives tunnr's
-
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 buildis 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), 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:
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 atvalidate()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 instancename(it is theMachine-id / fleet-member prefix — a blank one drips-1/-2members) 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 containerportsare validated too: each must be non-zero and listed at most once — the container backend exposes/host-binds every port verbatim, so a0port (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), andnet(the container network mode —NetMode::None= airgap--network none; seeoci-network-mode.md) — are rejected when set on a non-container backend: KVM/Redfish silently ignore them, so acmd/port/netthere is a misconfiguration that would vanish without a trace (parity with abmcon a non-Redfish backend). Every backend-exclusive field is thus policed:bmcis Redfish-only,cmd/ports/netare container-only. (The KVM-onlycloud_initis 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 powerLifecycle(which collapses every non-running state toOff): an exit-code-awareContainerState, streamed-log drains, and — the piece a live start/stop/exec lifecycle needs —exec(&Machine, argv) -> Result<ExecOutcome>, thepodman exec <id> <argv…>analogue over the ONE OCI engine. A detachedBoot::boothands back a long-livedMachine; 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.execis container-only (a KVM/RedfishMachineis rejected withError::Specbefore the engine is touched — parity with the container-onlynet/cmd/portsvalidate()checks) and rejects an empty argv; a non-zero exit of the exec'd process is theExecOutcome::exit_code, never anErr. It splits into a providedexec(guards + assembles the canonical command via the pureexec_argv(id, argv)→["exec", id, argv…]) delegating to a defaultedexec_commandhook (ContainerBootoverrides it to drive bollard's/execREST op; a mock keeps the default, so the guard + command-assembly path is fully mock-tested with no daemon). Seecontainer-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 pollsLifecycle::statusuntil it reports the wanted state. Pure std, generic over the backend, bounded-or-forever likerun_to_completion; a never-up node is a timeout not a hang, astatus()error propagates, and awaitingUnknownis aSpecerror. Seeboot-readback-design.md.
-
fn boot_fleet_and_await(&BootSpec, n, &B: Boot+Lifecycle, &WaitOptions) -> FleetReadback— the fleet-level boot-readback rollup:plan_fleet+ reuseboot_and_awaitper member (no twin), classifying each into aMemberOutcome(Up(Machine)|Timeout|Error) and returningFleetReadback { members }withready()/failed()/all_ready(). Infallible — a partial fleet is observable — and a bounded per-memberWaitOptionskeeps 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) sonnodes are booted+awaited at once and the wall-clock collapses from ~sumof the per-member waits to ~the slowest member's wait. Produces an identicalFleetReadbackto the serial call (both classify through the one sharedclassify_memberstep; threads are joined back in fleet order). RequiresB: Sync(shared&backendacross threads — all three real backends satisfy it); a!Syncbackend uses the serialboot_fleet_and_await, which stays the fully-general fallback (L2). Benched serial-vs-parallel inbenches/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, featurebackend-tunnr) — the KVM boot backend is tunnr's primitive; wiring it as an optional path dep (interim until tunnr publishes) is exactly howjeradoes it, so there is one KVM boot path in the constellation, not two. -
OCI runtime —
bollard(planned, featurebackend-oci) — the async podman/Docker REST clientjeraalready uses for its zero-shell container path; reusing it keeps a single container engine rather than a second bespoke one. -
Redfish —
ureqover rustls (featurebackend-redfish) — a thin pure-Rust client (no openssl/native-tls, per the charter) over the handful of endpoints we need (VirtualMediainsert/eject,Systems/{id}Bootoverride,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_pemtakes 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.
RedfishBoothand-rollsDebugso the BMC password (<redacted>/<unset>) and the pinned-cert bytes (<pinned>) never reach a log — the derivedDebugwould print the credential verbatim (LENS-4 finding).
- TLS trust. BMCs almost always ship a self-signed cert. The secure way to accept it is [
-
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:
- Land Draupnir (this repo) as the
Boot/Lifecycleseam + backend adapters. (done — this scaffold.) - Wire the KVM backend to
tunnr_vm::boot_testunderbackend-tunnr(signature already mapped insrc/kvm.rs); port Skidbladnir's cloud-init NoCloud seed authoring into draupnir. (done — now the owned, puresrc/seed.rsbuilder behind theseedfeature; the KVM adapter attaches it. Follow-up: dedup Skidbladnir'smachine.rscopy onto it — see step 5 /nocloud-seed.md.) - Wire the container backend to
bollardunderbackend-oci; fold inContainerController's start/stop/exec/wait_ready readiness model. - Add the Redfish backend (
backend-redfish) — net-new capability, no Skidbladnir source to move. - Repoint Skidbladnir (and jera) at Draupnir: delete
machine.rs's duplicate drivers, keep only the high-level service/airgap logic that calls Draupnir. Skidbladnir'sImageCacheeither moves to Draupnir or is replaced by the znippy-backed image store. - Repoint jera's
vm/containerseams at Draupnir instead of its owntunnr-vmpath 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).