mikronova/draupnir
src/lib.rs
//! # Draupnir — the nordisk boot / provisioning library
//!
//! Draupnir is the **low-level engine that fires up a runtime instance** from a
//! single [`BootSpec`], across several engines, and drives its power lifecycle.
//!
//! **What** is started ([`Kind`]: VM, container, bare metal, program) lives on the
//! spec; **which engine** starts it is the [`Boot`] implementation the caller passes
//! to [`boot`]. The engines today:
//!
//! - [`kvm`] — a **KVM/appliance VM**, booted by driving **tunnr**'s
//! `tunnr_vm::boot_test(BootSpec) -> BootHandle` primitive (feature
//! `backend-tunnr`). Draupnir does **not** reimplement VM boot — it wires the
//! [`Boot`] trait against tunnr.
//! - [`container`] — an **OCI container**, brought up over a container runtime.
//! - [`redfish`] — **bare metal**, provisioned out-of-band through a BMC's
//! **Redfish** REST API (iLO / iDRAC / OpenBMC): insert a virtual-media ISO,
//! set the one-time boot override to that media, power the node on. This is
//! the capability Draupnir uniquely owns.
//! - [`exe`] — a **plain executable** run as a child process on this host: the
//! non-VM, non-container shape (the `exe` build-thing kind).
//!
//! ## Booting OFF a medium (2026-08-15)
//!
//! A [`BootSpec`] can name a removable [`medium`](BootSpec::medium) and a
//! [`boot_order`](BootOrder), which is what makes an appliance ISO runnable at all:
//!
//! - [`BootSpec::iso_boot`] — live-boot an ISO. **One spec**, routable to the KVM
//! backend as it stands or to real metal with [`on_metal`](BootSpec::on_metal),
//! so `iso-kvm` and `iso-metal` are two runs of one claim rather than two claims.
//! - [`BootSpec::kvm_install_from_medium`] — boot off the ISO with a blank target
//! disk attached (the INSTALL leg).
//! - [`BootSpec::kvm_boot_installed_disk`] — boot that disk with the medium
//! **absent** (the SECOND VM lifetime). [`validate`](BootSpec::validate) refuses a
//! spec that claims to boot the installed system while still holding the medium.
//!
//! A spec that names no medium and no boot order behaves exactly as it did before
//! these fields existed — both default to "emit nothing".
//!
//! ## Waiting: a thing that keeps running is the NORMAL case
//!
//! [`exe::ExeBoot::await_marker`] and [`kvm::KvmBoot::await_serial_marker`] wait for
//! a **named marker or a deadline**, never for the instance to terminate, and both
//! return the same [`Seen`] describing what was actually observed. An appliance that
//! boots correctly serves forever; waiting for it to exit is waiting for it to fail.
//!
//! ## Where Draupnir sits
//!
//! Draupnir is the **shared low-level boot lib**. Two high-level consumers depend
//! on it **directly** and neither duplicates its boot code:
//!
//! - **jera** (edda's job handler) → depends on Draupnir for job instances
//! (`process | VM | container`). jera stays thin: just job policy.
//! - **Skidbladnir** (service/systemd, airgap, orchestration) → depends on
//! Draupnir for service instances.
//!
//! Draupnir itself knows nothing about jobs or services; it just fires up and
//! controls instances. For its KVM backend it calls **down** into tunnr.
//!
//! ```text
//! jera ─────────┐
//! ├──▶ Draupnir ──▶ { tunnr (KVM) | OCI runtime | Redfish BMC }
//! Skidbladnir ──┘
//! ```
//!
//! ## The mythological nod
//!
//! Draupnir is Odin's gold ring that drips **eight identical copies** of itself
//! every ninth night. Here that is a *natural extension, not the core*: booting a
//! **fleet of identical machines from one ISO** — [`plan_fleet`] fans one
//! [`BootSpec`] out into N identical specs (bare-metal fleet provisioning via
//! Redfish). The core is one library, three boot backends.
use std::collections::BTreeMap;
use std::fmt;
use std::path::PathBuf;
use std::time::Duration;
pub mod container;
pub mod exe;
pub mod kvm;
/// draupnir's OWN qemu — the argv, the launch, the screen and the disk. See the
/// module doc for why the machine shape belongs here and not in tunnr.
pub mod qemu;
pub mod redfish;
/// **Draupnir's own BMC** — the Redfish *service* end, fronting KVM
/// ([`redfish_server`](crate::redfish_server)). Behind the `redfish-server` feature,
/// because a client-only build must never grow a listening socket.
#[cfg(feature = "redfish-server")]
pub mod redfish_server;
pub mod seed;
#[cfg(feature = "backend-youki")]
pub mod youki;
/// Draupnir's result alias.
pub type Result<T> = std::result::Result<T, Error>;
/// **Introspection / emit marker** — record one functional-status row for the
/// nornir test matrix (the constellation-wide introspection-coverage gate).
/// Wraps `nornir_testmatrix::functional_status` behind the optional `testmatrix`
/// feature: ON, it emits a real matrix row nornir reads back; OFF, it is a
/// compiled-out `#[inline]` no-op with NO nornir dependency, so the lean default
/// build never pulls it. `component` is the reporting unit (e.g. `"draupnir/seed"`),
/// `check` what it verified, `ok` the verdict, `detail` a short human note. Mirrors
/// the sibling constellation crates (skidbladnir, ordning-core, korp-collectors).
#[inline]
pub fn functional_status(component: &str, check: &str, ok: bool, detail: &str) {
#[cfg(feature = "testmatrix")]
nornir_testmatrix::functional_status(component, check, ok, detail);
#[cfg(not(feature = "testmatrix"))]
{
let _ = (component, check, ok, detail);
}
}
/// **Drain the buffered functional-status rows** as `(component, check, ok)` triples
/// (feature `testmatrix`) — returns AND clears the process-global buffer nornir's
/// matrix reads back. Exposed so a test can assert that a lifecycle surface emitted
/// the expected GREEN/RED verdict (a failed boot/probe must land as a RED row). It
/// reuses `nornir-testmatrix`'s own buffer verbatim — no twin. When the feature is
/// off there is no buffer, so this is not compiled.
#[cfg(feature = "testmatrix")]
pub fn drain_status_rows() -> Vec<(String, String, bool)> {
nornir_testmatrix::drain_functional_rows()
.into_iter()
.map(|r| (r.suite, r.test_name, r.status == "pass"))
.collect()
}
/// Everything that can go wrong firing up or controlling an instance.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
/// The requested backend is not compiled in (build with its feature) or not
/// available on this host.
Unsupported(String),
/// A live backend (tunnr / OCI runtime / Redfish BMC) reported a failure.
Backend(String),
/// The [`BootSpec`] is internally inconsistent (e.g. an ISO image handed to
/// the KVM backend, or a Redfish spec with no BMC endpoint).
Spec(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Unsupported(m) => write!(f, "draupnir: unsupported: {m}"),
Error::Backend(m) => write!(f, "draupnir: backend error: {m}"),
Error::Spec(m) => write!(f, "draupnir: invalid boot spec: {m}"),
}
}
}
impl std::error::Error for Error {}
/// **What** a [`BootSpec`] starts — the kind of instance, never the engine that
/// starts it.
///
/// The engine is the [`Boot`] implementation the caller hands to [`boot`]: a
/// [`Kind::Vm`] can be fired by [`kvm::KvmBoot`] (QEMU via tunnr) today, and by an
/// incus or KubeVirt engine tomorrow, from the same spec. Until 0.2.0 this enum was
/// `Backend { Kvm, Container, Redfish, Exe }`, which named an engine for two of its
/// four members (`Kvm`, `Redfish`) and a kind for the other two — so an engine that
/// runs both VMs and containers had no honest place in it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
/// A virtual machine with its own kernel.
Vm,
/// An OCI container.
Container,
/// A **bare-metal** node, provisioned out-of-band (Redfish BMC).
BareMetal,
/// A **plain executable** run as a child process on this host — the non-VM,
/// non-container shape ([`exe::ExeBoot`]). This is the `exe` build-thing kind:
/// a binary stage 1 produced, started here, its stdout/stderr streamed, and
/// awaited on a **named marker or a deadline** — never on termination, because
/// a server that serves forever never terminates.
Exe,
}
/// The bootable payload — the *source* an instance is fired up from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ImageSource {
/// A kernel + rootfs/initramfs pair (the KVM appliance path → tunnr).
KernelRootfs {
/// Kernel image path (`-kernel`).
kernel: String,
/// Rootfs/initramfs path (`-initrd`).
rootfs: String,
},
/// A bootable disk image, qcow2 or raw (the KVM disk path → tunnr). tunnr's
/// direct-kernel launch still needs a `-kernel`, so the disk carries the
/// kernel to boot it with explicitly (there is no in-image bootloader path).
Disk {
/// Kernel image path (`-kernel`) used to direct-boot the disk.
kernel: String,
/// Bootable disk image path (qcow2 or raw), attached as a virtio drive.
disk: String,
},
/// A self-booting disk image with an **in-image bootloader** (the KVM
/// firmware-boot path → tunnr). No `-kernel`: OVMF boots the disk directly,
/// exactly as real hardware would. tunnr firmware-boots whenever the kernel
/// path is empty (`is_direct_kernel_boot()` false), so this variant carries
/// only the disk.
BootloaderDisk {
/// Bootable disk image path (qcow2 or raw), attached as a virtio drive
/// and booted by firmware (no `-kernel`).
disk: String,
},
/// An OCI image reference, e.g. `docker.io/library/redis:7` (container path).
OciImage(String),
/// A **bootable ISO**, booted *off the medium*: served as Redfish **virtual
/// media** to a BMC (bare metal), or attached to a KVM guest as a `-cdrom`
/// the firmware boots from ([`BootOrder::Medium`]).
///
/// It suits **both** [`Kind::BareMetal`] and [`Kind::Vm`] — deliberately,
/// and this is the whole of honesty rule 2: *one* spec, routed to the faked
/// KVM backend or to real metal, so "it works in the demo" and "it works on the
/// customer's server" stop being different claims. tunnr already renders an ISO
/// payload as `-cdrom <iso> -boot d`, so the KVM leg is its existing firmware
/// path, not a new one. Until 2026-08-15 `suits` rejected `(Iso, Kvm)`, which is
/// exactly the wall that kept the ISO chain from ever running.
Iso(String),
/// A **plain executable** on this host — the `exe` build-thing kind, run as a
/// child process by [`exe::ExeBoot`] (suits [`Kind::Exe`] only).
///
/// `args` are the process's own argv (argv[1..]); they live here rather than on
/// [`BootSpec::cmd`] because `cmd` is the container entrypoint override and is
/// validated container-only — an exe's arguments are part of *what it is*, not a
/// container knob.
Executable {
/// Path to the binary to run (stage 1's artifact).
path: String,
/// Arguments passed to it (argv[1..]).
args: Vec<String>,
},
}
impl ImageSource {
/// Whether this payload is a legal source for `backend` — the KVM backend
/// boots kernel+rootfs, a disk, **or an ISO off the medium**; the container
/// backend an OCI image; Redfish an ISO; and [`Kind::Exe`] an
/// [`Executable`](ImageSource::Executable). Used by [`BootSpec::validate`].
///
/// `(Iso, Kvm)` is legal as of 2026-08-15 — the one deliberate widening in this
/// pass. It was rejected before, which is precisely why the ISO chain could not
/// run under KVM at all; tunnr has rendered an ISO payload as `-cdrom <iso>
/// -boot d` since it was written (`tunnr/src/boot_primitive.rs:758-765`), so
/// nothing new is being invented here — only un-forbidden.
pub fn suits(&self, kind: Kind) -> bool {
matches!(
(self, kind),
(ImageSource::KernelRootfs { .. }, Kind::Vm)
| (ImageSource::Disk { .. }, Kind::Vm)
| (ImageSource::BootloaderDisk { .. }, Kind::Vm)
| (ImageSource::OciImage(_), Kind::Container)
| (ImageSource::Iso(_), Kind::BareMetal)
| (ImageSource::Iso(_), Kind::Vm)
| (ImageSource::Executable { .. }, Kind::Exe)
)
}
}
/// A BMC (baseboard management controller) endpoint — the out-of-band Redfish
/// service on a bare-metal node (iLO / iDRAC / OpenBMC).
///
/// The secret (password / session token) is supplied out of band at drive time
/// and is deliberately **not** a field here, so a [`BootSpec`] never carries a
/// credential.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BmcEndpoint {
/// Base URL of the Redfish service, e.g. `https://bmc-42.dc.example`.
pub host: String,
/// Redfish account username.
pub username: String,
/// The Redfish `ComputerSystem` resource id, e.g. `System.Embedded.1`.
pub system_id: String,
}
/// The one-time boot device a Redfish node is overridden to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BootTarget {
/// Boot from virtual media / CD (the ISO we inserted).
Cd,
/// Network / PXE.
Pxe,
/// The local disk.
Hdd,
/// Drop into BIOS/UEFI setup.
BiosSetup,
}
/// **Which device the firmware is told to boot from** — the boot-order half of
/// the boot-off-a-medium seam ([`BootSpec::boot_order`]).
///
/// This exists because *install* and *boot-the-installed-system* are two different
/// machines, not two phases of one. Honesty rule 1 of the chain driver: install in
/// one VM off the medium, **detach the medium, power-cycle, boot the disk in a
/// SECOND VM**. A caller must be able to say those two things as two distinct
/// specs, and a spec that claims to boot the installed disk must be *unable* to
/// carry the installer medium — otherwise "it booted" can silently be the installer
/// booting a second time.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BootOrder {
/// **Unchanged**: draupnir emits no boot-order of its own and the backend keeps
/// whatever it already did (tunnr's payload-shaped default; a Redfish node's
/// existing boot list). This is the [`Default`] — a spec that never named a boot
/// order produces a byte-identical launch to one written before this field
/// existed.
#[default]
Auto,
/// Boot **off the removable medium** — QEMU `-boot d`, Redfish
/// [`BootTarget::Cd`]. The install leg.
Medium,
/// Boot **off the local disk** — QEMU `-boot c`, Redfish [`BootTarget::Hdd`].
/// The boot-the-installed-system leg; [`BootSpec::validate`] refuses this
/// together with a medium, by construction.
Disk,
/// **The CD first, the disk after it** — QEMU `-boot order=dc`, Redfish
/// [`BootTarget::Cd`] (a one-time CD override falls back to the disk the
/// same way).
///
/// This is the cloud's own order and the mechanism of behaviour 17: a server
/// whose medium is still inserted boots the installer AGAIN, forever, and
/// nothing inside the guest can end it — only an eject from outside. The
/// estate could not express that at all while `Medium` (`-boot d`) and
/// `Disk` (`-boot c`) were the only orders: `d` alone cannot fall through to
/// the disk, so an install that finished looked like a machine that had no
/// system.
MediumThenDisk,
/// **The disk first, the CD after it** — QEMU `-boot order=cd`, the cloud's
/// `boot_order = "disk,cdrom"` (UpCloud: "disk / cdrom / network or comma
/// separated combination").
///
/// The fast install shape (2026-09-26, "5 minutes"): a BLANK boot disk is
/// not bootable, so the firmware falls through to the medium and the
/// installer runs; the installer REBOOTS when it is done, the disk now
/// boots, and nothing outside has to stop, detach, re-order or start the
/// machine. So a machine in this order is not `-no-reboot`: a guest reboot
/// is a reset in place, as the cloud's hypervisor does it, and the firmware
/// reads this order again — the whole mechanism.
DiskThenMedium,
}
/// **Which tray the removable medium rides in** — see
/// [`BootSpec::medium_attach`].
///
/// A medium has two faces and they are not interchangeable. The firmware reads
/// El Torito off an **IDE cdrom**; a PID 1 with no ATA driver finds the same
/// bytes only on **virtio**, by their `CD001` magic. Which face is needed is a
/// property of the guest KERNEL, not of the medium, so it cannot be inferred
/// here and has to be said.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MediumAttach {
/// `-cdrom <medium>` — the IDE cdrom, and nothing else. What every spec did
/// before this word existed, and what UpCloud does to an uploaded ISO, so it
/// stays the [`Default`]: an untouched spec renders a byte-identical launch.
#[default]
Cdrom,
/// `-drive file=<medium>,if=virtio,format=raw,readonly=on` — the virtio face
/// alone, for a guest whose firmware boots something else and whose kernel
/// has no ATA at all.
Virtio,
/// **Both faces of one medium**, cdrom first. The shape an appliance install
/// actually needs, and the one this crate could not express until
/// 2026-09-05: OVMF boots El Torito off the cdrom while the appliance's
/// PID 1 reads the volume over virtio.
///
/// MEASURED 2026-08-15 (gunnar), which is why this variant exists: driving
/// the install through this backend with `Cdrom` alone died with
/// `INSTALLER: volume: no block device carries the ISO-9660 magic (CD001)`
/// — the appliance kernel is the `nuked` preset and vetoes ATA/AHCI/SCSI
/// outright, so the cdrom the firmware had just booted from was invisible to
/// the system that firmware started. The same trap bit a real deploy one
/// cloud over (UpCloud, 2026-08-28: an ISO attached only as an IDE cdrom is
/// "not a bootable disk"; the fix there was a second, virtio attachment of
/// the same image).
Both,
}
impl MediumAttach {
/// Whether this is [`Cdrom`](MediumAttach::Cdrom) — the additive-parity
/// guard (a spec that never named an attachment must render as before).
pub fn is_default(self) -> bool {
matches!(self, MediumAttach::Cdrom)
}
/// Whether the firmware gets an El-Torito cdrom to boot from.
pub fn has_cdrom(self) -> bool {
matches!(self, MediumAttach::Cdrom | MediumAttach::Both)
}
/// Whether the medium is also readable as a virtio block device.
pub fn has_virtio(self) -> bool {
matches!(self, MediumAttach::Virtio | MediumAttach::Both)
}
}
impl BootOrder {
/// The QEMU `-boot` value this renders to, or [`None`] for [`Auto`](BootOrder::Auto)
/// (no `-boot` emitted → the launch is byte-identical to before this field).
pub fn qemu_value(self) -> Option<&'static str> {
match self {
BootOrder::Auto => None,
BootOrder::Medium => Some("d"),
BootOrder::Disk => Some("c"),
// `order=dc` and not `dc`: the long spelling is what QEMU documents
// for a LIST, and it is the spelling the oden measurement ran.
BootOrder::MediumThenDisk => Some("order=dc"),
BootOrder::DiskThenMedium => Some("order=cd"),
}
}
/// The Redfish one-time [`BootTarget`] this renders to, or [`None`] for
/// [`Auto`](BootOrder::Auto) (leave the node's boot list alone).
///
/// This is the *other half* of honesty rule 2: the SAME [`BootSpec`] expresses
/// "boot the medium" once, and each backend translates it into its own
/// vocabulary — `-boot d` here, `BootSourceOverrideTarget: "Cd"` there.
pub fn redfish_target(self) -> Option<BootTarget> {
match self {
BootOrder::Auto => None,
BootOrder::Medium => Some(BootTarget::Cd),
BootOrder::Disk => Some(BootTarget::Hdd),
// A one-time CD override IS "the CD first, the disk after it" on a
// Redfish node: the override applies to one boot and the node's own
// list takes over, which is the same fall-through `order=dc` gives.
BootOrder::MediumThenDisk => Some(BootTarget::Cd),
// No one-time override can say "disk, and the CD if the disk is blank";
// the node's own list (disk first) is the nearest, so no override.
BootOrder::DiskThenMedium => None,
}
}
/// **The inverse of [`redfish_target`](BootOrder::redfish_target)** — read a
/// Redfish `BootSourceOverrideTarget` back as a boot order.
///
/// It lives here, next to its forward direction, because draupnir owns *both*
/// ends of the Redfish protocol: the client turns a `BootOrder` into a
/// `BootSourceOverrideTarget`, and [`redfish_server`](crate::redfish_server) —
/// draupnir's own BMC — has to turn the received target back into the boot order
/// it will actually apply. Two functions in two modules would be two mappings,
/// and the one that mattered would be the one nobody tested.
///
/// [`None`] (no override) is [`Auto`](BootOrder::Auto). A target draupnir does
/// not model as a boot order — `Pxe`, `BiosSetup`, and every other DMTF
/// `BootSource` token — yields [`Option::None`]: a service that cannot honour a
/// target must **refuse it by name**, never silently substitute one it can.
pub fn from_redfish_target(target: Option<BootTarget>) -> Option<BootOrder> {
match target {
None => Some(BootOrder::Auto),
Some(BootTarget::Cd) => Some(BootOrder::Medium),
Some(BootTarget::Hdd) => Some(BootOrder::Disk),
Some(BootTarget::Pxe) | Some(BootTarget::BiosSetup) => None,
}
}
}
/// **The UEFI firmware a KVM guest boots on** — the pflash CODE/VARS pair, chosen
/// as ONE thing.
///
/// The host ships the halves as separate files (`OVMF_CODE_4M.fd`,
/// `OVMF_VARS_4M.fd`, `OVMF_CODE_4M.secboot.fd`, `OVMF_VARS_4M.ms.fd`), and a
/// caller handed two paths can pair them wrongly: the signature-ENFORCING CODE
/// with the stock, empty VARS template boots a guest that enforces against an
/// empty key store — i.e. it looks like Secure Boot and refuses nothing. That
/// combination is not discouraged here, it is **unrepresentable**: each variant
/// names both halves, so the pairing is a property of the type (the same shape as
/// [`BootOrder`], which cannot express "boot the disk with the installer medium
/// still in the tray").
///
/// The paths themselves are a **host fact**, resolved at boot time by
/// [`resolve`](Firmware::resolve) — `/usr/share/OVMF` is where Debian/Ubuntu put
/// them, Fedora uses `/usr/share/edk2/ovmf`, Arch `/usr/share/edk2-ovmf/x64`, and
/// `DRAUPNIR_OVMF_DIR` overrides the lot. A declared firmware that is not on this
/// host is **refused by name** — never substituted, because a guest that quietly
/// boots non-enforcing firmware when Secure Boot was asked for turns "the unsigned
/// image was refused" into a green that proved nothing.
///
/// Only the [`kvm`] backend has pflash: [`validate`](BootSpec::validate) refuses a
/// non-default firmware on any other backend rather than let it vanish.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Firmware {
/// **Unchanged**: draupnir names no firmware and tunnr's own stock discovery
/// (`tunnr_vm::locate_ovmf`) picks the pair, exactly as every boot before this
/// field existed. The [`Default`] — the launch is byte-identical.
#[default]
HostDefault,
/// **Stock UEFI**, named explicitly: `OVMF_CODE_4M.fd` + `OVMF_VARS_4M.fd`.
/// This firmware verifies NOTHING — it is the ordinary UEFI boot. Choosing it
/// by name (rather than [`HostDefault`](Firmware::HostDefault)) says "plain
/// UEFI, and fail loudly if this host has not got it".
Uefi,
/// **Secure Boot with the Microsoft keys enrolled**:
/// `OVMF_CODE_4M.secboot.fd` + `OVMF_VARS_4M.ms.fd`. The CODE build that
/// actually enforces signatures, paired with the vars template whose PK/KEK/db
/// are pre-populated — the shape under which a shim/GRUB/kernel chain signed by
/// the UEFI CA boots and an unsigned image is REFUSED.
SecureBoot,
/// **Secure Boot with the caller's OWN key store**: the enforcing
/// `OVMF_CODE_4M.secboot.fd` paired with a vars template the caller enrolled
/// itself (`korp_installer::secureboot::enroll_platform_keys` returns exactly
/// such an image). The CODE half is still fixed by the type — an enrolled key
/// store on non-enforcing firmware is the misconfiguration this whole enum
/// exists to make impossible.
///
/// tunnr copies the template into the guest's private workdir, so the enrolled
/// store is never written back to by a boot.
SecureBootEnrolled {
/// Path to the enrolled `OVMF_VARS` image (PK/KEK/db already populated).
vars_template: String,
},
/// **SeaBIOS — the legacy BIOS path, and the one a cloud actually gives you.**
///
/// No pflash at all. `rom` names the BIOS image (`Some`, e.g.
/// `/usr/share/seabios/bios-256k.bin`) or leaves QEMU to use its own built-in
/// SeaBIOS (`None`). Either way OVMF is neither searched for nor staged, so a
/// BIOS guest boots on a host with no `ovmf` package.
///
/// It exists because **UpCloud offers no firmware knob and every guest there
/// boots SeaBIOS**, while every KVM proof this estate has run was UEFI. A
/// proof on OVMF exercises a boot path the real box never takes — measured on
/// oden 2026-09-20 against `gunnar-installer-open-fullk.iso`; the clause table
/// is in `mock-upcloud`'s `kvm` module. [`enforces_secure_boot`] is false and
/// says so: a BIOS machine cannot verify anything.
///
/// [`enforces_secure_boot`]: Firmware::enforces_secure_boot
Bios {
/// The SeaBIOS ROM image, or `None` for QEMU's built-in.
rom: Option<String>,
},
}
/// **Whether the guest has a serial console at all** — see
/// [`BootSpec::serial`].
///
/// Every KVM proof this estate has run captured a serial console and keyed its
/// verdict off a marker line on it. A cloud guest has NO serial console: a PID-1
/// panic there reaches nobody and exists only as pixels. A harness that quietly
/// gave such a guest a serial port would make behaviour 13 untestable while
/// reporting it tested — the always-yes defect this estate has now found four
/// times.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SerialConsole {
/// **Unchanged**: `-serial mon:stdio`, captured, and a marker can be observed
/// on it. The [`Default`] — every spec written before this field renders the
/// launch it always did.
#[default]
Stdio,
/// `-serial none`. Nothing narrates the boot; a caller that wants a verdict
/// must read the screen.
None,
}
/// **What the guest's screen is, and who looks at it** — see
/// [`BootSpec::display`].
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Display {
/// **Unchanged**: the backend's own default (tunnr's `headless`), which is
/// `-nographic` — no framebuffer at all. The [`Default`].
#[default]
Auto,
/// **A framebuffer, and only that**: `-vga std` plus `-display none`, and
/// `-vnc <spec>` when `vnc` is set (e.g. `127.0.0.1:91`). The screen exists,
/// nobody sits in front of it, and a screendump can hash it.
///
/// `-nographic` would have removed the one surface a PID-1 panic appears on,
/// which is why "headless" and "no screen" had to stop being the same word.
Framebuffer {
/// The QEMU `-vnc` value, or `None` for a screen nothing serves.
vnc: Option<String>,
},
}
/// **The QEMU machine type** — see [`BootSpec::machine`].
///
/// `Q35` is what draupnir has always rendered and stays the default, so an
/// existing caller's argv does not move. `Pc` (i440fx, PIIX IDE) is the machine
/// UpCloud hands out (MEASURED, ledger L46): its `-cdrom` sits on a real IDE
/// channel, where on q35 it rides AHCI — a kernel without ATA reads neither, a
/// kernel with only AHCI reads one and not the other, so a proof on the wrong
/// chipset proves the wrong machine.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MachineType {
#[default]
Q35,
Pc,
}
impl MachineType {
/// The `-machine` type word.
pub fn qemu_value(self) -> &'static str {
match self {
MachineType::Q35 => "q35",
MachineType::Pc => "pc",
}
}
}
/// **What the guest's real-time clock reads** — see [`BootSpec::rtc`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Rtc {
/// **Unchanged**: QEMU's default, the host's own clock. The [`Default`].
#[default]
Host,
/// The RTC starts `seconds` away from the host's clock (`-rtc base=<t>`,
/// rendered as UTC). `+7200` is the skew every yvra VM boots with and the
/// reason `gunnar-clock` exists — a rig whose guests boot at the right time
/// makes that machinery look unnecessary.
SkewedBy { seconds: i64 },
}
/// **Which MACHINE a KVM guest is** — firmware, serial console and framebuffer
/// as one named choice, because they are not independent knobs: they describe a
/// class of real machine, and mixing them by hand is how a proof ends up on a
/// machine nobody ships.
///
/// MEASURED, and the reason this type exists: `kvm.rs` set `headless = true` and
/// tunnr's `build_qemu_args` pushed `-serial mon:stdio` and the OVMF pflash pair
/// **unconditionally**. So every KVM proof in this constellation ran on a UEFI
/// machine with a serial console and no framebuffer — while a console-only cloud
/// VM (UpCloud) is SeaBIOS, has no serial port, and shows the guest only through
/// VNC. Booting `gunnar-installer-open-fullk.iso` in that shape produced **zero
/// bytes on stdout**: every line the installer had to say was on the framebuffer
/// and nowhere else. A proof that cannot be run on the customer's machine is not
/// a proof of the customer's machine.
///
/// Mapped onto tunnr's own `GuestShape` by
/// [`kvm::KvmBoot::to_tunnr_spec`], which REFUSES rather than approximates when
/// the rest of the spec contradicts the shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GuestShape {
/// **UEFI + captured serial console + no framebuffer.** What every KVM boot
/// here has silently used, and the right machine for a local marker-driven
/// smoke boot: `success_marker` works because there is a wire to carry it.
#[default]
UefiSerial,
/// **SeaBIOS + no serial port + a VGA framebuffer.** The machine a
/// console-only cloud VM actually is. A guest in this shape can carry no
/// `BOOT-OK` marker at all — its evidence is
/// [`kvm::KvmBoot::screendump`], or whatever the guest itself reports over
/// the network. Naming a [`BootSpec::success_marker`] together with it is a
/// named refusal, not a marker that is quietly ignored.
BiosFramebuffer,
}
/// Directories searched for OVMF images, in order, when
/// [`Firmware::resolve`] is asked for a pair. Debian/Ubuntu first (the
/// constellation's own hosts), then Fedora, Arch and the QEMU share dir.
/// [`OVMF_DIR_ENV`] prepends to this list.
pub const OVMF_SEARCH_DIRS: &[&str] = &[
"/usr/share/OVMF",
"/usr/share/ovmf",
"/usr/share/edk2/ovmf",
"/usr/share/edk2-ovmf/x64",
"/usr/share/qemu",
];
/// Operator override for the OVMF search path — a `:`-separated list of
/// directories, tried BEFORE [`OVMF_SEARCH_DIRS`]. The escape hatch for a distro
/// (or a container image) that puts the firmware somewhere draupnir does not know,
/// so a host layout never needs a rebuild — the same shape as
/// `DRAUPNIR_VM_BOOT_TIMEOUT`.
pub const OVMF_DIR_ENV: &str = "DRAUPNIR_OVMF_DIR";
impl Firmware {
/// The CODE-image basenames this choice accepts, in preference order (the `_4M`
/// spelling is Debian/Ubuntu's; the bare one is everyone else's).
fn code_candidates(&self) -> &'static [&'static str] {
match self {
// A BIOS machine has no CODE image to find: `Bios` is resolved by
// `resolve_in` before it ever gets here.
Firmware::HostDefault | Firmware::Bios { .. } => &[],
Firmware::Uefi => &["OVMF_CODE_4M.fd", "OVMF_CODE.fd"],
Firmware::SecureBoot | Firmware::SecureBootEnrolled { .. } => {
&["OVMF_CODE_4M.secboot.fd", "OVMF_CODE.secboot.fd"]
}
}
}
/// The VARS-**template** basenames this choice accepts, in preference order.
/// Empty for [`SecureBootEnrolled`](Firmware::SecureBootEnrolled), whose vars
/// image is an explicit path rather than a discovered file.
fn vars_candidates(&self) -> &'static [&'static str] {
match self {
Firmware::HostDefault
| Firmware::SecureBootEnrolled { .. }
| Firmware::Bios { .. } => &[],
Firmware::Uefi => &["OVMF_VARS_4M.fd", "OVMF_VARS.fd"],
Firmware::SecureBoot => &["OVMF_VARS_4M.ms.fd", "OVMF_VARS.ms.fd"],
}
}
/// Does this firmware actually **enforce** image signatures? The question a
/// caller asks before claiming a Secure Boot result: only an enforcing CODE
/// build can turn "the unsigned image was refused" into a measurement.
pub fn enforces_secure_boot(&self) -> bool {
matches!(
self,
Firmware::SecureBoot | Firmware::SecureBootEnrolled { .. }
)
}
/// A short, stable label — what a GUI firmware picker shows, and what a config
/// file spells. Paired with [`from_label`](Firmware::from_label).
pub fn label(&self) -> &'static str {
match self {
Firmware::HostDefault => "host-default",
Firmware::Uefi => "uefi",
Firmware::SecureBoot => "secure-boot",
Firmware::SecureBootEnrolled { .. } => "secure-boot-enrolled",
Firmware::Bios { .. } => "bios",
}
}
/// The choices a picker offers without further input — the three that need no
/// caller-supplied path. [`SecureBootEnrolled`](Firmware::SecureBootEnrolled)
/// is deliberately absent: it carries a vars image only the caller can name.
pub fn pickable() -> [Firmware; 3] {
[Firmware::HostDefault, Firmware::Uefi, Firmware::SecureBoot]
}
/// Read a [`label`](Firmware::label) back. Unknown text is [`None`] — a config
/// that names a firmware this crate does not model must be refused, never
/// silently downgraded to the host default.
pub fn from_label(label: &str) -> Option<Firmware> {
match label {
"host-default" => Some(Firmware::HostDefault),
"uefi" => Some(Firmware::Uefi),
"secure-boot" => Some(Firmware::SecureBoot),
"bios" => Some(Firmware::Bios { rom: None }),
_ => None,
}
}
/// The directories to search on THIS host: `$DRAUPNIR_OVMF_DIR` (`:`-separated)
/// first, then [`OVMF_SEARCH_DIRS`].
pub fn search_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = std::env::var(OVMF_DIR_ENV)
.unwrap_or_default()
.split(':')
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.collect();
dirs.extend(OVMF_SEARCH_DIRS.iter().map(PathBuf::from));
dirs
}
/// Resolve this choice to the `(CODE, VARS-template)` pair on this host, or
/// [`None`] for [`HostDefault`](Firmware::HostDefault) (nothing named → tunnr's
/// own discovery, byte-identical argv).
///
/// Searches [`search_dirs`](Firmware::search_dirs). A firmware that is not
/// there is an [`Error::Spec`] naming the files looked for and the directories
/// searched — never a fall-back to a different firmware.
pub fn resolve(&self) -> Result<Option<(PathBuf, PathBuf)>> {
self.resolve_in(&Self::search_dirs())
}
/// [`resolve`](Firmware::resolve) against an explicit directory list — the seam
/// the unit tests drive, so firmware resolution is provable against a known
/// directory instead of against whatever the build host has installed.
pub fn resolve_in(&self, dirs: &[PathBuf]) -> Result<Option<(PathBuf, PathBuf)>> {
// Neither of these has a pflash pair: `HostDefault` leaves the choice to
// the backend, and `Bios` HAS no OVMF by definition. Returning `None` for
// the BIOS case is not a fallback to UEFI — the caller that receives it
// must emit no pflash at all, which is what `qemu::argv` does.
if matches!(self, Firmware::HostDefault | Firmware::Bios { .. }) {
return Ok(None);
}
let code = Self::find_one(self.label(), "CODE", self.code_candidates(), dirs)?;
let vars = match self {
// The caller named its own enrolled key store: it is a path, not a
// discovery. Refuse it by that exact path if it is not on disk — a
// Secure Boot run without the keys it says it enrolled is not a run.
Firmware::SecureBootEnrolled { vars_template } => {
let p = PathBuf::from(vars_template);
if !p.is_file() {
return Err(Error::Spec(format!(
"firmware `{}`: the enrolled OVMF_VARS image `{}` is not on this \
host — a Secure Boot guest cannot be given a key store that does \
not exist, and booting it on the stock template would enforce \
against an empty db",
self.label(),
p.display()
)));
}
p
}
_ => Self::find_one(self.label(), "VARS", self.vars_candidates(), dirs)?,
};
Ok(Some((code, vars)))
}
/// First existing `<dir>/<candidate>` over the cartesian product, or an
/// [`Error::Spec`] that names every candidate and every directory tried.
fn find_one(
label: &str,
half: &str,
candidates: &[&str],
dirs: &[PathBuf],
) -> Result<PathBuf> {
for dir in dirs {
for c in candidates {
let p = dir.join(c);
if p.is_file() {
return Ok(p);
}
}
}
Err(Error::Spec(format!(
"firmware `{label}`: no OVMF {half} image on this host — looked for [{}] in [{}]. \
Install the firmware (Debian/Ubuntu: `ovmf`) or point {OVMF_DIR_ENV} at it; \
draupnir will NOT substitute a different firmware for the one you named",
candidates.join(", "),
dirs.iter()
.map(|d| d.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)))
}
}
/// The power state of an instance.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PowerState {
/// Running.
On,
/// Powered off.
Off,
/// Not yet observed / indeterminate.
Unknown,
}
/// The container **network mode** — how the OCI backend attaches the container to
/// a network. Only the [`container`](BootSpec::container) backend acts on it
/// (KVM/Redfish carry no container network). [`Default`](NetMode::Default) is the
/// runtime default (podman/Docker's own choice — **no** `--network` flag, so the
/// created `HostConfig.network_mode` stays unset and the create body is byte-
/// identical to a spec that never named a net mode). The airgap case is
/// [`None`](NetMode::None): it renders `--network none` and cuts the container off
/// from all egress — the load-bearing wire for Skidbladnir's airgap container route.
///
/// Mirrors jera's `ContainerSpec` `NetMode` field-for-field so a jera container-run
/// routes its net choice through this **one** draupnir OCI engine — jera passes its
/// rendered value across with [`NetMode::from_oci_value`]`(jera_spec.net.oci_value())`
/// (no cross-repo variant coupling; the wire is the OCI string `"none"`/`"host"`/`"bridge"`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NetMode {
/// The runtime default — **no** `--network` flag; `HostConfig.network_mode`
/// stays unset (byte-identical to a spec that never set a net mode).
#[default]
Default,
/// Airgap: `--network none` — the container gets no network (loopback only).
None,
/// Share the host network namespace (`--network host`).
Host,
/// The default bridge network (`--network bridge`).
Bridge,
}
impl NetMode {
/// The OCI `HostConfig.network_mode` value this renders to, or [`Option::None`]
/// for [`Default`](NetMode::Default) (leaves the field unset → the runtime
/// default). `Some("none")` is the airgap value the container backend threads
/// onto the create body.
pub fn oci_value(self) -> Option<&'static str> {
match self {
NetMode::Default => Option::None,
NetMode::None => Some("none"),
NetMode::Host => Some("host"),
NetMode::Bridge => Some("bridge"),
}
}
/// Whether this is [`Default`](NetMode::Default) (no net flag) — the additive-
/// parity guard (a default-net spec must produce the unchanged create body).
pub fn is_default(self) -> bool {
matches!(self, NetMode::Default)
}
/// Reconstruct a `NetMode` from an OCI network-mode string — jera's
/// `oci_value()` output: `Some("none"|"host"|"bridge")` maps to the matching
/// variant; [`Option::None`] (or any unrecognised value) maps to
/// [`Default`](NetMode::Default). This is the **cross-repo wire** jera passes
/// without depending on draupnir's variant names.
pub fn from_oci_value(value: Option<&str>) -> Self {
match value {
Some("none") => NetMode::None,
Some("host") => NetMode::Host,
Some("bridge") => NetMode::Bridge,
_ => NetMode::Default,
}
}
}
/// **cloud-init NoCloud provisioning** for a KVM appliance boot: the `user-data`
/// (and optional `meta-data`) authored into a small FAT seed image (volume label
/// `cidata`) the guest's cloud-init picks up at first boot.
///
/// Only the [`kvm`] backend consumes it — containers have no init firstboot and
/// the Redfish path provisions the metal itself. It is pure data (zero deps); the
/// seed *image* is authored by the KVM adapter behind `backend-tunnr`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CloudInit {
/// The cloud-init `user-data` document (typically begins `#cloud-config`).
pub user_data: String,
/// The `meta-data` document; when `None` a minimal default carrying an
/// `instance-id`/`local-hostname` is supplied by the seed builder.
pub meta_data: Option<String>,
/// The optional NoCloud `network-config` document (cloud-init network schema).
/// `None` → no `network-config` file is written to the seed and the guest keeps
/// its default (usually DHCP). Present → authored as the third seed file.
pub network_config: Option<String>,
}
impl CloudInit {
/// A NoCloud provision from a `user-data` document (default `meta-data`, no
/// `network-config`).
pub fn user_data(user_data: impl Into<String>) -> Self {
Self {
user_data: user_data.into(),
meta_data: None,
network_config: None,
}
}
/// Attach a NoCloud `network-config` document (builder style).
pub fn with_network_config(mut self, network_config: impl Into<String>) -> Self {
self.network_config = Some(network_config.into());
self
}
}
/// A container **published-port mapping** — a distinct `host:container` pair
/// (podman `-p HOST:CONTAINER`). This is the general publish form: a service
/// listening on a **fixed port inside** the container (FalkorDB always binds
/// `6379`, Spark-Connect `15002`) can be published on a **different host port**
/// so several isolated copies coexist on one host — e.g. per-zone offsets
/// (`Demo` on `6379`, `Test` on `6380`, `Prod` on `6381`) that all reach the
/// same in-container `6379`. Carried on [`BootSpec::port_maps`].
///
/// The single-port field ([`BootSpec::ports`], a bare `u16`) is exactly the
/// `host == container` special case and stays the byte-identical default — a
/// spec that uses only `ports` renders the same create body it always did. Both
/// forms are published (`ports` as `host==container`, `port_maps` as the pair),
/// so a spec may carry either or both.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct PortMap {
/// The **host** port the publish binds on the host (the `HOST` in `-p
/// HOST:CONTAINER`); this is what a client on the host connects to.
pub host: u16,
/// The **container** port the service listens on *inside* the container
/// (the `CONTAINER` in `-p HOST:CONTAINER`); fixed by the image/service.
pub container: u16,
}
impl PortMap {
/// A distinct `host:container` publish — the general form (the host port
/// may differ from the in-container port, e.g. a per-zone offset).
pub fn new(host: u16, container: u16) -> Self {
Self { host, container }
}
/// The `host == container` publish — the single-port special case (the same
/// mapping the bare-`u16` [`BootSpec::ports`] form produces).
pub fn same(port: u16) -> Self {
Self {
host: port,
container: port,
}
}
}
impl From<u16> for PortMap {
/// A bare port maps `host == container` (the single-port form).
fn from(port: u16) -> Self {
Self::same(port)
}
}
impl From<(u16, u16)> for PortMap {
/// A `(host, container)` tuple is the distinct-mapping form.
fn from((host, container): (u16, u16)) -> Self {
Self::new(host, container)
}
}
/// A **self-contained boot request**. One shape fires up any backend; the
/// [`backend`](BootSpec::backend) selects the driver and [`validate`] enforces
/// that the [`image`](BootSpec::image) (and, for Redfish, the [`bmc`]) match.
///
/// [`bmc`]: BootSpec::bmc
// NB: `PartialEq` only (not `Eq`): the container `cpus` cap is an `Option<f64>`,
// and `f64` is not `Eq`. Every `==`/`assert_eq!` on a `BootSpec` needs only
// `PartialEq`; `BootSpec` is never used as a hash/btree key, so dropping `Eq` is
// additive (no consumer required it).
#[derive(Debug, Clone, PartialEq)]
pub struct BootSpec {
/// Human/instance name (also the fleet-member prefix).
pub name: String,
/// **What** this starts. The engine that starts it is the [`Boot`] passed to
/// [`boot`], not a field here.
pub kind: Kind,
/// The bootable payload source.
pub image: ImageSource,
/// Guest/appliance RAM in MiB (ignored by the bare-metal Redfish path).
pub mem_mb: u32,
/// vCPU count (ignored by the bare-metal Redfish path).
pub cores: u32,
/// Kernel/boot command line, if the backend takes one.
pub cmdline: String,
/// Container command / entrypoint override (empty = the image's own default).
/// Only the [`container`] backend acts on it; ignored by KVM/Redfish.
pub cmd: Vec<String>,
/// Container ports to publish, each bound to the **same host port**
/// (`host == container`) — the single-port form. Only the [`container`]
/// backend acts on them; ignored by KVM/Redfish. For a **distinct**
/// `host:container` publish (a per-zone host offset onto a fixed in-container
/// port) use [`port_maps`](BootSpec::port_maps); both are published.
pub ports: Vec<u16>,
/// Container **distinct-mapping** published ports — each a [`PortMap`]
/// `{ host, container }` rendered as podman `-p host:container` /
/// `HostConfig.port_bindings[container/tcp] = host`. This is the form that
/// publishes a **different host port than the in-container port**: FalkorDB
/// listens on `6379` inside every zone's container, but `Test` publishes it
/// on host `6380` and `Prod` on `6381` (`PortMap::new(6380, 6379)`), so the
/// zones don't collide on the host yet each reaches the fixed in-container
/// port. Empty (the default) leaves the create body **byte-identical** to a
/// spec that never named it; it composes with [`ports`](BootSpec::ports)
/// (the `host == container` form) — both sets are published. Only the
/// [`container`] backend acts on it; ignored by KVM/Redfish.
pub port_maps: Vec<PortMap>,
/// Environment for the instance (container env; appliance kernel env).
pub env: BTreeMap<String, String>,
/// The BMC endpoint — **required** for [`Kind::BareMetal`], `None` otherwise.
pub bmc: Option<BmcEndpoint>,
/// Optional cloud-init NoCloud provisioning. Consumed by the [`kvm`] backend,
/// which authors it into a seed image the guest reads at first boot; ignored by
/// the container/Redfish backends. `None` → no seed is attached.
pub cloud_init: Option<CloudInit>,
/// Container **network mode** — how the OCI backend attaches the container to a
/// network. Only the [`container`] backend acts on it (KVM/Redfish ignore it).
/// [`NetMode::Default`] (the field's [`Default`]) leaves the create body
/// byte-identical to a spec that never named a net mode; [`NetMode::None`]
/// renders `--network none` (the airgap wire). Added additively — every existing
/// constructor defaults it to [`NetMode::Default`].
pub net: NetMode,
/// Container **CPU quota** (podman `--cpus`) — how many host cores the container
/// may use. `None` (the default) leaves it **unconstrained**, so the container
/// sees **all** host cores: the deliberate default for hot infra (FalkorDB's
/// OpenMP pool, a Spark executor) that must never be throttled to one core.
/// `Some(n)` caps it at `n` cores (rendered as `HostConfig.nano_cpus = n * 1e9`).
/// Only the [`container`] backend acts on it (KVM sizing is [`mem_mb`]/[`cores`]);
/// `None` keeps the create body byte-identical to a spec that never named it.
///
/// [`cores`]: BootSpec::cores
pub cpus: Option<f64>,
/// Container **memory limit** in MiB (podman `--memory`). `None` (the default)
/// leaves it **unconstrained** (the container may use the box's memory) — the hot-
/// infra default. `Some(m)` caps it at `m` MiB (`HostConfig.memory = m * 1MiB`).
/// Only the [`container`] backend acts on it; the KVM guest RAM is the separate
/// [`mem_mb`](BootSpec::mem_mb). `None` keeps the create body byte-identical.
pub mem_limit_mb: Option<u32>,
/// Container **security options** — podman/Docker `--security-opt`, rendered
/// verbatim onto `HostConfig.security_opt`. Only the [`container`] backend acts
/// on it (KVM/Redfish ignore it); **empty (the default) keeps the create body
/// byte-identical** to a spec that never named one, so nothing that exists today
/// moves.
///
/// It exists because a container's *default* syscall filter is not a property of
/// the workload and cannot be discovered from inside it. podman's stock
/// `/usr/share/containers/seccomp.json` allow-lists no `io_uring_*` syscall, so
/// `io_uring_setup` inside a stock container returns **ENOSYS** — measured on
/// oden 2026-08-11, kernel 7.0.0-29, with `/proc/sys/kernel/io_uring_disabled=0`
/// and the identical probe returning a valid fd on the host and under
/// `--security-opt seccomp=unconfined`. A caller benchmarking an io_uring code
/// path therefore has no way to run it at all without this field, and "the arm
/// refused to start" is indistinguishable from "the kernel lacks it".
///
/// Each entry is one `--security-opt` value, e.g. `seccomp=/path/profile.json`,
/// `seccomp=unconfined`, `label=disable`, `no-new-privileges`. draupnir does not
/// interpret them: the daemon owns their meaning, and a value this crate parsed
/// would be a second, staler copy of that grammar.
pub security_opt: Vec<String>,
/// Container **bind mounts** — podman/Docker `-v` strings, `host:container[:opts]`
/// verbatim, where `host` is a directory OR a named volume. Only the
/// [`container`] backend acts on it (KVM/Redfish ignore it); **empty (the
/// default) keeps the create body byte-identical** to a spec that never named one.
///
/// It exists because the mounts of a long-lived container used to have NO way
/// in. [`ContainerBoot::boot_with_binds`](container::ContainerBoot::boot_with_binds)
/// took them as a separate argument, so only the caller that knew to reach past
/// [`Boot::boot`] could pass any — and the generic `Boot` runner in jera, which
/// boots every long-lived container korp starts, could not. MEASURED on oden
/// 2026-09-10: korp's FalkorDB spec declared a named volume at `/data` (and a unit
/// test proved the DECLARATION), while `podman inspect` of the live container
/// showed `Mounts: []`, `podman volume ls` was empty, and the store recreated at
/// 19:09:44 answered `DBSIZE 0` — every graph it had ever held died with its
/// container. A mount is part of WHAT is booted, so it lives on the spec.
pub binds: Vec<String>,
/// **Container process USER** — podman `--user`, rendered verbatim onto the
/// create body's `User` (`uid`, `uid:gid` or a name the image knows). `None`
/// (the default) leaves the image's own `USER` in place and keeps the create
/// body **byte-identical** to a spec that never named one. Only the
/// [`container`] backend acts on it (KVM/Redfish have no such notion, and
/// [`validate`](BootSpec::validate) refuses it there rather than drop it).
///
/// It exists because *who the process is* decides **who owns the files it
/// writes to a bind mount**, and that is not a property of the image. Under
/// **rootless** podman, container UID 0 maps to the invoking host user, so
/// `Some("0:0")` is what makes a build's output land on the mount owned by
/// the account that then has to read, copy and publish it. An image that
/// runs as its own unprivileged user writes files owned by a subuid the host
/// account cannot even read — the build "succeeds" and its artifact is
/// unreachable. Njord's appliance build (`deployer-server`'s build engine)
/// had `--user=0:0` on its `podman run` argv for exactly that reason and
/// could not move onto this crate without it.
pub user: Option<String>,
/// **Container WORKING DIRECTORY** — podman `--workdir`, rendered verbatim
/// onto the create body's `WorkingDir`. `None` (the default) leaves the
/// image's own `WORKDIR` and keeps the create body **byte-identical**. Only
/// the [`container`] backend acts on it; refused elsewhere by
/// [`validate`](BootSpec::validate) for the same reason as [`user`].
///
/// It exists because a build command's **relative** paths are the interface:
/// `cargo build` finds its manifest by walking up from the cwd, and
/// `cargo-leptos` resolves its `site-root` relative to the project dir, so
/// the directory the process starts in decides *where the output lands*, not
/// merely where it looks. Rewriting the argv to absolute paths is not an
/// equivalent workaround: it changes the tool's own relative-path answers.
///
/// [`user`]: BootSpec::user
pub working_dir: Option<String>,
/// podman `--cap-add`: capabilities the container needs beyond the
/// runtime default (`NET_ADMIN` for a guest that raises its own WireGuard
/// interface — nordisk-pairing's fleet test, 2026-09-24). Empty = none,
/// and the create body stays byte-identical to one that never named any.
/// Only the [`container`] backend acts on it.
pub cap_add: Vec<String>,
/// podman `--device`: host device nodes handed into the container
/// (`/dev/net/tun` for the same guest). Empty = none. Container backend only.
pub devices: Vec<String>,
/// podman `--cap-drop` (`ALL` drops every capability). Empty = the runtime
/// default set, and the create body stays byte-identical. Container backend only.
pub cap_drop: Vec<String>,
/// podman `--read-only`: the container's root filesystem is read-only (it writes
/// only to its mounts and tmpfs). `false` = the runtime default. Container only.
pub read_only_rootfs: bool,
/// podman `--tmpfs`, as `(path, options)` (`("/tmp", "rw,size=1024m,mode=1777")`).
/// Empty = none. Container backend only.
pub tmpfs: Vec<(String, String)>,
/// The image must already be local: never pulled (`--pull=never`). A missing image
/// is refused by name. `false` = pulled when missing (the old behaviour).
pub never_pull: bool,
/// **KVM extra QEMU args** — appended verbatim after tunnr's own argv (via
/// `tunnr_vm::BootSpec::extra_qemu_args`). Only the [`kvm`] backend acts on it
/// (containers/Redfish ignore it); it is the seam a KVM consumer uses to attach
/// host-specific QEMU devices tunnr's spec does not model natively — e.g.
/// Skidbladnir's `org.skidbladnir.report` virtio-serial report port, its
/// `org.qemu.guest_agent.0` guest-agent port, a read-only data drive, an install
/// `-cdrom`, `-cpu host`, and a `-snapshot` guard that keeps a shared,
/// content-addressed base image from being mutated. Empty (the default) leaves
/// the tunnr argv **byte-identical** to a spec that never named it.
pub extra_qemu_args: Vec<String>,
/// **KVM: must the guest's writes to the boot disk SURVIVE the boot?**
///
/// `false` (the default) keeps the long-standing behaviour: an
/// [`ImageSource::BootloaderDisk`] is opened `snapshot=on`, so guest writes land
/// in a throwaway overlay and a shared, content-addressed base OS image can never
/// be mutated by a boot. That is the right default — it is what stops a matrix
/// run from poisoning the image every later run depends on.
///
/// `true` says the caller owns this disk (typically a per-run copy or an overlay
/// it made itself) and needs it to persist, because the *point* of the boot is
/// what the guest wrote. Without this there is no way to express **two boots of
/// one machine**: boot once to install, boot again to see what systemd does with
/// the installed system at startup. Under the COW default the second boot silently
/// gets a pristine image, re-runs first-boot provisioning, and produces
/// plausible-looking facts about a machine that was never installed. Measured on
/// oden 2026-08-03: after a full install the per-run disk copy was byte-identical
/// (md5) to the base, and boot B replayed the whole of boot A.
///
/// Only the [`kvm`] backend acts on it, and only for a boot whose disk would
/// otherwise be COW-protected; every other [`ImageSource`] already persists.
/// `false` leaves the tunnr spec **byte-identical** to a spec that never named it.
pub persist_disk: bool,
/// **The removable boot MEDIUM** — an installer/appliance ISO attached *in
/// addition to* the [`image`](BootSpec::image) payload, so a machine can be
/// booted OFF the medium while a separate, blank target disk is present to be
/// installed onto. KVM renders it `-cdrom <iso>`; Redfish inserts it as virtual
/// media. `None` (the default) leaves every launch byte-identical to a spec
/// written before this field existed.
///
/// The distinction from [`image`](BootSpec::image) is the whole point.
/// `ImageSource::Iso` is a spec whose *only* payload is the ISO — a live boot,
/// nothing to install onto. `medium` is the *install* shape: `image` is the
/// blank disk that will receive the system, `medium` is the ISO doing the
/// installing. Read them together through [`medium_path`](BootSpec::medium_path),
/// which is the single accessor both backends use.
///
/// It is meaningless on [`Kind::Container`] and [`Kind::Exe`] (no
/// firmware, no drive tray), so [`validate`](BootSpec::validate) rejects it
/// there rather than let it vanish silently.
pub medium: Option<String>,
/// **Which tray the [`medium`](BootSpec::medium) rides in** — see
/// [`MediumAttach`]. [`MediumAttach::Cdrom`] (the default) renders exactly
/// what this crate rendered before the field existed.
pub medium_attach: MediumAttach,
/// **Which device the firmware boots from** — see [`BootOrder`].
/// [`BootOrder::Auto`] (the default) emits nothing and changes no existing
/// launch. [`BootOrder::Disk`] together with a [`medium`](BootSpec::medium) is
/// refused by [`validate`](BootSpec::validate): that combination is the exact
/// shape of the lie honesty rule 1 exists to stop.
pub boot_order: BootOrder,
/// **KVM: the console line that means "this guest booted OK".**
///
/// Handed to tunnr's `BootSpec::success_marker`, so the boot primitive's own
/// status flips to booted-OK the moment the line streams past on the serial
/// console, and [`kvm::KvmBoot::booted_ok`] can read that back. `None` (the
/// default) leaves tunnr's stock marker in place — byte-identical to every spec
/// written before this field existed. An appliance that prints
/// `KORP-APPLIANCE BOOT-OK`, an installer that prints `INSTALLER: INSTALL-OK`
/// and a live medium that prints `APP-ALIVE` are three different claims, and a
/// consumer that could not name its own marker had to reach past draupnir to
/// tunnr to make any of them — which is the layer-skip this field closes.
/// Only the [`kvm`] backend acts on it.
pub success_marker: Option<String>,
/// **KVM: the wall-clock budget for the boot** (tunnr's SIGKILL backstop).
///
/// `None` (the default) keeps tunnr's default (60 s) — byte-identical to before.
/// A 5-minute install and a 2-minute appliance boot are not the same budget, and
/// a spec that cannot say so is killed at 60 s by a backstop it never saw. The
/// operator override `DRAUPNIR_VM_BOOT_TIMEOUT` (seconds) still wins over this
/// field, so a slow box can be tuned without a rebuild, exactly as before.
/// Only the [`kvm`] backend acts on it.
pub boot_timeout: Option<Duration>,
/// **KVM: the UEFI firmware the guest boots on** — see [`Firmware`].
///
/// [`Firmware::HostDefault`] (the default) names nothing and leaves tunnr's own
/// stock OVMF discovery in place, so the argv is byte-identical to a spec
/// written before this field existed. Any other choice resolves a CODE/VARS
/// pair on this host and hands it to tunnr's `ovmf_code` /
/// `ovmf_vars_template` seam; an absent firmware is refused by name at
/// [`to_tunnr_spec`](kvm::KvmBoot::to_tunnr_spec) time, before the guest is
/// launched.
///
/// It exists because those two tunnr fields have been unreachable from every
/// layer above tunnr: nothing in draupnir or jera could set them, so no caller
/// could choose a firmware at all — a Secure Boot boot was expressible only by
/// reaching past draupnir straight into tunnr, which is the layer-skip this
/// field closes (the same defect [`success_marker`](BootSpec::success_marker)
/// closed for the boot verdict).
pub firmware: Firmware,
/// **Whether this guest has a serial console** — see [`SerialConsole`].
/// [`SerialConsole::Stdio`] (the default) is what every KVM boot here has
/// always had. Only the [`kvm`] backend acts on it.
pub serial: SerialConsole,
/// **What this guest's screen is** — see [`Display`]. [`Display::Auto`] (the
/// default) leaves the backend's own choice alone; [`Display::Framebuffer`]
/// keeps `-vga std` and drops only the display, which is the only mode in
/// which an invisible panic is visible. Only the [`kvm`] backend acts on it.
pub display: Display,
/// **What this guest's clock reads at boot** — see [`Rtc`]. [`Rtc::Host`] (the
/// default) is QEMU's own default and renders nothing. Only the [`kvm`]
/// backend acts on it.
pub rtc: Rtc,
/// **The DNS server the user-mode network hands the guest** (`-netdev
/// user,…,dns=<addr>`). `None` (the default) renders nothing and leaves
/// slirp's own `.3`, so an existing spec's launch is unchanged.
///
/// `Some("10.0.2.99")` is how behaviour 18 is modelled: slirp serves DNS on
/// its own `.3` and nothing else, so the guest's queries leave and **no reply
/// ever arrives** — which is what `systemd-timesyncd` experiences on UpCloud,
/// and why gunnar-clock syncs from the front over a signed nonce instead.
/// Only the [`kvm`] backend acts on it.
pub guest_dns: Option<String>,
/// **Where the qemu backend binds its TCP forwards on the HOST.** `None`
/// (the default) renders `hostfwd=tcp::H-:G` exactly as before — every
/// host interface. `Some("127.0.0.1")` renders `hostfwd=tcp:127.0.0.1:H-:G`,
/// which is what a rig needs whose guest's ports must not be reachable
/// from the network it sits on (an appliance whose bootstrap window is open
/// to anyone reaching 50051). Only the qemu backend acts on it.
pub forward_bind: Option<String>,
/// **The chipset** ([`MachineType`]). `Q35` by default — the argv every
/// existing caller already gets; [`cloud_machine_shape`](BootSpec::cloud_machine_shape)
/// selects `Pc`. Only the qemu backend acts on it.
pub machine: MachineType,
/// **KVM: which MACHINE this guest is** — see [`GuestShape`].
///
/// [`GuestShape::UefiSerial`] (the default) is what every KVM boot draupnir
/// has ever driven, so an untouched spec's argv is byte-identical.
/// [`GuestShape::BiosFramebuffer`] is the console-only cloud machine, and it
/// is refused rather than approximated when the rest of the spec contradicts
/// it (a success marker, or a named UEFI firmware).
///
/// It exists because the shape was never a field: `kvm.rs` hard-coded
/// `headless = true` and tunnr pushed `-serial mon:stdio` plus the OVMF
/// pflash pair unconditionally, so no caller at any layer could ask for the
/// machine its appliance is actually sold on. Only the [`kvm`] (tunnr)
/// backend acts on it; draupnir's own `qemu` backend spells the same
/// machine through [`firmware`](BootSpec::firmware) /
/// [`serial`](BootSpec::serial) / [`display`](BootSpec::display) (see
/// [`cloud_machine_shape`](BootSpec::cloud_machine_shape)). On the tunnr
/// path a [`Firmware::Bios`] must travel WITH
/// [`GuestShape::BiosFramebuffer`] — the two are refused apart.
pub guest_shape: GuestShape,
}
impl BootSpec {
/// **The one writer of a default [`BootSpec`]** — every constructor below is
/// this plus the two or three fields that make it that kind of boot.
///
/// It exists because the five public constructors were five verbatim copies of
/// the same 20-line field list (REUSE-law: >10 duplicated lines means share), and
/// every new field had to be added five times or one constructor silently drifted.
/// The `medium`/`boot_order` pair added 2026-08-15 is added here **once**, which
/// is also what makes "a spec that names no medium behaves exactly as before" a
/// property of the type rather than of five sites agreeing.
///
/// KVM sizing (512 MiB / 2 cores) is applied only to [`Kind::Vm`]: the
/// container, Redfish and exe paths carry no VM sizing and stay `0`, exactly the
/// "lean by design" defaults their constructors always produced.
fn of(name: impl Into<String>, kind: Kind, image: ImageSource) -> Self {
let (mem_mb, cores) = if kind == Kind::Vm {
(512, 2)
} else {
(0, 0)
};
Self {
name: name.into(),
kind,
image,
mem_mb,
cores,
cmdline: String::new(),
cmd: Vec::new(),
ports: Vec::new(),
port_maps: Vec::new(),
env: BTreeMap::new(),
bmc: None,
cloud_init: None,
net: NetMode::Default,
cpus: None,
mem_limit_mb: None,
security_opt: Vec::new(),
binds: Vec::new(),
user: None,
working_dir: None,
cap_add: Vec::new(),
devices: Vec::new(),
cap_drop: Vec::new(),
read_only_rootfs: false,
tmpfs: Vec::new(),
never_pull: false,
extra_qemu_args: Vec::new(),
persist_disk: false,
medium: None,
medium_attach: MediumAttach::default(),
boot_order: BootOrder::Auto,
success_marker: None,
boot_timeout: None,
firmware: Firmware::HostDefault,
// The machine-shape fields default to the machine draupnir always
// booted: a serial console, the backend's own display choice, the
// host's clock and slirp's own DNS. Pinned byte-for-byte by
// `qemu::tests::the_default_spec_renders_the_argv_draupnir_always_built`.
serial: SerialConsole::Stdio,
display: Display::Auto,
rtc: Rtc::Host,
guest_dns: None,
forward_bind: None,
machine: MachineType::Q35,
guest_shape: GuestShape::UefiSerial,
}
}
/// A KVM appliance boot from a kernel + rootfs (defaults: 512 MiB, 2 cores).
pub fn kvm_kernel_rootfs(
name: impl Into<String>,
kernel: impl Into<String>,
rootfs: impl Into<String>,
) -> Self {
Self::of(
name,
Kind::Vm,
ImageSource::KernelRootfs {
kernel: kernel.into(),
rootfs: rootfs.into(),
},
)
}
/// A KVM boot from a bootable **disk image** direct-launched with `kernel`
/// (defaults: 512 MiB, 2 cores). tunnr attaches the disk as a virtio drive;
/// the kernel is required because there is no in-image bootloader path.
pub fn kvm_disk(
name: impl Into<String>,
kernel: impl Into<String>,
disk: impl Into<String>,
) -> Self {
Self::of(
name,
Kind::Vm,
ImageSource::Disk {
kernel: kernel.into(),
disk: disk.into(),
},
)
}
/// A KVM boot from a **self-booting disk image** — a bootloader-in-disk
/// (firmware) boot with **no `-kernel`** (defaults: 512 MiB, 2 cores). tunnr
/// attaches the disk as a virtio drive and lets OVMF boot it directly, exactly
/// as real hardware would; there is no direct-kernel launch.
pub fn kvm_bootloader_disk(name: impl Into<String>, disk: impl Into<String>) -> Self {
Self::of(
name,
Kind::Vm,
ImageSource::BootloaderDisk { disk: disk.into() },
)
}
/// A container boot from an OCI image reference (e.g. a redis service).
pub fn container(name: impl Into<String>, oci_image: impl Into<String>) -> Self {
Self::of(
name,
Kind::Container,
ImageSource::OciImage(oci_image.into()),
)
}
/// A bare-metal Redfish boot: an ISO served as virtual media to a BMC node.
///
/// The boot order stays [`BootOrder::Auto`] so this constructor is unchanged
/// from every call written before boot order existed — the Redfish backend has
/// always set a one-time [`BootTarget::Cd`] override itself. Say
/// [`BootOrder::Medium`] explicitly (or use [`iso_boot`](BootSpec::iso_boot)
/// + [`on_metal`](BootSpec::on_metal)) when the spec must *also* be routable to
/// KVM.
pub fn redfish_iso(name: impl Into<String>, iso: impl Into<String>, bmc: BmcEndpoint) -> Self {
Self {
bmc: Some(bmc),
..Self::of(name, Kind::BareMetal, ImageSource::Iso(iso.into()))
}
}
/// **Boot OFF a medium — the backend-agnostic ISO spec.** One spec, routable to
/// the KVM backend as it stands or to real metal with [`on_metal`](BootSpec::on_metal),
/// which is honesty rule 2 in a constructor: "it works in the demo" and "it works
/// on the customer's server" become the same claim about the same value.
///
/// Defaults to [`Kind::Vm`] with [`BootOrder::Medium`] (`-cdrom <iso> -boot d`),
/// 512 MiB / 2 cores. There is no target disk: this is a *live* boot of the ISO.
/// For the install shape (ISO **plus** a blank disk to install onto) use
/// [`kvm_install_from_medium`](BootSpec::kvm_install_from_medium).
pub fn iso_boot(name: impl Into<String>, iso: impl Into<String>) -> Self {
Self {
boot_order: BootOrder::Medium,
..Self::of(name, Kind::Vm, ImageSource::Iso(iso.into()))
}
}
/// **Route this spec to real metal** (builder style): flip the backend to
/// [`Kind::BareMetal`] and attach the BMC, changing nothing else.
///
/// The point is what it does *not* touch — image, medium and boot order carry
/// over verbatim, so `iso_boot(..)` and `iso_boot(..).on_metal(bmc)` are the same
/// boot claim aimed at two different machines. That is the `iso-kvm` / `iso-metal`
/// pair the chain driver keeps as distinct rows: distinct *runs*, one spec.
pub fn on_metal(mut self, bmc: BmcEndpoint) -> Self {
self.kind = Kind::BareMetal;
self.bmc = Some(bmc);
self
}
/// **The INSTALL leg**: boot off `iso` with `target_disk` present and blank, so
/// the installer has somewhere to install to. KVM renders it as a firmware boot
/// of a virtio `target_disk` plus `-cdrom <iso> -boot d`.
///
/// [`persist_disk`](BootSpec::persist_disk) is **`true`** here, unlike every other
/// disk constructor, and that is load-bearing: the default COW guard would send
/// the installer's writes to a throwaway overlay, so the install would "succeed"
/// and leave the disk byte-identical to the blank one it started from (measured on
/// oden 2026-08-03). An install whose writes are discarded is the purest form of
/// the green we must never accept.
pub fn kvm_install_from_medium(
name: impl Into<String>,
iso: impl Into<String>,
target_disk: impl Into<String>,
) -> Self {
Self {
medium: Some(iso.into()),
boot_order: BootOrder::Medium,
persist_disk: true,
..Self::of(
name,
Kind::Vm,
ImageSource::BootloaderDisk {
disk: target_disk.into(),
},
)
}
}
/// **The BOOT-INSTALLED leg**: firmware-boot `disk` with **no medium at all**
/// (`-boot c`) — the second, separate VM lifetime.
///
/// This is deliberately a different constructor rather than a flag on the install
/// spec, because the two must be two *values*: the driver's
/// `an_installer_ready_green_is_refused_inside_one_vm_lifetime`
/// (`edda/crates/nornir-testmatrix/src/chain.rs:1111`) refuses a READY green that
/// was observed inside the installing machine. [`validate`](BootSpec::validate)
/// enforces the same thing from below: a [`BootOrder::Disk`] spec that carries a
/// medium is refused, so "I detached the ISO" cannot be merely claimed.
///
/// `persist_disk` is `true`: the whole subject of this boot is what the previous
/// lifetime wrote to that disk.
pub fn kvm_boot_installed_disk(name: impl Into<String>, disk: impl Into<String>) -> Self {
Self {
boot_order: BootOrder::Disk,
persist_disk: true,
..Self::of(
name,
Kind::Vm,
ImageSource::BootloaderDisk { disk: disk.into() },
)
}
}
/// **Run a plain executable** — the `exe` build-thing kind ([`exe::ExeBoot`]).
/// `path` is the binary stage 1 produced; `args` is its argv[1..].
pub fn exe<I, S>(name: impl Into<String>, path: impl Into<String>, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self::of(
name,
Kind::Exe,
ImageSource::Executable {
path: path.into(),
args: args.into_iter().map(Into::into).collect(),
},
)
}
/// **The medium this spec boots off, wherever it was named** — the single
/// accessor both the KVM and the Redfish backend read, so the two can never
/// disagree about what "the medium" is (REUSE-law: one reader, not a twin per
/// backend).
///
/// Returns the explicit [`medium`](BootSpec::medium) when set, else the
/// [`image`](BootSpec::image) when it is an [`ImageSource::Iso`] (a live ISO boot
/// *is* a boot off the medium), else [`None`].
pub fn medium_path(&self) -> Option<&str> {
match (&self.medium, &self.image) {
(Some(m), _) => Some(m.as_str()),
(None, ImageSource::Iso(iso)) => Some(iso.as_str()),
_ => None,
}
}
/// Attach a removable boot [`medium`](BootSpec::medium) (builder style) — an ISO
/// alongside the payload disk. Does **not** set the boot order; pair it with
/// [`with_boot_order`](BootSpec::with_boot_order) or use
/// [`kvm_install_from_medium`](BootSpec::kvm_install_from_medium).
pub fn with_medium(mut self, medium: impl Into<String>) -> Self {
self.medium = Some(medium.into());
self
}
/// Set the [`medium_attach`](BootSpec::medium_attach) (builder style) — which
/// face(s) of the medium the guest gets. [`MediumAttach::Both`] is the one an
/// appliance install needs when its kernel has no ATA.
pub fn with_medium_attach(mut self, attach: MediumAttach) -> Self {
self.medium_attach = attach;
self
}
/// Set the [`boot_order`](BootSpec::boot_order) (builder style).
pub fn with_boot_order(mut self, order: BootOrder) -> Self {
self.boot_order = order;
self
}
/// The path of whatever this spec boots — the kernel's rootfs, the disk, or
/// the ISO — for the "fail by name before anything is launched" check.
pub fn image_path(&self) -> Option<String> {
match &self.image {
ImageSource::KernelRootfs { rootfs, .. } => Some(rootfs.clone()),
ImageSource::Disk { disk, .. } => Some(disk.clone()),
ImageSource::BootloaderDisk { disk } => Some(disk.clone()),
ImageSource::Iso(iso) => Some(iso.clone()),
_ => None,
}
}
/// Every host→guest **TCP** forward this spec publishes, as `(host, guest)`
/// pairs — the two published forms folded into one list, exactly as the KVM
/// and container backends already fold them. There is deliberately no UDP
/// twin: inbound UDP does not arrive on a cloud guest (behaviour 18), and a
/// rig that forwarded one would be a rig where NTP works.
pub fn tcp_forwards(&self) -> Vec<(u16, u16)> {
self.ports
.iter()
.map(|&p| (p, p))
.chain(self.port_maps.iter().map(|pm| (pm.host, pm.container)))
.collect()
}
/// **The machine a cloud hands out, in one word** (builder style).
///
/// SeaBIOS, no serial console, a framebuffer served over `vnc`, an RTC two
/// hours ahead, slirp's DNS pointed at an address that answers nothing, the
/// CD first while it is inserted, and writes that survive. The six clauses
/// travel together on purpose: a guest that got two of them would be a
/// fourth machine shape nobody measured, and the estate has already paid for
/// proving things on a machine it was not going to run on.
///
/// The boot order is only set when a medium is present — `order=dc` with an
/// empty tray is `c` with extra words, and [`validate`](BootSpec::validate)
/// refuses it.
pub fn cloud_machine_shape(mut self, vnc: Option<String>) -> Self {
self.firmware = Firmware::Bios {
rom: std::path::Path::new(crate::qemu::SEABIOS_ROM)
.is_file()
.then(|| crate::qemu::SEABIOS_ROM.to_string()),
};
self.serial = SerialConsole::None;
self.display = Display::Framebuffer { vnc };
self.rtc = Rtc::SkewedBy {
seconds: crate::qemu::CLOUD_RTC_SKEW_SECONDS,
};
self.guest_dns = Some(crate::qemu::CLOUD_DNS.to_string());
// i440fx with PIIX IDE, the cdrom on a real IDE channel (ledger L46,
// L49). A caller that wants q35 sets `machine` after this.
self.machine = MachineType::Pc;
if self.medium_path().is_some() {
self.boot_order = BootOrder::MediumThenDisk;
}
// An install that wrote into a throwaway overlay would prove nothing.
self.persist_disk = true;
self
}
/// Choose the guest's UEFI [`firmware`](BootSpec::firmware) (builder style) —
/// the call a firmware picker makes, e.g.
/// `spec.with_firmware(Firmware::SecureBoot)`. The CODE/VARS pair travels as
/// one choice; see [`Firmware`] for why it cannot be two paths.
pub fn with_firmware(mut self, firmware: Firmware) -> Self {
self.firmware = firmware;
self
}
/// Name the **guest machine shape** (builder style) — see [`GuestShape`].
/// Only the [`kvm`] backend acts on it; [`GuestShape::UefiSerial`] (unset) is
/// the machine every earlier boot used.
pub fn with_guest_shape(mut self, shape: GuestShape) -> Self {
self.guest_shape = shape;
self
}
/// Name the **KVM success marker** (builder style) — see
/// [`success_marker`](BootSpec::success_marker). Only the [`kvm`] backend acts on
/// it; `None`/unset leaves tunnr's stock marker.
pub fn with_success_marker(mut self, marker: impl Into<String>) -> Self {
self.success_marker = Some(marker.into());
self
}
/// Set the **KVM boot budget** (builder style) — see
/// [`boot_timeout`](BootSpec::boot_timeout). Only the [`kvm`] backend acts on it;
/// unset keeps tunnr's default.
pub fn with_boot_timeout(mut self, budget: Duration) -> Self {
self.boot_timeout = Some(budget);
self
}
/// Set an environment variable (builder style).
pub fn with_env(mut self, key: impl Into<String>, val: impl Into<String>) -> Self {
self.env.insert(key.into(), val.into());
self
}
/// Set the container command / entrypoint override (builder style). Only the
/// [`container`] backend acts on it.
pub fn with_cmd<I, S>(mut self, cmd: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.cmd = cmd.into_iter().map(Into::into).collect();
self
}
/// Publish a container port (builder style), bound to the same host port. Only
/// the [`container`] backend acts on it.
pub fn with_port(mut self, port: u16) -> Self {
self.ports.push(port);
self
}
/// Publish a **distinct** `host:container` mapping (builder style) — the host
/// port may differ from the in-container port (a per-zone offset onto a fixed
/// service port; see [`port_maps`](BootSpec::port_maps) and [`PortMap`]). Only
/// the [`container`] backend acts on it.
pub fn with_port_map(mut self, host: u16, container: u16) -> Self {
self.port_maps.push(PortMap::new(host, container));
self
}
/// Attach cloud-init NoCloud provisioning (builder style). Only the KVM backend
/// acts on it — it authors a seed image the guest reads at first boot.
pub fn with_cloud_init(mut self, ci: CloudInit) -> Self {
self.cloud_init = Some(ci);
self
}
/// Set the container **network mode** (builder style). Only the [`container`]
/// backend acts on it; [`NetMode::None`] is the airgap `--network none` case.
/// [`NetMode::Default`] (unchanged) leaves the create body byte-identical.
pub fn with_net(mut self, net: NetMode) -> Self {
self.net = net;
self
}
/// Set the **KVM extra QEMU args** (builder style) — see
/// [`extra_qemu_args`](BootSpec::extra_qemu_args). Only the [`kvm`] backend acts
/// on it (appended verbatim to tunnr's argv); empty leaves the argv byte-identical.
pub fn with_extra_qemu_args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.extra_qemu_args = args.into_iter().map(Into::into).collect();
self
}
/// Set the container **CPU quota** (podman `--cpus`, builder style) — see
/// [`cpus`](BootSpec::cpus). `None` (unset) leaves the container **unconstrained**
/// (all host cores); `Some(n)` caps it at `n` cores. Only the [`container`]
/// backend acts on it.
pub fn with_cpus(mut self, cpus: f64) -> Self {
self.cpus = Some(cpus);
self
}
/// Set the container **memory limit** in MiB (podman `--memory`, builder style)
/// — see [`mem_limit_mb`](BootSpec::mem_limit_mb). `Some(m)` caps it at `m` MiB;
/// unset leaves it unconstrained. Only the [`container`] backend acts on it.
pub fn with_mem_limit_mb(mut self, mem_mb: u32) -> Self {
self.mem_limit_mb = Some(mem_mb);
self
}
/// Add one container **security option** (podman `--security-opt`, builder
/// style) — see [`security_opt`](BootSpec::security_opt). Appends rather than
/// replaces, because `--security-opt` is a repeatable flag and a setter that
/// silently dropped the previous value would be the quiet kind of wrong.
pub fn with_security_opt(mut self, opt: impl Into<String>) -> Self {
self.security_opt.push(opt.into());
self
}
/// Add one container bind mount, `host:container[:opts]` (`-v` semantics; `host`
/// may be a named volume). See [`BootSpec::binds`].
pub fn with_bind(mut self, bind: impl Into<String>) -> Self {
self.binds.push(bind.into());
self
}
/// Run the container's process as `user` (podman `--user`: `uid`, `uid:gid`
/// or a name) — see [`BootSpec::user`] for why a bind-mounting caller has to
/// be able to say this. Replaces rather than appends: `--user` is a single
/// value, unlike `--security-opt`.
pub fn with_user(mut self, user: impl Into<String>) -> Self {
self.user = Some(user.into());
self
}
/// Drop `cap` (podman `--cap-drop`; `ALL` drops every capability). Appends.
pub fn with_cap_drop(mut self, cap: impl Into<String>) -> Self {
self.cap_drop.push(cap.into());
self
}
/// A read-only root filesystem (podman `--read-only`).
pub fn with_read_only_rootfs(mut self) -> Self {
self.read_only_rootfs = true;
self
}
/// A tmpfs at `path` with podman `--tmpfs` options (`rw,size=1024m,mode=1777`).
pub fn with_tmpfs(mut self, path: impl Into<String>, options: impl Into<String>) -> Self {
self.tmpfs.push((path.into(), options.into()));
self
}
/// The image must already be local; it is never pulled (podman `--pull=never`).
pub fn never_pulled(mut self) -> Self {
self.never_pull = true;
self
}
/// Start the container's process in `dir` (podman `--workdir`) — see
/// [`BootSpec::working_dir`]. Replaces; there is only one cwd.
pub fn with_working_dir(mut self, dir: impl Into<String>) -> Self {
self.working_dir = Some(dir.into());
self
}
/// Reject an internally inconsistent spec **before** touching a backend:
/// the image must suit the backend, a Redfish spec must carry a BMC (and no
/// other backend may), every required payload path/ref is non-empty, the
/// instance `name` is non-empty, a Redfish `bmc` carries non-empty
/// host/username/system-id, a KVM spec is sized (`mem_mb`/`cores` > 0), the
/// container-only `cmd`/`ports` are not set on a non-container backend, and
/// every published container `port` is non-zero and listed at most once.
/// This is pure and unit-tested.
pub fn validate(&self) -> Result<()> {
if let Some((p, _)) = self.tmpfs.iter().find(|(p, _)| !p.starts_with('/')) {
return Err(Error::Spec(format!("a tmpfs mount needs an absolute path, not {p:?}")));
}
// Reject an empty/all-whitespace required string up front so a mistyped
// spec fails here with a clear message, not verbatim-passed to a backend
// that only fails deep in a runtime call (tunnr's direct-kernel launch
// needs a real `-kernel`; the OCI daemon a real image ref; Redfish a real
// ISO and a reachable BMC). `require` trims, so whitespace is caught too.
let require = |what: &str, val: &str| -> Result<()> {
if val.trim().is_empty() {
Err(Error::Spec(format!(
"a {:?} boot needs a non-empty {what}",
self.kind
)))
} else {
Ok(())
}
};
// The instance name is the `Machine` id prefix and the fleet-member prefix
// (`plan_fleet` mints `"{name}-{i}"`); a blank one yields `"-1"`/`"-2"`
// members and a leading-dash id, so reject it on every backend.
require("instance name", &self.name)?;
if !self.image.suits(self.kind) {
return Err(Error::Spec(format!(
"{:?} image is not bootable by the {:?} backend",
self.image, self.kind
)));
}
match &self.image {
ImageSource::KernelRootfs { kernel, rootfs } => {
require("kernel path", kernel)?;
require("rootfs path", rootfs)?;
}
ImageSource::Disk { kernel, disk } => {
require("kernel path", kernel)?;
require("disk path", disk)?;
}
// A bootloader-in-disk boot is kernel-less by design (firmware boots
// the disk), so require only the disk path — NOT a kernel.
ImageSource::BootloaderDisk { disk } => require("disk path", disk)?,
ImageSource::OciImage(image) => require("OCI image reference", image)?,
ImageSource::Iso(iso) => require("ISO path", iso)?,
ImageSource::Executable { path, .. } => require("executable path", path)?,
}
// ── the boot-off-a-medium seam ──────────────────────────────────────────
// A medium is a firmware/drive-tray concept: only a machine (KVM guest or
// Redfish node) has one. On a container or an exe it would vanish without a
// trace, exactly like the container-only knobs rejected below.
if self.medium.is_some()
&& !matches!(self.kind, Kind::Vm | Kind::BareMetal)
{
return Err(Error::Spec(format!(
"a {:?} boot has no drive tray (medium is Kvm/Redfish-only)",
self.kind
)));
}
if let Some(medium) = &self.medium {
require("medium (ISO) path", medium)?;
}
// UEFI pflash is a QEMU-guest concept: a container has no firmware at all,
// and a Redfish node's firmware is the physical board's, not ours to pick.
// Naming one anywhere else would be silently dropped — the same disappearing
// knob `medium` is rejected for above.
if self.firmware != Firmware::HostDefault && self.kind != Kind::Vm {
return Err(Error::Spec(format!(
"a {:?} boot has no UEFI pflash (firmware is Kvm-only; got `{}`)",
self.kind,
self.firmware.label()
)));
}
// A guest SHAPE is a QEMU-machine concept too: a container has no
// firmware, no serial port and no framebuffer, and a Redfish node's
// machine is the physical board. Naming a shape anywhere else would be
// silently dropped — the same disappearing knob `firmware` is rejected
// for above.
if self.guest_shape != GuestShape::UefiSerial && self.kind != Kind::Vm {
return Err(Error::Spec(format!(
"REFUSED [shape-not-supported] a {:?} boot has no machine shape to choose \
(guest_shape is Vm-only; got `{:?}`)",
self.kind, self.guest_shape
)));
}
// A boot order is likewise a machine concept.
if self.boot_order != BootOrder::Auto
&& !matches!(self.kind, Kind::Vm | Kind::BareMetal)
{
return Err(Error::Spec(format!(
"a {:?} boot has no firmware boot order (boot_order is Kvm/Redfish-only)",
self.kind
)));
}
// "Boot off the medium" with no medium anywhere is a spec that cannot do what
// it says. Named by field so the failure is actionable, not a generic reject.
// `order=dc` with nothing in the tray is `c` with extra words: the CD leg
// cannot happen, so a spec that says "the CD is first" would be claiming a
// machine shape it has not got. Same refusal, same reason, as `Medium`.
if self.boot_order == BootOrder::MediumThenDisk && self.medium_path().is_none() {
return Err(Error::Spec(
"boot_order = MediumThenDisk (-boot order=dc) but no medium: the CD-first leg cannot happen with an empty tray — set `medium`, or say BootOrder::Disk if the tray is meant to be empty"
.into(),
));
}
if self.boot_order == BootOrder::DiskThenMedium && self.medium_path().is_none() {
return Err(Error::Spec(
"boot_order = DiskThenMedium (-boot order=cd) but no medium: the fall-through to the CD \
cannot happen with an empty tray — set `medium`, or say BootOrder::Disk"
.into(),
));
}
if self.boot_order == BootOrder::Medium && self.medium_path().is_none() {
return Err(Error::Spec(
"boot_order = Medium but no medium: set `medium` (an ISO alongside the \
payload disk) or make the image an ImageSource::Iso"
.into(),
));
}
// HONESTY RULE 1, enforced by construction. "Boot the installed disk" and
// "the installer medium is still in the tray" cannot both be true: with the
// ISO present, a machine that fails to boot its freshly-installed disk falls
// through to the installer and comes up looking exactly like a success. The
// driver refuses a READY green observed inside one VM lifetime
// (edda/crates/nornir-testmatrix/src/chain.rs:1111); this refuses the SPEC
// that would let it be claimed in the first place — a caller must build a
// second, medium-less spec, which is a second boot.
if self.boot_order == BootOrder::Disk && self.medium_path().is_some() {
return Err(Error::Spec(format!(
"boot_order = Disk but the medium `{}` is still attached: booting the \
INSTALLED system means the medium is ABSENT — build a separate spec \
(BootSpec::kvm_boot_installed_disk) for the second VM lifetime",
self.medium_path().unwrap_or_default()
)));
}
// A KVM VM is booted with the spec's RAM/vCPU verbatim (the tunnr adapter
// sets `mem_mb`/`cores` from these); 0 would launch a 0-RAM/0-CPU guest
// that dies at boot, so a KVM spec must be sized. Container/Redfish carry
// no VM sizing (both default to 0 by design) and are exempt.
if self.kind == Kind::Vm {
if self.mem_mb == 0 {
return Err(Error::Spec("a Kvm boot needs mem_mb > 0 (VM RAM)".into()));
}
if self.cores == 0 {
return Err(Error::Spec("a Kvm boot needs cores > 0 (vCPUs)".into()));
}
}
// `cmd` (entrypoint override) and `ports` (published ports) are
// container-only knobs: only the container backend runs the cmd and
// publishes the ports — KVM/Redfish silently ignore both. Setting either
// on a non-container backend is a misconfiguration that would vanish
// without a trace (the caller asked to run a command / expose a port that
// never happens), so reject it up front — parity with a `bmc` on a
// non-Redfish backend (rejected below) and the same shape as the empty /
// zero / duplicate required-field checks.
if self.kind != Kind::Container {
if !self.cmd.is_empty() {
return Err(Error::Spec(format!(
"a {:?} boot takes no container cmd (cmd is container-only)",
self.kind
)));
}
// Ports are container vocabulary EXCEPT on KVM, for the same reason
// `NetMode::None` is (below): tunnr's user-mode NIC has modelled
// `(host, guest)` TCP forwards since it was written
// (`NetConfig::User { hostfwd }`, rendered `hostfwd=tcp::<h>-:<g>`),
// and [`PortMap`] is already exactly that pair. Until 2026-09-05 this
// rejection was the reason a caller who needed to REACH a service in
// the guest could not go through draupnir at all: gunnar's KVM install
// arm codes straight against `tunnr_vm::boot_test` and says so in its
// own header, which is a second KVM path in the constellation — the
// thing this crate exists to prevent. A forward that is dropped in
// silence is worse than one that is refused, and it was neither: it
// was refused on the container's behalf on a backend that can serve it.
//
// Redfish provisions metal and Exe runs a binary; neither publishes a
// port, so both still reject.
let ports_ok = (self.ports.is_empty() && self.port_maps.is_empty())
|| self.kind == Kind::Vm;
if !ports_ok {
return Err(Error::Spec(format!(
"a {:?} boot publishes no ports (ports are container-only, save a \
KVM boot — tunnr's user-mode NIC forwards host→guest)",
self.kind
)));
}
// A forward needs a NIC to arrive on. `NetMode::None` is `-nic none`
// on the KVM path, so the two together are a contradiction the caller
// has to resolve — and resolving it here (silently dropping one) is
// how a test that believes it can reach the guest hangs on a closed
// port instead of failing with a reason.
if self.kind == Kind::Vm
&& self.net == NetMode::None
&& (!self.ports.is_empty() || !self.port_maps.is_empty())
{
return Err(Error::Spec(
"a KVM boot cannot both cut the guest off (NetMode::None → `-nic none`) \
and forward a port to it — drop the port maps or the airgap"
.into(),
));
}
// `net` is container vocabulary EXCEPT the airgap: the kvm backend
// honours `NetMode::None` as tunnr's `-nic none` (no egress NIC), the
// exact KVM twin of the container's `--network none`. Every OTHER
// non-default mode (Host/Bridge) is still container-only — on KVM it
// would vanish without a trace, so those stay rejected. Redfish
// provisions metal and honours none of them.
let net_ok = self.net.is_default()
|| (self.kind == Kind::Vm && self.net == NetMode::None);
if !net_ok {
return Err(Error::Spec(format!(
"a {:?} boot takes no container network mode (net is container-only, \
save `NetMode::None` on Kvm — the airgap)",
self.kind
)));
}
// `cpus` (podman `--cpus`) and `mem_limit_mb` (podman `--memory`) are
// container resource knobs the OCI backend threads onto `HostConfig`;
// KVM sizing is the separate `mem_mb`/`cores`, and Redfish provisions the
// metal itself — so a container CPU/memory cap on a non-container backend
// is a misconfiguration that would vanish without a trace. Reject it up
// front (parity with cmd/ports/net above).
if self.cpus.is_some() {
return Err(Error::Spec(format!(
"a {:?} boot takes no container cpus quota (cpus is container-only)",
self.kind
)));
}
if self.mem_limit_mb.is_some() {
return Err(Error::Spec(format!(
"a {:?} boot takes no container memory limit (mem_limit_mb is container-only)",
self.kind
)));
}
// `user` and `working_dir` are the container process's identity and
// cwd — knobs only the OCI create body carries. A KVM guest runs its
// own init as its own root and a Redfish node is a board; setting
// either there would vanish without a trace, which is the same
// disappearing-knob failure cmd/ports/net/caps are refused for above.
if self.user.is_some() {
return Err(Error::Spec(format!(
"a {:?} boot takes no container user (user is container-only)",
self.kind
)));
}
if self.working_dir.is_some() {
return Err(Error::Spec(format!(
"a {:?} boot takes no container working directory (working_dir is container-only)",
self.kind
)));
}
}
// A named-but-blank `user`/`working_dir` is not a choice, it is a typo:
// the daemon would take `""` as "unset" and the caller would believe the
// process runs as somebody, or starts somewhere, that it does not.
if let Some(u) = &self.user {
require("container user (uid, uid:gid or a name)", u)?;
}
if let Some(w) = &self.working_dir {
require("container working directory", w)?;
}
// A container CPU/memory cap, when set, must be positive: `--cpus 0` /
// `--memory 0` are not a real cap (they would either error at the daemon or
// mean "unlimited", which is what `None` already expresses). Reject a
// non-positive value here rather than pass a nonsense limit to the engine.
if let Some(c) = self.cpus {
if !c.is_finite() || c <= 0.0 {
return Err(Error::Spec(
"a container cpus quota must be a finite value > 0 (use None for all host cores)".into(),
));
}
}
if self.mem_limit_mb == Some(0) {
return Err(Error::Spec(
"a container memory limit must be > 0 MiB (use None for unconstrained)".into(),
));
}
// Published container ports are exposed and host-bound verbatim by the
// container backend (`ports`: `{p}/tcp` → host_port `{p}`; `port_maps`:
// `{container}/tcp` → host_port `{host}`). Port `0` is never a real
// published port (it would expose `0/tcp` / bind host port 0, which is
// not what any caller means), and the same **host** port bound twice —
// whether from `ports`, `port_maps`, or a mix — is a self-colliding host
// binding, so reject both up front (parity with the other required-field
// checks). By the guard above, a non-empty ports set here is necessarily
// a container spec. Two different host ports mapping to the same
// *container* port is fine (that is exactly the multi-zone case).
if !self.ports.is_empty() || !self.port_maps.is_empty() {
let mut seen_host = std::collections::BTreeSet::new();
// `ports` (the host==container single-port form).
for &p in &self.ports {
if p == 0 {
return Err(Error::Spec("a published container port must be > 0".into()));
}
if !seen_host.insert(p) {
return Err(Error::Spec(format!(
"published container port {p} is listed twice"
)));
}
}
// `port_maps` (the distinct host:container form): both ends must be a
// real port, and the host port must not collide with any already
// bound (from `ports` or an earlier map).
for pm in &self.port_maps {
if pm.host == 0 || pm.container == 0 {
return Err(Error::Spec(
"a published container port map needs host > 0 and container > 0".into(),
));
}
if !seen_host.insert(pm.host) {
return Err(Error::Spec(format!(
"published container host port {} is bound twice",
pm.host
)));
}
}
}
match (self.kind, &self.bmc) {
(Kind::BareMetal, None) => {
Err(Error::Spec("Redfish boot needs a BMC endpoint".into()))
}
(Kind::BareMetal, Some(bmc)) => {
// The BMC fields are woven into the Redfish REST URLs the backend
// drives; a blank host/username/system-id yields a malformed
// request that only fails on the wire, so require them here too
// (parity with the non-empty ISO path check above).
require("BMC host", &bmc.host)?;
require("BMC username", &bmc.username)?;
require("BMC system id", &bmc.system_id)?;
Ok(())
}
(_, Some(_)) => Err(Error::Spec(
"only the Redfish backend takes a BMC endpoint".into(),
)),
(_, None) => Ok(()),
}
}
}
// ---------------------------------------------------------------------------
// What a wait actually SAW — the shared stage-2 observation type.
// ---------------------------------------------------------------------------
/// **What was actually observed** while waiting on a running instance — the return
/// of [`exe::ExeBoot::await_marker`] and of [`kvm::KvmBoot::await_serial_marker`].
///
/// Note what is missing: there is no `Ok`/`Fail`, no "ready". Every one of these
/// outcomes can be the right answer depending on what the caller asked, and a type
/// that guessed would be inventing the verdict the caller exists to make. It carries
/// the evidence instead.
///
/// It lives at the crate root, not inside a backend, because it is the **one**
/// stage-2 observation shape: a process's captured stdout/stderr and a VM's serial
/// console are the same question ("did the named line appear before the deadline?")
/// asked of two different streams, and a per-backend copy would be two definitions
/// of "seen" that drift.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Seen {
/// The named marker appeared in the captured output. Carries the **whole line**
/// it appeared on — the applied output, not the fact that a boolean flipped.
Marker {
/// The full output line containing the marker.
line: String,
/// How long after the wait started it appeared.
after: Duration,
},
/// The deadline elapsed with the process **still running** and the marker not
/// seen. This is not automatically a failure: for a server, still-running is the
/// healthy state, and the caller may well have been waiting to confirm exactly
/// that. Carries the tail of what was captured so the caller can say *why*.
StillRunning {
/// The last lines captured, so a report can quote real output.
tail: String,
/// The budget that elapsed.
waited: Duration,
},
/// The process **terminated** before the marker appeared. For a server this is
/// the real failure, and the exit status plus tail is the evidence.
Exited {
/// Exit code, or `None` if it was killed by a signal.
code: Option<i32>,
/// The last lines captured.
tail: String,
/// How long it ran.
after: Duration,
},
}
impl Seen {
/// Whether the named marker was observed — the one question with a single
/// honest boolean answer.
pub fn saw_marker(&self) -> bool {
matches!(self, Seen::Marker { .. })
}
/// A one-line, quotable summary carrying the applied output (the matched line,
/// or the tail that was captured instead) — what a matrix cell's `detail` should
/// say so a red names what happened rather than that something did not.
pub fn detail(&self) -> String {
match self {
Seen::Marker { line, after } => {
format!("marker seen after {after:?}: {}", line.trim())
}
Seen::StillRunning { tail, waited } => format!(
"still running after {waited:?}, marker NOT seen; last output: {}",
one_line(tail)
),
Seen::Exited { code, tail, after } => format!(
"process EXITED (code {code:?}) after {after:?} without the marker; \
last output: {}",
one_line(tail)
),
}
}
}
/// Squash a captured tail onto one line so it can live in a matrix cell.
fn one_line(s: &str) -> String {
let joined = s
.lines()
.rev()
.take(3)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join(" ⏎ ");
if joined.trim().is_empty() {
"<no output>".into()
} else {
joined
}
}
/// A booted (or booting) instance handle — what a [`Boot::boot`] returns and
/// what [`Lifecycle`] acts on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Machine {
/// Backend-scoped instance id (VM handle / container id / Redfish system id).
pub id: String,
/// The [`BootSpec::name`] this was fired up from.
pub spec_name: String,
/// What it is.
pub kind: Kind,
/// Last-observed power state.
pub power: PowerState,
}
impl Machine {
/// Record a freshly fired-up instance (power state assumed `On`).
pub fn started(id: impl Into<String>, spec: &BootSpec) -> Self {
Self {
id: id.into(),
spec_name: spec.name.clone(),
kind: spec.kind,
power: PowerState::On,
}
}
}
/// **Fire up** an instance from a [`BootSpec`]. One trait, three implementations
/// ([`kvm::KvmBoot`], [`container::ContainerBoot`], [`redfish::RedfishBoot`]).
pub trait Boot {
/// Boot the instance described by `spec`, returning its live [`Machine`].
fn boot(&self, spec: &BootSpec) -> Result<Machine>;
}
/// Drive an instance's **power lifecycle** after it is fired up.
pub trait Lifecycle {
/// Power the instance on.
fn power_on(&self, machine: &Machine) -> Result<()>;
/// Power the instance off.
fn power_off(&self, machine: &Machine) -> Result<()>;
/// Observe the instance's current power state.
fn status(&self, machine: &Machine) -> Result<PowerState>;
}
/// **Redfish virtual-media + boot-override** control — the out-of-band steps that
/// make a bare-metal node boot our ISO. Only the [`redfish::RedfishBoot`] backend
/// implements it; the KVM/container backends have no BMC.
pub trait VirtualMedia {
/// Attach `iso` to the node as Redfish virtual media (CD/DVD).
fn insert_media(&self, node: &BmcEndpoint, iso: &str) -> Result<()>;
/// Detach any virtual media from the node.
fn eject_media(&self, node: &BmcEndpoint) -> Result<()>;
/// Set the node's **one-time** boot override to `target`.
fn set_boot_override(&self, node: &BmcEndpoint, target: BootTarget) -> Result<()>;
}
/// **The unifying entry point** — fire up one instance from a [`BootSpec`] across
/// *whichever* backend is handed in. It [`validate`](BootSpec::validate)s the spec
/// first (so a mismatched image/BMC is rejected before any backend is touched),
/// then delegates to the backend's [`Boot::boot`]. The same `spec` boots the same
/// image on a [`kvm::KvmBoot`], a [`container::ContainerBoot`], or a
/// [`redfish::RedfishBoot`] — one call, three backends.
pub fn boot(spec: &BootSpec, backend: &dyn Boot) -> Result<Machine> {
spec.validate()?;
let outcome = backend.boot(spec);
// Record the boot verdict as a functional-status row: GREEN when the instance
// came up, RED when the backend failed to fire it — the boot surface nornir's
// matrix reads back (a failed boot must be visible, never swallowed).
functional_status(
"draupnir/boot",
"boot",
outcome.is_ok(),
&match &outcome {
Ok(m) => format!("instance `{}` booted", m.id),
Err(e) => format!("instance `{}` failed to boot: {e}", spec.name),
},
);
outcome
}
/// **The ring drips eight copies** — fan one [`BootSpec`] out into `n` identical
/// specs, each with a distinct `"{name}-{i}"` name (1-based), for booting a
/// fleet of identical machines from one image/ISO.
///
/// Pure bookkeeping: it plans the fleet; the caller boots each member through the
/// backend. Unit-tested.
pub fn plan_fleet(spec: &BootSpec, n: usize) -> Vec<BootSpec> {
(1..=n)
.map(|i| {
let mut member = spec.clone();
member.name = format!("{}-{i}", spec.name);
member
})
.collect()
}
/// **Drip a fleet from one image** — [`plan_fleet`] the spec into `n` members and
/// [`boot`] each through `backend`, returning a per-member result (a partial fleet
/// is observable: some members may boot while a later one errors).
pub fn boot_fleet(spec: &BootSpec, n: usize, backend: &dyn Boot) -> Vec<Result<Machine>> {
plan_fleet(spec, n)
.iter()
.map(|member| boot(member, backend))
.collect()
}
// ---------------------------------------------------------------------------
// Power-state readback — confirm a booted instance actually reached a state.
// ---------------------------------------------------------------------------
/// Knobs for [`await_power_state`] / [`boot_and_await`]: how long to wait for the
/// instance to reach the target power state and how often to poll it. [`Default`]
/// waits **indefinitely** (parity with [`container::RunOptions`]) and polls every
/// 200 ms — a bounded budget ([`WaitOptions::bounded`]) is recommended for a boot
/// readback so a node that never comes up is a timeout, not a hang.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WaitOptions {
/// Overall budget before giving up. `None` = wait forever for the state.
/// `Some(d)` returns an [`Error::Backend`] if the state is not reached within `d`.
pub timeout: Option<std::time::Duration>,
/// How often [`Lifecycle::status`] is polled while waiting.
pub poll_interval: std::time::Duration,
}
impl Default for WaitOptions {
fn default() -> Self {
Self {
timeout: None,
poll_interval: std::time::Duration::from_millis(200),
}
}
}
impl WaitOptions {
/// Wait indefinitely, polling on `poll_interval`.
pub fn poll_every(poll_interval: std::time::Duration) -> Self {
Self {
timeout: None,
poll_interval,
}
}
/// Cap the wait at `timeout`, polling on `poll_interval`.
pub fn bounded(timeout: std::time::Duration, poll_interval: std::time::Duration) -> Self {
Self {
timeout: Some(timeout),
poll_interval,
}
}
}
/// **Confirm an instance reached a power state** — poll a [`Lifecycle`]'s
/// [`status`](Lifecycle::status) until it reports `want`, returning `Ok(())` the
/// moment it does. This is the cross-backend **boot-status readback** seam: a
/// [`Boot::boot`] fires an instance up but returns before it has actually powered
/// on (a KVM guest is still booting, a Redfish node is still POSTing, a container is
/// still being scheduled), so a consumer (jera / Skidbladnir) that needs to *know*
/// the instance is up polls this — the power-lifecycle analogue of the container
/// [`run_to_completion`](container::run_to_completion) drive loop, but written
/// against the plain [`Lifecycle`] trait so it drives **any** backend (KVM,
/// container, Redfish) and a mock in a unit test with no live instance.
///
/// A live backend's own `status` error propagates as `Err` (the readback failed).
/// A `want` of [`PowerState::Unknown`] is a nonsensical target (`Unknown` means "not
/// observed") and is rejected as an [`Error::Spec`] before polling. With a bounded
/// [`WaitOptions`] a state never reached is an [`Error::Backend`] timeout carrying
/// the last-observed state; with the default (unbounded) options it waits forever.
pub fn await_power_state<L: Lifecycle>(
lifecycle: &L,
machine: &Machine,
want: PowerState,
opts: &WaitOptions,
) -> Result<()> {
if want == PowerState::Unknown {
return Err(Error::Spec(
"cannot await PowerState::Unknown (it means \"not observed\")".into(),
));
}
let deadline = opts.timeout.map(|t| std::time::Instant::now() + t);
loop {
let observed = lifecycle.status(machine)?;
if observed == want {
functional_status(
"draupnir/lifecycle",
"await_power_state",
true,
&format!("instance {} reached {want:?}", machine.id),
);
return Ok(());
}
if let Some(dl) = deadline {
if std::time::Instant::now() >= dl {
functional_status(
"draupnir/lifecycle",
"await_power_state",
false,
&format!("instance {} never reached {want:?}", machine.id),
);
return Err(Error::Backend(format!(
"instance `{}` did not reach {want:?} within {:?} (last observed {observed:?})",
machine.id,
opts.timeout.unwrap()
)));
}
}
std::thread::sleep(opts.poll_interval);
}
}
/// **Boot an instance and confirm it is up** — the one-call provision seam jera /
/// Skidbladnir want: [`validate`](BootSpec::validate) + [`boot`] the `spec` on
/// `backend`, then [`await_power_state`] it to [`PowerState::On`], returning the
/// live [`Machine`] only once it has actually powered on. A validation or boot
/// failure short-circuits before any wait (the backend is never touched on an
/// invalid spec — [`boot`] enforces that); a boot that never comes up within a
/// bounded [`WaitOptions`] is a timeout [`Error::Backend`]. Written against
/// `Boot + Lifecycle` so it drives any backend and a mock alike.
pub fn boot_and_await<B>(backend: &B, spec: &BootSpec, opts: &WaitOptions) -> Result<Machine>
where
B: Boot + Lifecycle,
{
let machine = boot(spec, backend)?;
await_power_state(backend, &machine, PowerState::On, opts)?;
Ok(machine)
}
/// **Roll a booted instance back** — tear down an instance a boot brought up (or a
/// half-finished provision) by powering it off through the backend's [`Lifecycle`],
/// recording the teardown verdict as a functional-status row. This is the *undo*
/// seam jera / Skidbladnir call when a provision must be reverted: a boot that never
/// reached [`PowerState::On`], a [`wait_ready`](container::ContainerBoot::wait_ready)
/// that timed out, or a fleet member being reclaimed. GREEN when the backend confirms
/// the power-off; a live backend failure to power it down is a **RED** row AND a
/// propagated [`Err`] — a rollback that could not complete must be visible, never
/// swallowed. Written against the plain [`Lifecycle`] trait so it rolls back **any**
/// backend (KVM / container / Redfish) and a mock in a unit test alike — one shared
/// teardown, never a per-backend twin.
pub fn rollback<L: Lifecycle>(lifecycle: &L, machine: &Machine) -> Result<()> {
let outcome = lifecycle.power_off(machine);
functional_status(
"draupnir/lifecycle",
"rollback",
outcome.is_ok(),
&match &outcome {
Ok(()) => format!("instance `{}` rolled back (powered off)", machine.id),
Err(e) => format!("instance `{}` rollback failed: {e}", machine.id),
},
);
outcome
}
// ---------------------------------------------------------------------------
// Fleet-level boot-status readback — boot N members and roll up who is ready.
// ---------------------------------------------------------------------------
/// The boot-readback verdict for **one** fleet member in a
/// [`boot_fleet_and_await`] rollup — the three ways a member can land.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MemberOutcome {
/// Booted **and confirmed** at [`PowerState::On`] within the wait budget — the
/// member is ready. Carries the live [`Machine`] handle.
Up(Machine),
/// The backend **did not get the member to [`PowerState::On`]** within the
/// bounded budget: the readback timed out (booted but never powered on) or the
/// backend failed / went unreachable while bringing it up (an [`Error::Backend`]
/// — the fleet's "this node did not come up in time" bucket). Carries the
/// failure detail. Counts as **failed**, never blocks the rest of the rollup.
Timeout(String),
/// The member could **not even be launched**: its spec was invalid or the
/// backend is not available ([`Error::Spec`] / [`Error::Unsupported`]) — a hard
/// misconfiguration that waiting can never fix. Carries the failure detail.
/// Counts as **failed**.
Error(String),
}
/// One member's line in a [`FleetReadback`]: its name paired with its verdict.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberReadback {
/// The fleet member's name (`"{spec.name}-{i}"`, as minted by [`plan_fleet`]).
pub name: String,
/// Its boot-readback [`outcome`](MemberOutcome).
pub outcome: MemberOutcome,
}
/// **The fleet-level boot-readback rollup** returned by [`boot_fleet_and_await`]:
/// one verdict per member (in fleet order `node-1`, `node-2`, …) plus the aggregate
/// ready/failed tallies a dispatcher (jera) reads to decide whether the fleet is up.
///
/// It is *infallible by construction* — a dead or misconfigured member is a
/// per-member [`MemberOutcome::Timeout`]/[`Error`](MemberOutcome::Error) line, never
/// an early return, so a partial fleet is always observable (like [`boot_fleet`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FleetReadback {
/// Per-member verdicts, in fleet order.
pub members: Vec<MemberReadback>,
}
impl FleetReadback {
/// How many members booted **and** confirmed [`PowerState::On`].
pub fn ready(&self) -> usize {
self.members
.iter()
.filter(|m| matches!(m.outcome, MemberOutcome::Up(_)))
.count()
}
/// How many members did **not** confirm up (timeout **or** error) — the
/// complement of [`ready`](Self::ready).
pub fn failed(&self) -> usize {
self.members.len() - self.ready()
}
/// `true` only when the fleet is non-empty and **every** member is up.
pub fn all_ready(&self) -> bool {
!self.members.is_empty() && self.failed() == 0
}
}
/// **Boot a fleet and roll up who is actually ready** — the fleet-level analogue of
/// [`boot_and_await`], and the multi-machine provision-readback seam jera's
/// dispatcher wants (jera Roster/WorkPayload → draupnir boots the fleet → this rolls
/// up who came up). It [`plan_fleet`]s `spec` into `n` members and drives each
/// through [`boot_and_await`] (validate → [`boot`] → confirm [`PowerState::On`]),
/// classifying every member's result into a [`MemberOutcome`] and returning the
/// [`FleetReadback`] rollup (per-member verdict + aggregate ready/failed counts).
///
/// **Pass a bounded [`WaitOptions`]** ([`WaitOptions::bounded`]): the per-member
/// timeout is what keeps one dead node from hanging the whole rollup — each member is
/// awaited independently within `opts`, so a member that never powers on rolls up as
/// a [`MemberOutcome::Timeout`] while the healthy members are [`Up`](MemberOutcome::Up)
/// (the default unbounded `WaitOptions` would block forever on the first dead node).
/// Written against `Boot + Lifecycle` so it drives any backend (KVM / container /
/// Redfish) and a mock alike, exactly like [`boot_and_await`] — no per-backend twin.
pub fn boot_fleet_and_await<B>(
spec: &BootSpec,
n: usize,
backend: &B,
opts: &WaitOptions,
) -> FleetReadback
where
B: Boot + Lifecycle,
{
let members = plan_fleet(spec, n)
.iter()
.map(|member| classify_member(backend, member, opts))
.collect();
let rollup = FleetReadback { members };
functional_status(
"draupnir/lifecycle",
"boot_fleet_and_await",
rollup.all_ready(),
&format!(
"fleet of {n}: {} ready, {} failed",
rollup.ready(),
rollup.failed()
),
);
rollup
}
/// **Boot one fleet member and classify how it landed** — the shared per-member step
/// behind both the serial [`boot_fleet_and_await`] and the parallel
/// [`boot_fleet_and_await_parallel`], so the two paths produce a **byte-identical**
/// [`MemberReadback`] for the same member by construction (there is exactly ONE
/// classification, never a twin — L5). Drives the member through [`boot_and_await`]
/// (validate → [`boot`] → confirm [`PowerState::On`]) and buckets the result:
/// `Ok` → [`Up`](MemberOutcome::Up); `Err(Backend)` → [`Timeout`](MemberOutcome::Timeout)
/// (the backend did not get it up in budget — a readback timeout or a transient boot
/// failure); `Err(Spec | Unsupported)` → [`Error`](MemberOutcome::Error) (a hard
/// misconfiguration / no backend — waiting cannot help).
fn classify_member<B>(backend: &B, member: &BootSpec, opts: &WaitOptions) -> MemberReadback
where
B: Boot + Lifecycle,
{
let outcome = match boot_and_await(backend, member, opts) {
Ok(machine) => MemberOutcome::Up(machine),
// A backend failure to bring the member up in time (readback timeout or a
// live backend error while booting/polling) — the node did not come up
// within the budget.
Err(Error::Backend(msg)) => MemberOutcome::Timeout(msg),
// A hard misconfiguration (invalid spec) or an unavailable backend — waiting
// cannot help, so it is an error, not a timeout.
Err(Error::Spec(msg)) | Err(Error::Unsupported(msg)) => MemberOutcome::Error(msg),
};
MemberReadback {
name: member.name.clone(),
outcome,
}
}
/// **Boot a fleet CONCURRENTLY and roll up who is ready** — the parallel sibling of
/// [`boot_fleet_and_await`]. Where the serial call awaits members one-at-a-time (so a
/// fleet's wall-clock is the *sum* of the per-member waits), this fans every member
/// onto the **gatling fork-join pool** ([`gatling::gatling_forkjoin::gatling_for_each`]
/// — the constellation's one sanctioned threading home, ROOT-LAW #0), one worker per
/// member, so `n` nodes are booted and awaited **at the same time** and the
/// wall-clock collapses to roughly the *slowest* member's wait, not the sum. This is
/// the real win for jera's multi-machine dispatcher: booting a fleet of `n` nodes with
/// a 5-minute per-member budget takes ~5 minutes, not ~`5n` minutes.
///
/// It produces an **identical [`FleetReadback`]** to the serial call for the same
/// inputs — same per-member [`MemberOutcome`], same **fleet order** (`node-1`…`node-n`,
/// not completion order: the threads are joined back in fleet order), same aggregate
/// tallies — because both paths classify each member through the one shared
/// [`classify_member`] step. It is likewise *infallible by construction*: a dead or
/// misconfigured member is its own per-member line, and — because each member is
/// awaited on its **own** thread within the bounded `opts` — one dead node cannot hang
/// the others (they finish independently and are joined). **Pass a bounded
/// [`WaitOptions`]** for the same reason as the serial call.
///
/// Requires `B: Sync` (the backend is shared `&B` across the gatling workers); all three
/// real backends ([`kvm::KvmBoot`], [`container::ContainerBoot`], [`redfish::RedfishBoot`])
/// satisfy it. A backend that is not `Sync` simply uses the serial
/// [`boot_fleet_and_await`] instead — the serial path stays the fully-general fallback
/// (L2: the working path is kept, never replaced).
pub fn boot_fleet_and_await_parallel<B>(
spec: &BootSpec,
n: usize,
backend: &B,
opts: &WaitOptions,
) -> FleetReadback
where
B: Boot + Lifecycle + Sync,
{
let plan = plan_fleet(spec, n);
// Fan each member onto the gatling fork-join pool (the constellation's ONE
// sanctioned home for threading — ROOT-LAW #0: no bare `std::thread::spawn`).
// `gatling_for_each` self-dispatches units and returns results in **index
// order**, so the rollup stays deterministic and order-identical to the serial
// path regardless of which member finishes first — identical effect to the
// former `std::thread::scope` fan-out.
//
// Worker count is pinned to `plan.len()` (ONE worker per member), NOT the
// default core count: `classify_member` is **blocking-IO** (boot a backend +
// poll it up), not CPU work, so every member must have its own thread parked
// on its own boot at the same time for the fleet wall-clock to collapse to the
// *slowest* member (the whole point of this call). A core-bounded pool would
// serialise `n > cores` members and break that guarantee. This is deliberately
// exempt from the core-saturation law, which governs CPU hot paths, not
// IO-bound waiters.
let members: Vec<MemberReadback> =
gatling::gatling_forkjoin::gatling_for_each(plan.len(), plan.len(), |i| {
classify_member(backend, &plan[i], opts)
});
let rollup = FleetReadback { members };
functional_status(
"draupnir/lifecycle",
"boot_fleet_and_await_parallel",
rollup.all_ready(),
&format!(
"fleet of {n} (parallel): {} ready, {} failed",
rollup.ready(),
rollup.failed()
),
);
rollup
}
#[cfg(test)]
mod tests {
use super::*;
fn bmc() -> BmcEndpoint {
BmcEndpoint {
host: "https://bmc-42.dc.example".into(),
username: "admin".into(),
system_id: "System.Embedded.1".into(),
}
}
#[test]
fn image_source_suits_the_right_backend() {
assert!(ImageSource::Disk {
kernel: "/bzImage".into(),
disk: "/d.qcow2".into()
}
.suits(Kind::Vm));
assert!(ImageSource::OciImage("redis:7".into()).suits(Kind::Container));
assert!(ImageSource::Iso("/boot.iso".into()).suits(Kind::BareMetal));
assert!(ImageSource::Executable {
path: "/bin/true".into(),
args: Vec::new()
}
.suits(Kind::Exe));
// An ISO suits BOTH machine backends — changed 2026-08-15, and the point of
// the change. `(Iso, Kvm)` used to be asserted here as REJECTED, which is
// literally the wall that made the ISO chain unrunnable: there was no way to
// hand the KVM backend an ISO at all. One spec must route to the faked KVM
// machine and to real metal (honesty rule 2), so the pairing is legal on both.
assert!(
ImageSource::Iso("/boot.iso".into()).suits(Kind::Vm),
"an ISO is bootable under KVM (-cdrom): this is the iso-kvm leg"
);
// Cross pairings that remain nonsense are still rejected.
assert!(!ImageSource::OciImage("redis:7".into()).suits(Kind::BareMetal));
assert!(!ImageSource::Iso("/boot.iso".into()).suits(Kind::Container));
assert!(!ImageSource::OciImage("redis:7".into()).suits(Kind::Exe));
assert!(!ImageSource::Executable {
path: "/bin/true".into(),
args: Vec::new()
}
.suits(Kind::Vm));
}
#[test]
fn valid_specs_pass_validation() {
BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz")
.validate()
.unwrap();
BootSpec::container("cache", "docker.io/library/redis:7")
.validate()
.unwrap();
BootSpec::redfish_iso("node-42", "/images/installer.iso", bmc())
.validate()
.unwrap();
BootSpec::kvm_disk("disky", "/bzImage", "/disk.qcow2")
.validate()
.unwrap();
}
#[test]
fn disk_boot_without_a_kernel_is_rejected() {
// The whole point of the fix: a Disk spec that carries no kernel would
// pass an empty `-kernel` to tunnr and fail at runtime, so reject it here.
let mut spec = BootSpec::kvm_disk("disky", "", "/disk.qcow2");
assert!(matches!(spec.validate(), Err(Error::Spec(_))));
// ...and an empty disk path is likewise rejected.
spec = BootSpec::kvm_disk("disky", "/bzImage", "");
assert!(matches!(spec.validate(), Err(Error::Spec(_))));
}
#[test]
fn empty_required_payload_paths_are_rejected_on_every_image_source() {
// Parity with the Disk checks: an empty required path/ref on ANY image
// source is rejected at validate() rather than handed empty to a backend.
// RED-when-broken — drop any arm's `require` and one of these passes.
// KernelRootfs: empty kernel, then empty rootfs.
let mut kr = BootSpec::kvm_kernel_rootfs("kr", "", "/rootfs.cpio.gz");
assert!(
matches!(kr.validate(), Err(Error::Spec(_))),
"empty kernel rejected"
);
kr = BootSpec::kvm_kernel_rootfs("kr", "/bzImage", " ");
assert!(
matches!(kr.validate(), Err(Error::Spec(_))),
"whitespace rootfs rejected"
);
// OciImage: an empty image reference is not bootable.
let oci = BootSpec::container("cache", "");
assert!(
matches!(oci.validate(), Err(Error::Spec(_))),
"empty OCI ref rejected"
);
// Iso: an empty ISO path is not bootable (BMC present so only the ISO fails).
let iso = BootSpec::redfish_iso("node", " ", bmc());
assert!(
matches!(iso.validate(), Err(Error::Spec(_))),
"empty ISO path rejected"
);
// The well-formed constructors still pass (no regression).
BootSpec::kvm_kernel_rootfs("kr", "/bzImage", "/rootfs.cpio.gz")
.validate()
.unwrap();
BootSpec::container("cache", "redis:7").validate().unwrap();
BootSpec::redfish_iso("node", "/boot.iso", bmc())
.validate()
.unwrap();
}
#[test]
fn blank_instance_name_is_rejected_on_every_backend() {
// The name is the Machine-id / fleet-member prefix; a blank one yields
// "-1"/"-2" members and a leading-dash id, so reject it up front.
// RED-when-broken — drop the `require("instance name", …)` and these pass.
let mut vm = BootSpec::kvm_kernel_rootfs("", "/bzImage", "/rootfs.cpio.gz");
assert!(
matches!(vm.validate(), Err(Error::Spec(_))),
"blank KVM name rejected"
);
vm = BootSpec::kvm_kernel_rootfs(" ", "/bzImage", "/rootfs.cpio.gz");
assert!(
matches!(vm.validate(), Err(Error::Spec(_))),
"whitespace KVM name rejected"
);
let ctr = BootSpec::container("", "redis:7");
assert!(
matches!(ctr.validate(), Err(Error::Spec(_))),
"blank container name rejected"
);
let node = BootSpec::redfish_iso("", "/boot.iso", bmc());
assert!(
matches!(node.validate(), Err(Error::Spec(_))),
"blank Redfish name rejected"
);
}
#[test]
fn kvm_spec_without_ram_or_cpus_is_rejected() {
// A KVM VM is booted with the spec's RAM/vCPU verbatim; 0 launches a dead
// guest. RED-when-broken — drop either sizing guard and one of these passes.
let mut vm = BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz");
vm.mem_mb = 0;
assert!(
matches!(vm.validate(), Err(Error::Spec(_))),
"0 MiB RAM rejected"
);
vm = BootSpec::kvm_disk("disky", "/bzImage", "/disk.qcow2");
vm.cores = 0;
assert!(
matches!(vm.validate(), Err(Error::Spec(_))),
"0 vCPUs rejected"
);
// Container/Redfish carry no VM sizing (both default to 0) and stay valid.
BootSpec::container("cache", "redis:7").validate().unwrap();
BootSpec::redfish_iso("node", "/boot.iso", bmc())
.validate()
.unwrap();
}
#[test]
fn redfish_spec_with_a_blank_bmc_field_is_rejected() {
// Parity with the ISO-path check: each BMC field is woven into a Redfish
// URL, so a blank one is rejected at validate(), not on the wire.
// RED-when-broken — drop any `require("BMC …", …)` and its arm passes.
let mut spec = BootSpec::redfish_iso("node", "/boot.iso", bmc());
spec.bmc = Some(BmcEndpoint {
host: " ".into(),
..bmc()
});
assert!(
matches!(spec.validate(), Err(Error::Spec(_))),
"blank BMC host rejected"
);
spec = BootSpec::redfish_iso("node", "/boot.iso", bmc());
spec.bmc = Some(BmcEndpoint {
username: String::new(),
..bmc()
});
assert!(
matches!(spec.validate(), Err(Error::Spec(_))),
"blank BMC username rejected"
);
spec = BootSpec::redfish_iso("node", "/boot.iso", bmc());
spec.bmc = Some(BmcEndpoint {
system_id: String::new(),
..bmc()
});
assert!(
matches!(spec.validate(), Err(Error::Spec(_))),
"blank BMC system id rejected"
);
// A fully-populated BMC still validates (no regression).
BootSpec::redfish_iso("node", "/boot.iso", bmc())
.validate()
.unwrap();
}
#[test]
fn container_spec_with_a_zero_or_duplicate_port_is_rejected() {
// Published ports are exposed and host-bound verbatim; `0` is never a real
// published port and a doubled port self-collides. RED-when-broken — drop
// either guard and its arm passes.
let zero = BootSpec::container("cache", "redis:7").with_port(0);
assert!(
matches!(zero.validate(), Err(Error::Spec(_))),
"0 published port rejected"
);
let dup = BootSpec::container("cache", "redis:7")
.with_port(8080)
.with_port(8080);
assert!(
matches!(dup.validate(), Err(Error::Spec(_))),
"duplicate published port rejected"
);
// A mix with a single 0 among valid ports is still rejected.
let mixed = BootSpec::container("cache", "redis:7")
.with_port(8080)
.with_port(0);
assert!(
matches!(mixed.validate(), Err(Error::Spec(_))),
"0 among valid ports rejected"
);
// A valid, distinct port set passes; no ports at all passes (no regression).
BootSpec::container("cache", "redis:7")
.with_port(8080)
.with_port(8443)
.validate()
.unwrap();
BootSpec::container("cache", "redis:7").validate().unwrap();
}
#[test]
fn container_port_map_validates_distinct_host_container_and_host_collisions() {
// The distinct `host:container` form (the per-zone offset fix): a `Test`
// zone publishes FalkorDB on host 6380 → container 6379. RED-when-broken —
// drop a guard and its arm passes.
// A valid distinct map passes — this is korp's Test/Prod zone shape.
BootSpec::container("falkor", "docker.io/falkordb/falkordb:v4.20.0")
.with_port_map(6380, 6379)
.validate()
.unwrap();
// Host 0 or container 0 is not a real published port.
let host0 = BootSpec::container("f", "img:1").with_port_map(0, 6379);
assert!(
matches!(host0.validate(), Err(Error::Spec(_))),
"host 0 rejected"
);
let cont0 = BootSpec::container("f", "img:1").with_port_map(6380, 0);
assert!(
matches!(cont0.validate(), Err(Error::Spec(_))),
"container 0 rejected"
);
// The same HOST port bound twice self-collides — across two maps...
let dup_map = BootSpec::container("f", "img:1")
.with_port_map(6380, 6379)
.with_port_map(6380, 15002);
assert!(
matches!(dup_map.validate(), Err(Error::Spec(_))),
"duplicate host port across maps rejected"
);
// ...and across the `ports` + `port_maps` fields (a host 8080 in both).
let dup_mix = BootSpec::container("f", "img:1")
.with_port(8080)
.with_port_map(8080, 80);
assert!(
matches!(dup_mix.validate(), Err(Error::Spec(_))),
"host port shared by ports+port_maps rejected"
);
// Two DIFFERENT host ports mapping to the SAME container port is fine —
// that is exactly the multi-zone case (never both live on one host at
// once, but a spec listing both is legitimate) — no false collision.
BootSpec::container("f", "img:1")
.with_port_map(6380, 6379)
.with_port_map(6381, 6379)
.validate()
.unwrap();
// `ports` (host==container) composes with a distinct map on another port.
BootSpec::container("f", "img:1")
.with_port(6379)
.with_port_map(15003, 15002)
.validate()
.unwrap();
}
#[test]
fn container_only_cmd_or_ports_on_a_non_container_backend_is_rejected() {
// `cmd` and `ports` are container-only knobs; KVM/Redfish silently ignore
// them, so setting either there is a misconfiguration that would vanish
// without a trace — reject it (parity with a bmc on a non-Redfish backend).
// RED-when-broken — drop either guard and its arm passes.
// A cmd on a KVM spec is rejected.
let kvm_cmd = BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz")
.with_cmd(["/bin/init"]);
assert!(
matches!(kvm_cmd.validate(), Err(Error::Spec(_))),
"cmd on KVM rejected"
);
// A published port on a KVM spec is ACCEPTED as of 2026-09-05: tunnr's
// user-mode NIC forwards `(host, guest)` natively, so the pair has a real
// wire here and is no longer container-only. This assertion is inverted
// from what it said before, deliberately — see `BootSpec::validate` for
// why the old rejection was the reason a caller who needed to reach a
// service in the guest could not use draupnir at all.
BootSpec::kvm_disk("disky", "/bzImage", "/disk.qcow2")
.with_port(8080)
.validate()
.expect("a KVM boot may forward a host port to the guest");
// A cmd on a Redfish spec is rejected...
let redfish_cmd = BootSpec::redfish_iso("node", "/boot.iso", bmc()).with_cmd(["/bin/init"]);
assert!(
matches!(redfish_cmd.validate(), Err(Error::Spec(_))),
"cmd on Redfish rejected"
);
// ...and a published port on a Redfish spec is rejected.
let redfish_port = BootSpec::redfish_iso("node", "/boot.iso", bmc()).with_port(443);
assert!(
matches!(redfish_port.validate(), Err(Error::Spec(_))),
"port on Redfish rejected"
);
// The container backend still carries both (no regression).
BootSpec::container("web", "nginx:latest")
.with_cmd(["nginx", "-g", "daemon off;"])
.with_port(8080)
.with_port(8443)
.validate()
.unwrap();
}
/// **A forward needs a NIC to arrive on.** `NetMode::None` renders `-nic
/// none` on the KVM path, so a spec that asks for both is a contradiction —
/// and the failure mode of resolving it silently is the worst kind: the
/// guest comes up with no NIC, the host connects to a port nothing is
/// listening on, and the test hangs to its timeout instead of saying why.
/// Refused by name, at spec time.
///
/// RED-when-broken: drop the airgap-vs-forward arm in `validate` and this
/// spec validates clean.
#[test]
fn a_kvm_boot_cannot_both_airgap_the_guest_and_forward_a_port_to_it() {
let both = BootSpec::kvm_disk("contradiction", "/bzImage", "/disk.qcow2")
.with_net(NetMode::None)
.with_port_map(2222, 22);
let err = both.validate().expect_err("airgap + forward is a contradiction");
let msg = err.to_string();
assert!(msg.contains("NetMode::None"), "names the airgap: {msg}");
assert!(msg.contains("forward"), "names the forward: {msg}");
// Either one ALONE is fine — the refusal is about the pair, not about
// having reintroduced a rejection of forwards.
BootSpec::kvm_disk("dark", "/bzImage", "/disk.qcow2")
.with_net(NetMode::None)
.validate()
.expect("airgap alone");
BootSpec::kvm_disk("open", "/bzImage", "/disk.qcow2")
.with_port_map(2222, 22)
.validate()
.expect("forward alone");
}
#[test]
fn container_resource_knobs_are_container_only_and_must_be_positive() {
// `cpus` / `mem_limit_mb` are container-only (parity with cmd/ports/net):
// a CPU/memory cap on a KVM or Redfish spec is rejected up front.
// RED-when-broken — drop either container-only guard and its arm passes.
let kvm_cpus =
BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz").with_cpus(8.0);
assert!(
matches!(kvm_cpus.validate(), Err(Error::Spec(_))),
"cpus on KVM rejected"
);
let kvm_mem =
BootSpec::kvm_disk("disky", "/bzImage", "/disk.qcow2").with_mem_limit_mb(4096);
assert!(
matches!(kvm_mem.validate(), Err(Error::Spec(_))),
"mem limit on KVM rejected"
);
let redfish_cpus = BootSpec::redfish_iso("node", "/boot.iso", bmc()).with_cpus(4.0);
assert!(
matches!(redfish_cpus.validate(), Err(Error::Spec(_))),
"cpus on Redfish rejected"
);
// A non-positive cap is rejected on the container backend (None = all cores /
// unconstrained is the way to express "no limit", never 0).
let zero_cpus = BootSpec::container("cache", "redis:7").with_cpus(0.0);
assert!(
matches!(zero_cpus.validate(), Err(Error::Spec(_))),
"0 cpus rejected"
);
let mut neg = BootSpec::container("cache", "redis:7");
neg.cpus = Some(-1.0);
assert!(
matches!(neg.validate(), Err(Error::Spec(_))),
"negative cpus rejected"
);
let zero_mem = BootSpec::container("cache", "redis:7").with_mem_limit_mb(0);
assert!(
matches!(zero_mem.validate(), Err(Error::Spec(_))),
"0 MiB memory rejected"
);
// A hot-infra container (all cores, sized memory) validates; and the default
// (no caps = all host cores, unconstrained memory) validates too (no regression).
BootSpec::container("falkordb", "docker.io/falkordb/falkordb:v4.20.0")
.with_port(6379)
.with_cpus(12.0)
.with_mem_limit_mb(16384)
.validate()
.unwrap();
BootSpec::container("cache", "redis:7").validate().unwrap();
assert_eq!(
BootSpec::container("cache", "redis:7").cpus,
None,
"default = all host cores"
);
}
#[test]
fn the_container_process_identity_is_container_only_and_never_blank() {
// `user` / `working_dir` are the container process's identity and cwd —
// container vocabulary, parity with cmd/ports/net/caps. A KVM guest runs
// its own init and a Redfish node is a board: setting either there would
// vanish without a trace, so it is refused rather than dropped.
let kvm = BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz")
.with_user("0:0");
assert!(
matches!(kvm.validate(), Err(Error::Spec(_))),
"user on KVM rejected"
);
let redfish = BootSpec::redfish_iso("node", "/boot.iso", bmc()).with_working_dir("/src");
assert!(
matches!(redfish.validate(), Err(Error::Spec(_))),
"working_dir on Redfish rejected"
);
// Named-but-blank is a typo, not a choice: the daemon would read `""` as
// "unset" and the caller would believe the process runs as somebody, or
// starts somewhere, that it does not.
assert!(matches!(
BootSpec::container("b", "img:1").with_user(" ").validate(),
Err(Error::Spec(_))
));
assert!(matches!(
BootSpec::container("b", "img:1").with_working_dir("").validate(),
Err(Error::Spec(_))
));
// The shape Njord's appliance build needs, end to end — and the default
// (neither named) still validates, so nothing that exists today moves.
let build = BootSpec::container("njord-build-aaa1111-cargo", "njord-toolchain:1.95.0")
.with_user("0:0")
.with_working_dir("/src")
.with_bind("/var/lib/njord-build/src/aaa1111:/src:rw");
build.validate().unwrap();
assert_eq!(build.user.as_deref(), Some("0:0"));
assert_eq!(build.working_dir.as_deref(), Some("/src"));
let plain = BootSpec::container("cache", "redis:7");
plain.validate().unwrap();
assert!(plain.user.is_none() && plain.working_dir.is_none());
}
// -- Boot off a medium: the two legs, and the lie between them ---------------
#[test]
fn the_install_leg_and_the_boot_installed_leg_are_two_distinct_specs() {
// Honesty rule 1 in the type system. The install spec carries the medium and
// boots off it; the boot-installed spec carries NO medium and boots the disk.
// They are two values, so they can only be two boots.
let install = BootSpec::kvm_install_from_medium("app", "/gunnar.iso", "/target.qcow2");
install.validate().unwrap();
assert_eq!(install.medium_path(), Some("/gunnar.iso"));
assert_eq!(install.boot_order, BootOrder::Medium);
assert_eq!(
install.image,
ImageSource::BootloaderDisk {
disk: "/target.qcow2".into()
},
"the blank target disk is present to be installed ONTO"
);
assert!(
install.persist_disk,
"an install whose writes are thrown away installed nothing"
);
let installed = BootSpec::kvm_boot_installed_disk("app", "/target.qcow2");
installed.validate().unwrap();
assert_eq!(installed.medium_path(), None, "the medium is ABSENT");
assert_eq!(installed.boot_order, BootOrder::Disk);
assert!(installed.persist_disk);
// Same disk, different machine: that is the whole claim.
assert_ne!(install, installed);
}
#[test]
fn a_boot_installed_spec_that_still_carries_the_medium_is_refused() {
// THE guard. With the ISO still in the tray, a machine that fails to boot its
// freshly-installed disk falls through to the installer and looks like a pass.
// RED-when-broken: delete the `BootOrder::Disk && medium_path().is_some()`
// arm in validate() and this spec sails through.
let lie = BootSpec::kvm_boot_installed_disk("app", "/target.qcow2")
.with_medium("/gunnar.iso");
let err = lie.validate().unwrap_err();
let msg = format!("{err}");
assert!(matches!(err, Error::Spec(_)));
assert!(msg.contains("/gunnar.iso"), "names the medium: {msg}");
assert!(msg.contains("ABSENT"), "says what boot-installed MEANS: {msg}");
assert!(
msg.contains("second VM lifetime"),
"points at the fix: {msg}"
);
// ...and the same lie spelled the other way (an ISO payload + boot from disk).
let lie2 = BootSpec::iso_boot("app", "/gunnar.iso").with_boot_order(BootOrder::Disk);
assert!(
matches!(lie2.validate(), Err(Error::Spec(_))),
"an Iso payload counts as an attached medium too"
);
}
#[test]
fn boot_order_medium_with_no_medium_anywhere_is_refused_by_name() {
// "Boot off the medium" with nothing in the tray cannot do what it says.
// RED-when-broken: drop the `Medium && medium_path().is_none()` arm.
let empty = BootSpec::kvm_bootloader_disk("app", "/d.qcow2")
.with_boot_order(BootOrder::Medium);
let err = empty.validate().unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("no medium"), "names the problem: {msg}");
assert!(msg.contains("medium"), "names the field to set: {msg}");
}
#[test]
fn one_iso_spec_routes_to_kvm_or_to_metal_without_changing_the_boot_claim() {
// Honesty rule 2: `iso-kvm` and `iso-metal` are distinct RUNS of ONE spec.
// Everything that describes the boot must survive the routing; only the
// machine it is aimed at changes.
let kvm = BootSpec::iso_boot("gunnar-server", "/images/gunnar-server.iso");
kvm.validate().unwrap();
assert_eq!(kvm.kind, Kind::Vm);
assert_eq!(kvm.boot_order, BootOrder::Medium);
let metal = kvm.clone().on_metal(bmc());
metal.validate().unwrap();
assert_eq!(metal.kind, Kind::BareMetal);
// The boot claim is untouched by the routing.
assert_eq!(metal.image, kvm.image);
assert_eq!(metal.medium_path(), kvm.medium_path());
assert_eq!(metal.boot_order, kvm.boot_order);
// Each backend reads the SAME field in its own vocabulary — one field, two
// renderings, so a KVM green and a metal green are the same claim.
assert_eq!(kvm.boot_order.qemu_value(), Some("d"));
assert_eq!(metal.boot_order.redfish_target(), Some(BootTarget::Cd));
}
#[test]
fn a_medium_is_rejected_on_the_backends_that_have_no_drive_tray() {
// RED-when-broken: drop the Kvm|Redfish guard and a container silently
// accepts an ISO it will never read.
let ctr = BootSpec::container("cache", "redis:7").with_medium("/boot.iso");
assert!(matches!(ctr.validate(), Err(Error::Spec(_))), "medium on container rejected");
let ctr_order = BootSpec::container("cache", "redis:7").with_boot_order(BootOrder::Disk);
assert!(
matches!(ctr_order.validate(), Err(Error::Spec(_))),
"boot order on container rejected"
);
// A blank medium path is rejected like every other required path.
let blank = BootSpec::kvm_bootloader_disk("app", "/d.qcow2").with_medium(" ");
assert!(matches!(blank.validate(), Err(Error::Spec(_))), "blank medium rejected");
}
#[test]
fn every_pre_existing_constructor_still_names_no_medium_and_no_boot_order() {
// The additive-parity guard: the two new fields must leave every spec that
// existed before them exactly as it was, so no caller's launch moves.
for spec in [
BootSpec::kvm_kernel_rootfs("a", "/bzImage", "/rootfs.cpio.gz"),
BootSpec::kvm_disk("b", "/bzImage", "/d.qcow2"),
BootSpec::kvm_bootloader_disk("c", "/d.qcow2"),
BootSpec::container("d", "redis:7"),
BootSpec::redfish_iso("e", "/boot.iso", bmc()),
] {
assert_eq!(spec.medium, None, "{} names no explicit medium", spec.name);
assert_eq!(
spec.boot_order,
BootOrder::Auto,
"{} names no boot order",
spec.name
);
assert_eq!(
spec.boot_order.qemu_value(),
None,
"{} emits no -boot",
spec.name
);
spec.validate().unwrap();
}
// The one pre-existing spec whose image IS an ISO still resolves a medium
// through the shared accessor — that is how the Redfish backend keeps working.
assert_eq!(
BootSpec::redfish_iso("e", "/boot.iso", bmc()).medium_path(),
Some("/boot.iso")
);
}
#[test]
fn the_one_constructor_writer_keeps_the_lean_and_kvm_defaults_apart() {
// `BootSpec::of` is the single writer; prove it did not flatten the two
// default shapes into one (KVM is sized, everything else is lean).
assert_eq!(
(
BootSpec::kvm_bootloader_disk("k", "/d.qcow2").mem_mb,
BootSpec::kvm_bootloader_disk("k", "/d.qcow2").cores
),
(512, 2)
);
for lean in [
BootSpec::container("c", "redis:7"),
BootSpec::redfish_iso("r", "/b.iso", bmc()),
BootSpec::exe("x", "/bin/true", Vec::<String>::new()),
] {
assert_eq!((lean.mem_mb, lean.cores), (0, 0), "{} stays lean", lean.name);
}
}
#[test]
fn image_backend_mismatch_is_rejected() {
let mut spec = BootSpec::container("bad", "redis:7");
spec.image = ImageSource::Iso("/boot.iso".into());
assert!(matches!(spec.validate(), Err(Error::Spec(_))));
}
#[test]
fn redfish_without_bmc_is_rejected() {
let mut spec = BootSpec::redfish_iso("node", "/boot.iso", bmc());
spec.bmc = None;
assert!(matches!(spec.validate(), Err(Error::Spec(_))));
}
#[test]
fn non_redfish_with_bmc_is_rejected() {
let mut spec = BootSpec::container("cache", "redis:7");
spec.bmc = Some(bmc());
assert!(matches!(spec.validate(), Err(Error::Spec(_))));
}
#[test]
fn started_machine_records_the_spec() {
let spec = BootSpec::container("cache", "redis:7").with_env("PORT", "6379");
let m = Machine::started("ctr-abc123", &spec);
assert_eq!(m.spec_name, "cache");
assert_eq!(m.kind, Kind::Container);
assert_eq!(m.power, PowerState::On);
assert_eq!(spec.env.get("PORT").map(String::as_str), Some("6379"));
}
/// A fake [`Boot`] that records the specs it was handed and mints a stable id,
/// so the unifying [`boot`]/[`boot_fleet`] entry points are testable with no
/// live backend.
#[derive(Default)]
struct RecordingBoot {
seen: std::cell::RefCell<Vec<String>>,
}
impl Boot for RecordingBoot {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
self.seen.borrow_mut().push(spec.name.clone());
Ok(Machine::started(format!("id-{}", spec.name), spec))
}
}
#[test]
fn boot_validates_then_delegates_to_the_backend() {
let backend = RecordingBoot::default();
let spec = BootSpec::container("cache", "redis:7");
let m = boot(&spec, &backend).unwrap();
assert_eq!(m.id, "id-cache");
assert_eq!(m.kind, Kind::Container);
assert_eq!(backend.seen.borrow().as_slice(), &["cache".to_string()]);
}
#[test]
fn boot_rejects_an_invalid_spec_before_touching_the_backend() {
let backend = RecordingBoot::default();
let mut spec = BootSpec::container("bad", "redis:7");
spec.image = ImageSource::Iso("/boot.iso".into()); // ISO can't boot on Container
assert!(matches!(boot(&spec, &backend), Err(Error::Spec(_))));
assert!(
backend.seen.borrow().is_empty(),
"backend never touched on an invalid spec"
);
}
#[test]
fn boot_fleet_drips_and_boots_every_member_through_one_backend() {
let backend = RecordingBoot::default();
let one = BootSpec::redfish_iso("node", "/images/installer.iso", bmc());
let results = boot_fleet(&one, 3, &backend);
assert_eq!(results.len(), 3);
let ids: Vec<_> = results.into_iter().map(|r| r.unwrap().id).collect();
assert_eq!(ids, vec!["id-node-1", "id-node-2", "id-node-3"]);
assert_eq!(
backend.seen.borrow().as_slice(),
&["node-1", "node-2", "node-3"]
);
}
/// A [`Boot`] that mints ids until it has booted `ok_before` members, then
/// errors — so the documented "a partial fleet is observable" contract of
/// [`boot_fleet`] can be exercised.
struct FlakyBoot {
ok_before: usize,
booted: std::cell::RefCell<usize>,
}
impl Boot for FlakyBoot {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
let mut n = self.booted.borrow_mut();
if *n >= self.ok_before {
return Err(Error::Backend(format!(
"backend went away booting {}",
spec.name
)));
}
*n += 1;
Ok(Machine::started(format!("id-{}", spec.name), spec))
}
}
#[test]
fn boot_fleet_reports_a_partial_fleet_when_a_later_member_fails() {
// The doc promises a partial fleet is observable: the first members boot,
// a later one errors, and every per-member result is returned in order.
let backend = FlakyBoot {
ok_before: 2,
booted: std::cell::RefCell::new(0),
};
let one = BootSpec::redfish_iso("node", "/images/installer.iso", bmc());
let results = boot_fleet(&one, 4, &backend);
assert_eq!(results.len(), 4);
assert_eq!(results[0].as_ref().unwrap().id, "id-node-1");
assert_eq!(results[1].as_ref().unwrap().id, "id-node-2");
assert!(
matches!(results[2], Err(Error::Backend(_))),
"3rd member fails"
);
assert!(
matches!(results[3], Err(Error::Backend(_))),
"4th member fails too"
);
let ok = results.iter().filter(|r| r.is_ok()).count();
assert_eq!(ok, 2, "exactly the first two members booted");
}
#[test]
fn plan_fleet_of_zero_is_empty_and_of_one_keeps_a_suffix() {
assert!(plan_fleet(&BootSpec::container("c", "redis:7"), 0).is_empty());
// Even n=1 gets the 1-based suffix (a fleet member is always "{name}-{i}").
let one = plan_fleet(&BootSpec::container("c", "redis:7"), 1);
assert_eq!(one.len(), 1);
assert_eq!(one[0].name, "c-1");
}
#[test]
fn cloud_init_user_data_constructor_defaults_meta_data_to_none() {
let ci = CloudInit::user_data("#cloud-config\n");
assert_eq!(ci.user_data, "#cloud-config\n");
assert_eq!(ci.meta_data, None);
assert_eq!(ci.network_config, None);
}
#[test]
fn container_spec_defaults_are_lean() {
// A container boot carries no VM sizing and no overrides until asked — the
// "lean by design" contract the KVM path does not share.
let spec = BootSpec::container("cache", "docker.io/library/redis:7");
assert_eq!(spec.mem_mb, 0);
assert_eq!(spec.cores, 0);
assert!(spec.cmd.is_empty());
assert!(spec.ports.is_empty());
assert!(spec.env.is_empty());
assert!(spec.bmc.is_none());
assert!(spec.cloud_init.is_none());
}
#[test]
fn cloud_init_on_a_container_spec_still_validates_and_is_backend_ignored() {
// cloud_init is a KVM-only provisioning payload; attaching it to a container
// spec is not an error (the container backend simply ignores it).
let spec = BootSpec::container("cache", "redis:7")
.with_cloud_init(CloudInit::user_data("#cloud-config\n"));
spec.validate().unwrap();
assert!(spec.cloud_init.is_some());
}
#[test]
fn plan_fleet_drips_n_identical_but_distinctly_named_members() {
let one = BootSpec::redfish_iso("node", "/images/installer.iso", bmc());
let fleet = plan_fleet(&one, 8);
assert_eq!(fleet.len(), 8);
assert_eq!(fleet[0].name, "node-1");
assert_eq!(fleet[7].name, "node-8");
// Identical payload + backend across the whole ring.
assert!(fleet
.iter()
.all(|m| m.image == one.image && m.kind == one.kind));
// Names are unique.
let mut names: Vec<_> = fleet.iter().map(|m| m.name.clone()).collect();
names.sort();
names.dedup();
assert_eq!(names.len(), 8);
}
/// A backend that is both [`Boot`] and [`Lifecycle`], reporting a scripted
/// sequence of power states from `status` (then a `fallback` once the sequence
/// is exhausted) — so the [`await_power_state`] / [`boot_and_await`] readback
/// seam is testable with no live instance.
struct ScriptedNode {
states: std::cell::RefCell<std::collections::VecDeque<PowerState>>,
fallback: PowerState,
status_calls: std::cell::Cell<usize>,
}
impl ScriptedNode {
fn new(seq: impl IntoIterator<Item = PowerState>, fallback: PowerState) -> Self {
Self {
states: std::cell::RefCell::new(seq.into_iter().collect()),
fallback,
status_calls: std::cell::Cell::new(0),
}
}
}
impl Boot for ScriptedNode {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
Ok(Machine::started(format!("id-{}", spec.name), spec))
}
}
impl Lifecycle for ScriptedNode {
fn power_on(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn power_off(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn status(&self, _m: &Machine) -> Result<PowerState> {
self.status_calls.set(self.status_calls.get() + 1);
Ok(self
.states
.borrow_mut()
.pop_front()
.unwrap_or(self.fallback))
}
}
fn tiny_bounded() -> WaitOptions {
WaitOptions::bounded(
std::time::Duration::from_millis(200),
std::time::Duration::from_millis(1),
)
}
#[test]
fn await_power_state_returns_ok_once_the_state_is_reached() {
// status reports Unknown twice, then On — await must poll past the
// Unknowns and return Ok the moment it observes On.
let node = ScriptedNode::new(
[PowerState::Unknown, PowerState::Unknown, PowerState::On],
PowerState::On,
);
let m = Machine::started("node-1", &BootSpec::container("c", "redis:7"));
await_power_state(&node, &m, PowerState::On, &tiny_bounded()).unwrap();
assert!(
node.status_calls.get() >= 3,
"polled past the two Unknowns to On"
);
}
#[test]
fn await_power_state_times_out_when_the_state_is_never_reached() {
// The node is stuck Off forever; a bounded wait must time out with a
// Backend error rather than block. RED-when-broken — drop the `== want`
// gate (return Ok on the first poll) and this Err expectation fails.
let node = ScriptedNode::new(std::iter::empty(), PowerState::Off);
let m = Machine::started("node-2", &BootSpec::container("c", "redis:7"));
let r = await_power_state(
&node,
&m,
PowerState::On,
&WaitOptions::bounded(
std::time::Duration::from_millis(20),
std::time::Duration::from_millis(1),
),
);
assert!(
matches!(r, Err(Error::Backend(_))),
"never-up node times out"
);
}
#[test]
fn await_power_state_rejects_awaiting_unknown() {
// Awaiting Unknown is nonsensical (Unknown = "not observed") and is
// rejected up front as a Spec error, before any poll.
let node = ScriptedNode::new([PowerState::On], PowerState::On);
let m = Machine::started("node-3", &BootSpec::container("c", "redis:7"));
let r = await_power_state(&node, &m, PowerState::Unknown, &tiny_bounded());
assert!(matches!(r, Err(Error::Spec(_))), "await Unknown rejected");
assert_eq!(node.status_calls.get(), 0, "rejected before polling");
}
#[test]
fn await_power_state_propagates_a_backend_status_error() {
// A live backend's status() error is the readback failing — it propagates.
struct ErrLifecycle;
impl Lifecycle for ErrLifecycle {
fn power_on(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn power_off(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn status(&self, _m: &Machine) -> Result<PowerState> {
Err(Error::Backend("BMC unreachable".into()))
}
}
let m = Machine::started("node-4", &BootSpec::container("c", "redis:7"));
let r = await_power_state(&ErrLifecycle, &m, PowerState::On, &tiny_bounded());
assert!(
matches!(r, Err(Error::Backend(_))),
"status error propagates"
);
}
#[test]
fn boot_and_await_boots_then_confirms_power_on() {
// The one-call provision seam: boot, then confirm On. The node reports
// Unknown once, then On — boot_and_await returns the live Machine only
// after the On readback.
let node = ScriptedNode::new([PowerState::Unknown, PowerState::On], PowerState::On);
let spec = BootSpec::container("cache", "redis:7");
let m = boot_and_await(&node, &spec, &tiny_bounded()).unwrap();
assert_eq!(m.id, "id-cache");
assert_eq!(m.power, PowerState::On);
assert!(node.status_calls.get() >= 2, "awaited past Unknown to On");
}
#[test]
fn boot_and_await_times_out_when_the_instance_never_comes_up() {
// A node that never powers on makes boot_and_await a bounded timeout, not a
// hang — the readback is what distinguishes a booted-but-dead instance.
let node = ScriptedNode::new(std::iter::empty(), PowerState::Off);
let spec = BootSpec::container("cache", "redis:7");
let r = boot_and_await(
&node,
&spec,
&WaitOptions::bounded(
std::time::Duration::from_millis(20),
std::time::Duration::from_millis(1),
),
);
assert!(
matches!(r, Err(Error::Backend(_))),
"never-up boot times out"
);
}
#[test]
fn boot_and_await_rejects_an_invalid_spec_before_booting() {
// boot_and_await goes through boot(), so an invalid spec is rejected at
// validate() before the backend (or any wait) is ever touched.
let node = ScriptedNode::new([PowerState::On], PowerState::On);
let mut spec = BootSpec::container("bad", "redis:7");
spec.image = ImageSource::Iso("/boot.iso".into()); // ISO can't boot a container
let r = boot_and_await(&node, &spec, &tiny_bounded());
assert!(
matches!(r, Err(Error::Spec(_))),
"invalid spec rejected pre-boot"
);
assert_eq!(
node.status_calls.get(),
0,
"never awaited an unbooted instance"
);
}
/// A fleet backend where members whose name is in `dead` never power on (their
/// `status` stays [`PowerState::Off`]) while every other member boots and reports
/// [`PowerState::On`] — so a *partial-fleet* boot-readback rollup (some up, one
/// timed out) is testable with no live instances. Keys on `Machine::spec_name`,
/// which carries the fleet member name.
struct FleetNode {
dead: std::collections::HashSet<String>,
}
impl FleetNode {
fn with_dead<'a>(dead: impl IntoIterator<Item = &'a str>) -> Self {
Self {
dead: dead.into_iter().map(String::from).collect(),
}
}
}
impl Boot for FleetNode {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
Ok(Machine::started(format!("id-{}", spec.name), spec))
}
}
impl Lifecycle for FleetNode {
fn power_on(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn power_off(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn status(&self, m: &Machine) -> Result<PowerState> {
// A dead member is stuck Off forever (never confirms up); everyone else
// is On the moment they are polled.
if self.dead.contains(&m.spec_name) {
Ok(PowerState::Off)
} else {
Ok(PowerState::On)
}
}
}
#[test]
fn boot_fleet_and_await_rolls_up_one_dead_member_as_timeout_others_up() {
// The headline fleet-readback contract: boot a fleet of 3 where the middle
// member never powers on. The rollup must show node-2 = Timeout while node-1
// and node-3 = Up, with the aggregate ready/failed tallies correct — a dead
// node is a per-member verdict, never a hang or an early return.
//
// RED-when-broken: this leans on `boot_and_await` confirming On per member
// AND the Err(Backend)→Timeout classification. Neuter await_power_state's
// `observed == want` gate (return Ok on the first poll) and node-2 rolls up
// Up → ready becomes 3, this fails; flip the Timeout classification arm to
// Error and the `matches!(.., Timeout)` assertion fails.
let backend = FleetNode::with_dead(["node-2"]);
let one = BootSpec::redfish_iso("node", "/images/installer.iso", bmc());
let rollup = boot_fleet_and_await(&one, 3, &backend, &tiny_bounded());
assert_eq!(
rollup.members.len(),
3,
"one verdict per member, in fleet order"
);
assert_eq!(rollup.members[0].name, "node-1");
assert_eq!(rollup.members[1].name, "node-2");
assert_eq!(rollup.members[2].name, "node-3");
// The two healthy members are Up with their live handle...
match &rollup.members[0].outcome {
MemberOutcome::Up(m) => assert_eq!(m.id, "id-node-1"),
other => panic!("node-1 should be Up, was {other:?}"),
}
assert!(
matches!(rollup.members[2].outcome, MemberOutcome::Up(_)),
"node-3 up"
);
// ...and the dead member is a Timeout, not an Up and not a hang.
assert!(
matches!(rollup.members[1].outcome, MemberOutcome::Timeout(_)),
"node-2 never powered on → Timeout, was {:?}",
rollup.members[1].outcome
);
// Aggregate tallies (mutation-verified).
assert_eq!(
rollup.ready(),
2,
"exactly the two healthy members are ready"
);
assert_eq!(rollup.failed(), 1, "exactly the one dead member failed");
assert!(!rollup.all_ready(), "a partial fleet is not all-ready");
}
#[test]
fn boot_fleet_and_await_reports_a_fully_ready_fleet() {
// The all-healthy path: every member boots and confirms On, so the rollup is
// all_ready with ready == n and no failures.
let backend = FleetNode::with_dead(std::iter::empty());
let one = BootSpec::container("cache", "redis:7");
let rollup = boot_fleet_and_await(&one, 4, &backend, &tiny_bounded());
assert_eq!(rollup.members.len(), 4);
assert!(rollup
.members
.iter()
.all(|m| matches!(m.outcome, MemberOutcome::Up(_))));
assert_eq!(rollup.ready(), 4);
assert_eq!(rollup.failed(), 0);
assert!(rollup.all_ready(), "a fully-up fleet is all-ready");
}
#[test]
fn boot_fleet_and_await_rolls_up_an_invalid_spec_as_error_not_timeout() {
// An invalid spec is rejected at validate() inside boot_and_await, before any
// backend/await — so every member is an Error (hard misconfig), never a
// Timeout, and the backend is never touched. Distinguishes the two failure
// buckets: Spec → Error, Backend → Timeout.
let backend = FleetNode::with_dead(std::iter::empty());
let mut bad = BootSpec::container("bad", "redis:7");
bad.image = ImageSource::Iso("/boot.iso".into()); // ISO can't boot a container
let rollup = boot_fleet_and_await(&bad, 2, &backend, &tiny_bounded());
assert_eq!(rollup.members.len(), 2);
assert!(
rollup
.members
.iter()
.all(|m| matches!(m.outcome, MemberOutcome::Error(_))),
"an invalid spec rolls up as Error on every member, not Timeout"
);
assert_eq!(rollup.ready(), 0);
assert_eq!(rollup.failed(), 2);
}
#[test]
fn boot_fleet_and_await_of_zero_is_an_empty_not_ready_rollup() {
// An empty fleet: no members, and all_ready is false (vacuously "nothing up"
// is not a ready fleet — parity with plan_fleet(0) being empty).
let backend = FleetNode::with_dead(std::iter::empty());
let rollup = boot_fleet_and_await(
&BootSpec::container("c", "redis:7"),
0,
&backend,
&tiny_bounded(),
);
assert!(rollup.members.is_empty());
assert_eq!(rollup.ready(), 0);
assert_eq!(rollup.failed(), 0);
assert!(!rollup.all_ready(), "an empty fleet is not all-ready");
}
// -- Parallel fleet-boot: identical rollup + proven overlap ------------------
/// A `Sync` fleet backend that produces a *mixed* rollup with no live instances:
/// members named in `unbootable` fail at [`Boot::boot`] (`Err(Unsupported)` →
/// `Error`), members named in `dead` boot but never power on (`status` stays `Off`
/// → `Timeout`), everyone else comes `Up`. `Sync` (only owned `HashSet`s), so it
/// drives BOTH the serial and the scoped-thread parallel path — letting the two be
/// asserted byte-for-byte equal.
struct MixedNode {
dead: std::collections::HashSet<String>,
unbootable: std::collections::HashSet<String>,
}
impl Boot for MixedNode {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
if self.unbootable.contains(&spec.name) {
return Err(Error::Unsupported(format!(
"no backend slot for {}",
spec.name
)));
}
Ok(Machine::started(format!("id-{}", spec.name), spec))
}
}
impl Lifecycle for MixedNode {
fn power_on(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn power_off(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn status(&self, m: &Machine) -> Result<PowerState> {
if self.dead.contains(&m.spec_name) {
Ok(PowerState::Off)
} else {
Ok(PowerState::On)
}
}
}
#[test]
fn parallel_fleet_boot_is_identical_to_serial_for_a_mixed_fleet() {
// The equivalence guard: a mixed fleet (some Up, one Timeout, one Error) MUST
// roll up to the SAME FleetReadback — same per-member outcomes, same fleet
// order, same aggregate tallies — whether booted serially or in parallel. The
// parallel path only changes WHEN members are awaited, never WHAT the rollup
// says.
//
// RED-when-broken: the two share the one `classify_member` step, so any
// divergence (a reordered join, a different bucket, a dropped member) breaks
// this `assert_eq!`. Fleet of 5: node-3 dead (→ Timeout), node-5 unbootable
// (→ Error), node-1/2/4 Up.
let backend = MixedNode {
dead: std::collections::HashSet::from(["node-3".to_string()]),
unbootable: std::collections::HashSet::from(["node-5".to_string()]),
};
let spec = BootSpec::container("node", "redis:7");
let serial = boot_fleet_and_await(&spec, 5, &backend, &tiny_bounded());
let parallel = boot_fleet_and_await_parallel(&spec, 5, &backend, &tiny_bounded());
// Byte-for-byte identical rollups (names, order, outcomes, and the carried
// Machine handles / failure strings all compared by derived PartialEq).
assert_eq!(
parallel, serial,
"parallel rollup must equal the serial rollup"
);
// And it is genuinely the mixed shape we intended (not two identical *empty*
// rollups trivially matching).
assert_eq!(parallel.members.len(), 5);
assert!(
matches!(parallel.members[0].outcome, MemberOutcome::Up(_)),
"node-1 up"
);
assert!(
matches!(parallel.members[2].outcome, MemberOutcome::Timeout(_)),
"node-3 timeout"
);
assert!(
matches!(parallel.members[4].outcome, MemberOutcome::Error(_)),
"node-5 error"
);
assert_eq!(parallel.ready(), 3);
assert_eq!(parallel.failed(), 2);
}
/// A `Sync` backend that proves the members are awaited **concurrently**: every
/// member, on its first `status` poll, bumps a shared "in flight" counter, records
/// the running peak, then waits (bounded) until all `n` members have entered before
/// returning `On`. In the parallel path all `n` threads enter together so the peak
/// reaches `n`; a serial path would only ever have one member in flight (peak 1),
/// timing out the entry wait instead of hanging.
struct BarrierNode {
n: usize,
in_flight: std::sync::atomic::AtomicUsize,
peak: std::sync::atomic::AtomicUsize,
}
impl BarrierNode {
fn new(n: usize) -> Self {
Self {
n,
in_flight: std::sync::atomic::AtomicUsize::new(0),
peak: std::sync::atomic::AtomicUsize::new(0),
}
}
}
impl Boot for BarrierNode {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
Ok(Machine::started(format!("id-{}", spec.name), spec))
}
}
impl Lifecycle for BarrierNode {
fn power_on(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn power_off(&self, _m: &Machine) -> Result<()> {
Ok(())
}
fn status(&self, _m: &Machine) -> Result<PowerState> {
use std::sync::atomic::Ordering::SeqCst;
let now = self.in_flight.fetch_add(1, SeqCst) + 1;
self.peak.fetch_max(now, SeqCst);
The file goes on; the console shows all of it.