src/kvm.rs
//! **KVM backend** — fire up an appliance VM by driving **tunnr**.
//!
//! Draupnir does not reimplement VM boot. tunnr owns the QEMU + OVMF (UEFI) + KVM
//! launch and exposes a self-contained, kill-able primitive —
//! `tunnr_vm::boot_test(BootSpec) -> BootHandle`. This backend is the thin adapter
//! that maps a Draupnir [`BootSpec`] onto tunnr's `BootSpec`, calls `boot_test`,
//! and adapts the returned `BootHandle` into a [`Machine`] / [`Lifecycle`].
//!
//! The trait wiring compiles unconditionally; the live tunnr call is behind the
//! `backend-tunnr` feature (an optional path dep on `tunnr-vm`, exactly how `jera`
//! wires it — switch to a version once tunnr publishes to crates.io). Without the
//! feature every entry point returns an honest [`Error::Unsupported`] rather than
//! pretending to boot.
//!
//! **Live handles.** [`Boot::boot`] returns a plain-data [`Machine`] (an id + power
//! state), but [`Lifecycle`] needs tunnr's `BootHandle` to kill/poll the exact QEMU
//! process group. The backend therefore keeps a small registry
//! (`machine id → BootHandle`) so `power_off`/`status` address the VM that `boot`
//! actually launched.
#[cfg(feature = "backend-tunnr")]
use crate::ImageSource;
use crate::{Boot, BootSpec, CloudInit, Error, Lifecycle, Machine, PowerState, Result, Seen};
#[cfg(feature = "backend-tunnr")]
use std::collections::HashMap;
#[cfg(feature = "backend-tunnr")]
use std::sync::{Arc, Mutex};
/// The registry of live tunnr boot handles, keyed by [`Machine::id`].
#[cfg(feature = "backend-tunnr")]
type LiveHandles = Arc<Mutex<HashMap<String, tunnr_vm::BootHandle>>>;
/// The KVM boot backend (drives tunnr).
#[derive(Default, Clone)]
pub struct KvmBoot {
/// Live tunnr handles for the VMs this backend booted, so [`Lifecycle`] can
/// kill/poll the exact QEMU process group. `BootHandle` is a cheap `Arc` clone.
#[cfg(feature = "backend-tunnr")]
live: LiveHandles,
}
impl std::fmt::Debug for KvmBoot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KvmBoot").finish_non_exhaustive()
}
}
impl KvmBoot {
/// Construct the KVM backend.
pub fn new() -> Self {
Self::default()
}
/// **Pure** render of the cloud-init NoCloud seed file set —
/// `[("user-data", …), ("meta-data", …)]` (plus `network-config` when set).
/// A thin re-export of the owned builder [`crate::seed::seed_files`] so the
/// seed lives in exactly one place (draupnir's [`crate::seed`] module); kept
/// here for the KVM backend's existing call sites.
pub fn nocloud_seed_files(ci: &CloudInit) -> Vec<(&'static str, String)> {
crate::seed::seed_files(ci)
}
/// The **serial console** captured so far for a live KVM guest — read off the
/// tunnr `BootHandle` in the registry (`tunnr_vm::BootHandle::boot_log`). This is
/// the KVM-console surface a consumer (jera → dwarves) streams to make the boot's
/// console **visible in the forge**: draupnir's [`Lifecycle`] exposes power state,
/// but the boot handle it keeps alive also holds the live `-serial mon:stdio`
/// capture, so a caller that has the [`Machine`] can read the console without
/// re-implementing any capture. `None` when no live VM matches `machine` (or the
/// `backend-tunnr` feature is off — no tunnr handle to read).
#[cfg(feature = "backend-tunnr")]
pub fn serial_log(&self, machine: &Machine) -> Option<String> {
self.live
.lock()
.unwrap()
.get(&machine.id)
.map(|h| h.boot_log())
}
/// Without `backend-tunnr` there is no live tunnr handle, so no console to read.
#[cfg(not(feature = "backend-tunnr"))]
pub fn serial_log(&self, _machine: &Machine) -> Option<String> {
None
}
/// **Dump a live guest's framebuffer to a PPM** — the evidence channel of a
/// machine that has no serial port.
///
/// The sibling of [`serial_log`](KvmBoot::serial_log): one reads the wire, this
/// one reads the screen, and a [`crate::GuestShape::BiosFramebuffer`] guest has
/// only the second. What lands in `dest` is exactly the pixels a human would
/// read off a provider's VNC console — where, on such a machine, a PID-1 panic
/// and an installer's refusal are the ONLY places anything is ever written.
///
/// `Err` by name, never an empty file: the guest was not booted by this backend
/// (`no-live-vm`), it has no display adapter (`no-framebuffer`, i.e. the
/// `UefiSerial` shape), or the monitor socket would not take the command.
#[cfg(feature = "backend-tunnr")]
pub fn screendump(&self, machine: &Machine, dest: &std::path::Path) -> Result<()> {
let g = self.live.lock().unwrap();
let h = g.get(&machine.id).ok_or_else(|| {
Error::Backend(format!(
"REFUSED [no-live-vm] no live tunnr VM for {} — nothing to screendump",
machine.id
))
})?;
h.screendump(dest)
.map_err(|r| Error::Backend(format!("{r} (machine {})", machine.id)))
}
/// Without `backend-tunnr` there is no live guest, so there are no pixels.
#[cfg(not(feature = "backend-tunnr"))]
pub fn screendump(&self, machine: &Machine, _dest: &std::path::Path) -> Result<()> {
Err(Error::Backend(format!(
"REFUSED [no-live-vm] kvm backend needs the `backend-tunnr` feature (machine {})",
machine.id
)))
}
/// **Did this guest reach its success marker?** — tunnr's own verdict
/// (`BootStatus::BootedOk`: the [`BootSpec::success_marker`] streamed past on the
/// serial console), read off the live handle. [`Lifecycle::status`] folds that
/// into [`PowerState::On`] — a booted guest is a powered guest — so a consumer
/// that needs the *boot* verdict (jera's `BootStatus::BootedOk`, a pane's
/// "PASSED") reads it here. `false` when the marker has not been seen, the VM is
/// not one this backend booted, or `backend-tunnr` is off.
#[cfg(feature = "backend-tunnr")]
pub fn booted_ok(&self, machine: &Machine) -> bool {
self.live
.lock()
.unwrap()
.get(&machine.id)
.map(|h| matches!(h.poll_status(), tunnr_vm::BootStatus::BootedOk))
.unwrap_or(false)
}
/// Without `backend-tunnr` nothing boots, so nothing booted OK.
#[cfg(not(feature = "backend-tunnr"))]
pub fn booted_ok(&self, _machine: &Machine) -> bool {
false
}
/// **Why a powered-off guest is off**, in tunnr's words: `Some("boot timed out")`
/// for the wall-clock backstop, `Some("boot killed")` for a deliberate teardown,
/// `Some(reason)` for a failed boot — `None` while the guest is up (or booted OK),
/// or when no live handle matches. [`Lifecycle::status`] collapses all three into
/// [`PowerState::Off`]; this keeps the distinction a console reader wants to show.
#[cfg(feature = "backend-tunnr")]
pub fn exit_reason(&self, machine: &Machine) -> Option<String> {
let guard = self.live.lock().unwrap();
let handle = guard.get(&machine.id)?;
match handle.poll_status() {
tunnr_vm::BootStatus::Failed(e) => Some(e),
tunnr_vm::BootStatus::Killed => Some("boot killed".to_string()),
tunnr_vm::BootStatus::TimedOut => Some("boot timed out".to_string()),
tunnr_vm::BootStatus::Booting | tunnr_vm::BootStatus::BootedOk => None,
// A guest with no marker channel is not off and has not failed — it
// simply cannot be judged from the wire. Saying so is the whole
// point of the state; folding it into `None` (all fine) or into a
// failure string would be inventing a verdict.
tunnr_vm::BootStatus::Unobservable(why) => Some(format!("unobservable: {why}")),
}
}
/// Without `backend-tunnr` there is no live handle to ask.
#[cfg(not(feature = "backend-tunnr"))]
pub fn exit_reason(&self, _machine: &Machine) -> Option<String> {
None
}
/// **Stop the guest and let it FLUSH** — tunnr's graceful `shutdown(grace)`
/// (monitor power-down → ACPI → SIGKILL after `grace`), as opposed to
/// [`Lifecycle::power_off`]'s immediate SIGKILL.
///
/// The difference is load-bearing for an install: QEMU's raw drive is
/// `cache=writeback`, so guest writes sit in QEMU's memory and a SIGKILL throws
/// them away — measured 2026-08-16 (gunnar): INSTALL-OK, payload streamed onto
/// p2 with one blake3, SIGKILL 4 s later, and the second boot hashed the same
/// bytes to a different blake3. The handle is released afterwards like
/// `power_off`; the guest is always gone when this returns.
#[cfg(feature = "backend-tunnr")]
pub fn shutdown(&self, machine: &Machine, grace: std::time::Duration) -> Result<()> {
let handle = self
.live
.lock()
.unwrap()
.remove(&machine.id)
.ok_or_else(|| Error::Backend(format!("no live tunnr VM for {}", machine.id)))?;
let _ = handle.shutdown(grace);
Ok(())
}
/// Without `backend-tunnr` there is nothing to shut down.
#[cfg(not(feature = "backend-tunnr"))]
pub fn shutdown(&self, _machine: &Machine, _grace: std::time::Duration) -> Result<()> {
Err(Error::Unsupported(
"kvm backend needs the `backend-tunnr` feature".into(),
))
}
/// Map a Draupnir [`BootSpec`] onto tunnr's `tunnr_vm::BootSpec`.
///
/// Field-for-field this is the seam `jera::vm` already wires. Only compiled
/// with `backend-tunnr` (the type comes from the optional `tunnr-vm` dep).
#[cfg(feature = "backend-tunnr")]
pub fn to_tunnr_spec(&self, spec: &BootSpec) -> Result<tunnr_vm::BootSpec> {
self.to_tunnr_spec_in(spec, &crate::Firmware::search_dirs())
}
/// [`to_tunnr_spec`](KvmBoot::to_tunnr_spec) against an explicit OVMF search
/// path. The in-crate seam the firmware proofs drive, so "the secboot pair
/// reaches the pflash units" is provable against a KNOWN directory rather than
/// against whatever the build host has in `/usr/share/OVMF`.
#[cfg(feature = "backend-tunnr")]
pub(crate) fn to_tunnr_spec_in(
&self,
spec: &BootSpec,
ovmf_dirs: &[std::path::PathBuf],
) -> Result<tunnr_vm::BootSpec> {
use std::path::PathBuf;
use std::time::Duration;
let (kernel, rootfs_or_disk, base_snapshot) = match &spec.image {
ImageSource::KernelRootfs { kernel, rootfs } => (kernel.clone(), rootfs.clone(), false),
ImageSource::Disk { kernel, disk } => {
// tunnr attaches a non-cpio image as a virtio -drive; a kernel is
// still required for the direct-kernel boot path and now travels
// in the spec (validated non-empty by BootSpec::validate).
(kernel.clone(), disk.clone(), false)
}
ImageSource::BootloaderDisk { disk } => {
// A self-booting disk: hand tunnr an EMPTY kernel path so its
// `is_direct_kernel_boot()` is false and OVMF firmware-boots the
// disk directly (no `-kernel`), exactly as real hardware would.
// COW-protect the base: a matrix boot of a shared, content-addressed
// base OS disk must never mutate it (tunnr opens it snapshot=on).
(String::new(), disk.clone(), true)
}
// A LIVE boot off the medium: the ISO is the whole payload, there is no
// target disk. Empty kernel → tunnr firmware-boots it, and tunnr's own
// `.iso` arm renders it `-cdrom <iso> -boot d`. `base_snapshot` is
// meaningless for a read-only CD, so it stays false.
ImageSource::Iso(iso) => (String::new(), iso.clone(), false),
other => return Err(Error::Spec(format!("kvm backend cannot boot {other:?}"))),
};
// Fail by name before anything is launched: a medium that is not on disk is
// a build that did not happen, not a boot that did not come up.
Self::check_medium_present(spec)?;
let mut boot =
tunnr_vm::BootSpec::smoke(PathBuf::from(kernel), PathBuf::from(rootfs_or_disk));
// COW-protection is the default, but it is not always right: a caller that
// booted its OWN per-run disk copy in order to boot it a SECOND time needs the
// writes to survive. `persist_disk` is that opt-out — it can only ever turn
// COW off, never on, so an image source that already persists is unaffected.
boot.base_snapshot = base_snapshot && !spec.persist_disk;
boot.mem_mb = spec.mem_mb;
boot.cores = spec.cores;
// **The machine shape.** This line used to read `boot.headless = true;`,
// which was not a choice at all: it produced `-nographic`, and tunnr on
// its side pushed `-serial mon:stdio` and the OVMF pflash pair
// unconditionally. Every KVM proof draupnir has ever driven therefore ran
// on ONE machine — UEFI, serial console, no framebuffer — while a
// console-only cloud VM (UpCloud) is the other one: SeaBIOS, no serial
// port, VNC. An image that boots here and panics there is the defect this
// field exists to make visible.
match spec.guest_shape {
crate::GuestShape::UefiSerial => {
// `Firmware::Bios` (0.2.0, the qemu backend's SeaBIOS) on the
// UEFI shape is two answers to one question. tunnr would boot the
// UEFI the shape names and the BIOS the firmware names would be
// dropped in silence — so name the contradiction instead.
if let crate::Firmware::Bios { .. } = spec.firmware {
return Err(Error::Spec(
"REFUSED [bios-on-uefi-shape] firmware `bios` on a UefiSerial guest: \
the tunnr KVM backend spells a BIOS machine as \
`GuestShape::BiosFramebuffer` (SeaBIOS, no serial port, a VGA \
framebuffer) — set `with_guest_shape(GuestShape::BiosFramebuffer)`, or \
drop the BIOS firmware to boot UEFI."
.into(),
));
}
boot = boot.with_shape(tunnr_vm::GuestShape::UefiSerial);
}
crate::GuestShape::BiosFramebuffer => {
// Refuse, never substitute. A marker is detected on the serial
// capture and nowhere else; on a machine with no serial port it
// could never appear, so a spec that asks for both is a spec
// whose verdict could only ever be invented.
if let Some(marker) = &spec.success_marker {
return Err(Error::Spec(format!(
"REFUSED [no-serial-console] success_marker {marker:?} on a \
BiosFramebuffer guest: that machine has no serial port, so the marker \
could never be observed and `booted_ok` could only ever be a guess. \
Drop the marker and read the framebuffer \
(`KvmBoot::screendump`), or boot the UefiSerial shape."
)));
}
// A UEFI key store on a BIOS machine is not "ignored", it is a
// Secure-Boot run that stopped enforcing anything. `HostDefault`
// names nothing and `Bios` is this very machine; every OVMF choice
// (plain UEFI included) contradicts the shape.
let rom = match &spec.firmware {
crate::Firmware::HostDefault => None,
crate::Firmware::Bios { rom } => rom.as_ref().map(PathBuf::from),
other => {
return Err(Error::Spec(format!(
"REFUSED [secure-boot-on-bios] firmware `{}` on a BiosFramebuffer \
guest: a BIOS machine has no UEFI variable store, so the pair would \
simply not be used — and a firmware run whose firmware was dropped \
proves nothing.",
other.label()
)));
}
};
boot = boot.with_shape(tunnr_vm::GuestShape::BiosFramebuffer);
// A named SeaBIOS blob travels to tunnr's own `-bios` seam; tunnr's
// `check_shape` refuses it BY NAME if it is not on this host rather
// than quietly falling back to the built-in one.
boot.firmware = tunnr_vm::GuestFirmware::Bios { rom };
}
}
// The caller's own budget first; then let a slow appliance boot be tuned
// without a rebuild (parity with jera) — the operator override wins.
if let Some(budget) = spec.boot_timeout {
boot.timeout = budget;
}
if let Some(secs) = std::env::var("DRAUPNIR_VM_BOOT_TIMEOUT")
.ok()
.and_then(|s| s.parse().ok())
{
boot.timeout = Duration::from_secs(secs);
}
if !spec.cmdline.is_empty() {
boot.kernel_cmdline = spec.cmdline.clone();
}
// The caller's own "booted OK" line, so tunnr's status (and `booted_ok`)
// flips on THIS guest's marker rather than the stock one.
if let Some(marker) = &spec.success_marker {
boot.success_marker = marker.clone();
}
// cloud-init NoCloud: author the seed image and attach it through tunnr's
// native `with_seed` seam (its `seed` field), so tunnr owns the exact
// read-only virtio-disk argv — no hand-rolled `extra_qemu_args` drift.
// The guest's cloud-init finds it by its `cidata` vfat label at first boot.
if let Some(ci) = &spec.cloud_init {
let seed = Self::build_seed_image(&Self::seed_path(&spec.name), ci)?;
boot = boot.with_seed(seed);
}
// Airgap for the KVM path: a `NetMode::None` spec cuts the guest off from all
// egress. tunnr expresses this as `NetConfig::None` (`-nic none`); every other
// net mode leaves tunnr's default user-mode virtio-net (byte-identical). This
// is the KVM twin of the container backend's `--network none` — the one wire a
// real airgap (egress-isolation) VM test asserts against.
if spec.net == crate::NetMode::None {
boot.net = tunnr_vm::NetConfig::None;
} else if !spec.ports.is_empty() || !spec.port_maps.is_empty() {
// **The host→guest forwards.** tunnr's `NetConfig::User { hostfwd }`
// takes `(host_port, guest_port)` pairs and renders each as
// `hostfwd=tcp::<host>-:<guest>` on the user-mode netdev, which is
// field-for-field what [`crate::PortMap`] already spells. The two
// published forms fold into the one pair list exactly as the
// container backend folds them (`ports` is the `host == container`
// special case — see `PortMap::same`), so a caller writing
// `.with_port_map(2222, 22)` gets the same wire either way and the
// KVM and container backends do not disagree about what a published
// port means.
//
// `NetMode::None` is handled above and never reaches here; the pair
// is refused by `BootSpec::validate` rather than resolved in silence.
// An empty forward list cannot reach this arm at all, so a spec that
// names no port still yields tunnr's own default and the argv stays
// byte-identical to one written before this existed.
let hostfwd: Vec<(u16, u16)> = spec
.ports
.iter()
.map(|&p| (p, p))
.chain(spec.port_maps.iter().map(|pm| (pm.host, pm.container)))
.collect();
boot.net = tunnr_vm::NetConfig::User { hostfwd };
}
// Host-specific extra QEMU devices tunnr's spec does not model natively (the
// Skidbladnir report / guest-agent virtio-serial ports, a data drive, a
// `-snapshot` base-image guard, `-cpu host`) ride through verbatim. Empty
// leaves the argv byte-identical to a spec that never named them.
// The removable medium + boot order, then the caller's own extras LAST so a
// caller can still override (QEMU takes the last `-boot`). Empty when the
// spec names no medium and no boot order → byte-identical argv to before.
boot.extra_qemu_args = Self::medium_qemu_args(spec);
boot.extra_qemu_args.extend(spec.extra_qemu_args.iter().cloned());
// The UEFI firmware: resolve the caller's CODE/VARS pair on THIS host and
// hand it to tunnr's own firmware seam (`with_secure_boot_firmware`), so
// tunnr keeps owning the pflash argv and the per-guest writable VARS copy —
// no hand-rolled `-drive if=pflash` here. `Firmware::HostDefault` resolves to
// None and sets nothing: tunnr's stock discovery still picks the pair and the
// argv is byte-identical to a spec written before the field existed.
//
// A named-but-absent firmware fails HERE, by name, before a guest is
// launched — the same "a build that did not happen is not a boot that did not
// come up" rule `check_medium_present` enforces for the ISO. Substituting the
// stock, non-enforcing pair for a Secure Boot run would turn a refusal that
// proves something into a green that proves nothing.
if let Some((code, vars_template)) = spec.firmware.resolve_in(ovmf_dirs)? {
boot = boot.with_secure_boot_firmware(code, vars_template);
}
Ok(boot)
}
/// The temp path the NoCloud seed image for `name` is authored at.
#[cfg(feature = "backend-tunnr")]
fn seed_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("draupnir-{name}-seed.img"))
}
/// **Wait for a NAMED LINE on the guest's serial console, or a deadline — never
/// for the VM to exit.**
///
/// The KVM half of the one stage-2 observation shape ([`Seen`]); its sibling is
/// `exe::ExeBoot::await_marker`, and both return the same type for the same
/// reason. An appliance that boots correctly **serves forever** — waiting for
/// QEMU to exit is waiting for the appliance to fail. So this polls the captured
/// console (`tunnr_vm::BootHandle::boot_log`, read through
/// [`serial_log`](KvmBoot::serial_log)) for `marker`, and reports what it saw:
/// the marker with the console line it was on, the VM having died first, or the
/// budget elapsing with the guest still up.
///
/// All three are `Ok` — a deadline is an observation, not an error. `Err` is
/// reserved for "this backend never booted that machine".
///
/// Give the boot a real budget: `DRAUPNIR_VM_BOOT_TIMEOUT` (seconds) raises
/// tunnr's own SIGKILL backstop, which is a *separate* clock. A budget longer
/// than that backstop can only ever observe [`Seen::Exited`].
pub fn await_serial_marker(
&self,
machine: &Machine,
marker: &str,
budget: std::time::Duration,
poll: std::time::Duration,
) -> Result<Seen> {
// **A wait that could never succeed is a refusal, not a deadline.** On a
// GuestShape::BiosFramebuffer guest there is no serial port at all, so
// this loop would poll an empty string for the whole budget and then
// report `Seen::Deadline` — "the marker did not appear", which reads as
// evidence about the GUEST when it is only a fact about the WIRE. Say so
// by name instead, and point at the channel that does carry something.
#[cfg(feature = "backend-tunnr")]
{
let unobservable = self
.live
.lock()
.unwrap()
.get(&machine.id)
.and_then(|h| match h.poll_status() {
tunnr_vm::BootStatus::Unobservable(why) => Some(why),
_ => None,
});
if let Some(why) = unobservable {
return Err(Error::Backend(format!(
"REFUSED [no-serial-console] cannot wait for {marker:?} on {}: {why}",
machine.id
)));
}
}
let started = std::time::Instant::now();
let deadline = started + budget;
loop {
let console = self.serial_log(machine).ok_or_else(|| {
Error::Backend(format!("no live KVM guest for {}", machine.id))
})?;
if let Some(line) = console.lines().find(|l| l.contains(marker)) {
let seen = Seen::Marker {
line: line.to_string(),
after: started.elapsed(),
};
self.record_seen(machine, &seen, true);
return Ok(seen);
}
// Powered Off means the QEMU process is gone (failed / killed / timed
// out) — the guest cannot print the marker any more, so stop waiting for
// something that can never arrive and report the console instead.
if self.status(machine)? == PowerState::Off {
let seen = Seen::Exited {
code: None,
tail: console,
after: started.elapsed(),
};
self.record_seen(machine, &seen, false);
return Ok(seen);
}
let now = std::time::Instant::now();
if now >= deadline {
let seen = Seen::StillRunning {
tail: console,
waited: started.elapsed(),
};
self.record_seen(machine, &seen, false);
return Ok(seen);
}
std::thread::sleep(poll.min(deadline.saturating_duration_since(now)));
}
}
/// Record a console observation as a functional-status row, so a marker that
/// never appeared is visible in nornir's matrix rather than swallowed.
fn record_seen(&self, machine: &Machine, seen: &Seen, ok: bool) {
crate::functional_status(
"draupnir/kvm",
"await_serial_marker",
ok,
&format!("`{}`: {}", machine.id, seen.detail()),
);
}
/// **The removable-medium QEMU argv** — the ONE place a draupnir
/// [`BootSpec`]'s [`medium`](BootSpec::medium) / [`boot_order`](crate::BootOrder)
/// become QEMU flags. Pure (no I/O, no feature gate) so it is unit-testable with
/// no KVM and no tunnr, exactly like tunnr's own `build_qemu_args`.
///
/// Emits, in order:
/// * `-cdrom <iso>` when the spec carries an explicit
/// [`medium`](BootSpec::medium) — an ISO *alongside* the payload disk, which is
/// the install shape. Deliberately **not** emitted when the medium comes from
/// an [`ImageSource::Iso`] payload: tunnr already renders that as its own
/// `-cdrom` (`tunnr/src/boot_primitive.rs:758-765`), and a second `-cdrom` is a
/// duplicate-drive error, not a merge. One writer per flag.
/// * `-boot d|c` for [`BootOrder::Medium`](crate::BootOrder::Medium) /
/// [`Disk`](crate::BootOrder::Disk). Safe to repeat: QEMU takes the last
/// `-boot`, and for an ISO payload the value tunnr emits (`d`) is the value we
/// emit, so the two agree by construction rather than by coincidence.
///
/// [`BootOrder::Auto`](crate::BootOrder::Auto) with no medium returns an **empty**
/// vector — which is what makes every spec written before this existed produce a
/// byte-identical argv.
pub fn medium_qemu_args(spec: &BootSpec) -> Vec<String> {
let mut args = Vec::new();
if let Some(iso) = &spec.medium {
// The firmware's face: El Torito off an IDE cdrom.
if spec.medium_attach.has_cdrom() {
args.push("-cdrom".into());
args.push(iso.clone());
}
// The system's face: the SAME bytes as a read-only virtio block
// device, for a PID 1 whose kernel has no ATA and therefore cannot
// see the cdrom the firmware just booted it from. Read-only because
// a medium is, and because it keeps the installer's disk-picker from
// ever choosing it as a target. Byte-for-byte the attachment
// `korp_installer::isoproof::attach_iso_as_virtio` already proves
// (LAW 5: the shape is copied from the one that works, not invented).
if spec.medium_attach.has_virtio() {
args.push("-drive".into());
args.push(format!("file={iso},if=virtio,format=raw,readonly=on"));
}
}
if let Some(order) = spec.boot_order.qemu_value() {
args.push("-boot".into());
args.push(order.into());
}
args
}
/// **Fail BY NAME when the medium is not there.** A `-cdrom` pointing at a path
/// that does not exist makes QEMU die with its own message deep inside a boot,
/// which reads as "the appliance did not come up" rather than "you named an ISO
/// that was never built" — the two failures a chain matrix must never confuse.
///
/// Checks whatever [`BootSpec::medium_path`] resolves to (explicit medium or an
/// [`ImageSource::Iso`] payload), so both spellings of "boot off the medium" are
/// covered by one check. A spec with no medium is trivially `Ok` and untouched.
/// Pure of feature gates so it is provable without KVM.
pub fn check_medium_present(spec: &BootSpec) -> Result<()> {
let Some(medium) = spec.medium_path() else {
return Ok(());
};
if std::path::Path::new(medium).is_file() {
Ok(())
} else {
Err(Error::Spec(format!(
"boot medium `{medium}` does not exist (spec `{}`): the ISO must be on \
disk before a machine can be booted off it",
spec.name
)))
}
}
/// Materialize a [`CloudInit`] into a **NoCloud seed image** at `out` — a thin
/// call onto the owned builder [`crate::seed::build_seed_image`] (vfat, volume
/// label `cidata`, pure-Rust `fatfs`, no `genisoimage`/`mkisofs`). The seed
/// authoring lives in exactly one place ([`crate::seed`]); this is the KVM
/// backend's call site.
#[cfg(feature = "backend-tunnr")]
pub fn build_seed_image(out: &std::path::Path, ci: &CloudInit) -> Result<std::path::PathBuf> {
crate::seed::build_seed_image(out, ci)
}
}
impl Boot for KvmBoot {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
spec.validate()?;
#[cfg(feature = "backend-tunnr")]
{
let tunnr_spec = self.to_tunnr_spec(spec)?;
let handle = tunnr_vm::boot_test(tunnr_spec)
.map_err(|e| Error::Backend(format!("tunnr boot_test: {e}")))?;
let id = handle.id().to_string();
let machine = Machine::started(&id, spec);
self.live.lock().unwrap().insert(id, handle);
Ok(machine)
}
#[cfg(not(feature = "backend-tunnr"))]
{
let _ = spec;
Err(Error::Unsupported(
"kvm backend needs the `backend-tunnr` feature (drives tunnr's KVM boot primitive)"
.into(),
))
}
}
}
impl Lifecycle for KvmBoot {
fn power_on(&self, machine: &Machine) -> Result<()> {
let _ = machine;
// tunnr's primitive is fire-and-forget (a booted VM has no re-power line);
// the honest answer is "boot a fresh instance", not a fake success.
Err(Error::Unsupported(
"kvm/tunnr VMs are fire-and-forget: re-power by calling draupnir::boot() again".into(),
))
}
fn power_off(&self, machine: &Machine) -> Result<()> {
#[cfg(feature = "backend-tunnr")]
{
let handle = self
.live
.lock()
.unwrap()
.remove(&machine.id)
.ok_or_else(|| Error::Backend(format!("no live tunnr VM for {}", machine.id)))?;
handle.kill();
Ok(())
}
#[cfg(not(feature = "backend-tunnr"))]
{
let _ = machine;
Err(Error::Unsupported(
"kvm backend needs the `backend-tunnr` feature".into(),
))
}
}
fn status(&self, machine: &Machine) -> Result<PowerState> {
#[cfg(feature = "backend-tunnr")]
{
let guard = self.live.lock().unwrap();
let Some(handle) = guard.get(&machine.id) else {
return Ok(PowerState::Unknown);
};
Ok(map_status(handle.poll_status()))
}
#[cfg(not(feature = "backend-tunnr"))]
{
let _ = machine;
Err(Error::Unsupported(
"kvm backend needs the `backend-tunnr` feature".into(),
))
}
}
}
/// Map a tunnr `BootStatus` onto draupnir's [`PowerState`]: still booting or booted
/// OK ⇒ powered `On`; any terminal (failed/killed/timed-out) ⇒ `Off`.
#[cfg(feature = "backend-tunnr")]
fn map_status(s: tunnr_vm::BootStatus) -> PowerState {
match s {
// `Unobservable` is a guest we cannot JUDGE, not a guest that is off:
// QEMU is running and the machine is powered. The verdict is missing,
// the power state is not.
tunnr_vm::BootStatus::Booting
| tunnr_vm::BootStatus::BootedOk
| tunnr_vm::BootStatus::Unobservable(_) => PowerState::On,
tunnr_vm::BootStatus::Failed(_)
| tunnr_vm::BootStatus::Killed
| tunnr_vm::BootStatus::TimedOut => PowerState::Off,
}
}
#[cfg(test)]
mod seed_tests {
use super::*;
#[test]
fn nocloud_seed_files_render_user_data_and_a_default_meta_data() {
let ci = CloudInit::user_data("#cloud-config\nruncmd:\n - [echo, hi]\n");
let files = KvmBoot::nocloud_seed_files(&ci);
assert_eq!(files[0].0, "user-data");
assert_eq!(files[0].1, "#cloud-config\nruncmd:\n - [echo, hi]\n");
assert_eq!(files[1].0, "meta-data");
// A default meta-data is supplied when the spec omits it.
assert!(
files[1].1.contains("instance-id"),
"default meta-data has an instance-id"
);
assert!(files[1].1.contains("local-hostname"));
}
#[test]
fn nocloud_seed_files_pass_through_an_explicit_meta_data() {
let ci = CloudInit {
user_data: "#cloud-config\n".into(),
meta_data: Some("instance-id: node-7\n".into()),
network_config: None,
};
let files = KvmBoot::nocloud_seed_files(&ci);
assert_eq!(files[1].1, "instance-id: node-7\n");
}
#[test]
fn nocloud_seed_files_omit_network_config_by_default_and_emit_it_when_set() {
// No network-config → just the two required files.
let plain = CloudInit::user_data("#cloud-config\n");
let files = KvmBoot::nocloud_seed_files(&plain);
assert_eq!(files.len(), 2);
assert!(!files.iter().any(|(n, _)| *n == "network-config"));
// With one → a third `network-config` file carrying the document.
let net = CloudInit::user_data("#cloud-config\n")
.with_network_config("version: 2\nethernets:\n eth0:\n dhcp4: true\n");
let files = KvmBoot::nocloud_seed_files(&net);
assert_eq!(files.len(), 3);
let nc = files
.iter()
.find(|(n, _)| *n == "network-config")
.expect("network-config present");
assert!(nc.1.contains("dhcp4: true"));
}
}
/// The boot-off-a-medium argv seam. Pure — no tunnr, no KVM, no feature — so the
/// flags a medium turns into are pinned on every build, not only the one that can
/// launch a VM.
#[cfg(test)]
mod medium_tests {
use super::*;
use crate::BootOrder;
#[test]
fn a_spec_with_no_medium_and_no_boot_order_emits_nothing() {
// The additive-parity guard, at the argv. Empty here is what makes every
// pre-existing launch byte-identical.
// RED-when-broken: make `medium_qemu_args` always push "-boot" and this fails.
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"),
] {
assert!(
KvmBoot::medium_qemu_args(&spec).is_empty(),
"{} adds no argv",
spec.name
);
}
}
#[test]
fn the_install_leg_renders_cdrom_plus_boot_d() {
let spec = BootSpec::kvm_install_from_medium("app", "/images/g.iso", "/target.qcow2");
assert_eq!(
KvmBoot::medium_qemu_args(&spec),
vec!["-cdrom", "/images/g.iso", "-boot", "d"],
"boot OFF the medium with the target disk still attached"
);
}
#[test]
fn the_boot_installed_leg_renders_boot_c_and_no_cdrom() {
let spec = BootSpec::kvm_boot_installed_disk("app", "/target.qcow2");
let argv = KvmBoot::medium_qemu_args(&spec);
assert_eq!(argv, vec!["-boot", "c"]);
assert!(
!argv.iter().any(|a| a == "-cdrom"),
"the medium is absent, not merely deprioritised: {argv:?}"
);
}
#[test]
fn an_iso_payload_does_not_get_a_second_cdrom() {
// tunnr already renders an `.iso` payload as its own `-cdrom` + `-boot d`
// (tunnr/src/boot_primitive.rs:758-765). A second `-cdrom` is a QEMU
// duplicate-drive error, so this path must emit only the boot order.
// RED-when-broken: render from `medium_path()` instead of the explicit
// `medium` field and this grows a duplicate `-cdrom`.
let spec = BootSpec::iso_boot("live", "/images/g.iso");
assert_eq!(spec.medium_path(), Some("/images/g.iso"), "it IS a medium boot");
assert_eq!(
KvmBoot::medium_qemu_args(&spec),
vec!["-boot", "d"],
"no second -cdrom for an ISO payload"
);
}
#[test]
fn a_medium_that_is_not_on_disk_fails_by_name() {
// "you named an ISO that was never built" must not read as "the appliance
// did not come up". RED-when-broken: delete `check_medium_present`'s
// is_file() test and this returns Ok.
let ghost = BootSpec::kvm_install_from_medium(
"app",
"/images/never-built.iso",
"/target.qcow2",
);
let err = KvmBoot::check_medium_present(&ghost).unwrap_err();
let msg = format!("{err}");
assert!(matches!(err, Error::Spec(_)));
assert!(msg.contains("/images/never-built.iso"), "names the ISO: {msg}");
assert!(msg.contains("does not exist"), "says what is wrong: {msg}");
assert!(msg.contains("app"), "names the spec: {msg}");
// A real file passes, and a spec with no medium is trivially fine.
let real = std::env::current_exe().expect("the test binary is a real file");
let ok = BootSpec::kvm_install_from_medium(
"app",
real.to_string_lossy(),
"/target.qcow2",
);
KvmBoot::check_medium_present(&ok).unwrap();
KvmBoot::check_medium_present(&BootSpec::kvm_bootloader_disk("n", "/d.qcow2")).unwrap();
}
#[test]
fn a_directory_is_not_a_medium() {
// `is_file`, not `exists`: a path that happens to be a directory would pass
// an `exists()` check and then fail inside QEMU.
let spec = BootSpec::kvm_install_from_medium("app", "/tmp", "/target.qcow2");
assert!(KvmBoot::check_medium_present(&spec).is_err());
}
#[test]
fn boot_order_maps_to_both_backends_from_one_field() {
assert_eq!(BootOrder::Auto.qemu_value(), None);
assert_eq!(BootOrder::Medium.qemu_value(), Some("d"));
assert_eq!(BootOrder::Disk.qemu_value(), Some("c"));
assert_eq!(BootOrder::Auto.redfish_target(), None);
assert_eq!(BootOrder::Medium.redfish_target(), Some(crate::BootTarget::Cd));
assert_eq!(BootOrder::Disk.redfish_target(), Some(crate::BootTarget::Hdd));
}
}
#[cfg(all(test, feature = "backend-tunnr"))]
mod tests {
use super::*;
use crate::ImageSource;
/// Author the NoCloud seed image, then read it back through `fatfs` and confirm
/// the two files + their contents survive and the volume label is `cidata`
/// (proves the guest's cloud-init will find `user-data`/`meta-data`).
#[test]
fn build_seed_image_writes_a_cidata_fat_with_both_files() {
let out =
std::env::temp_dir().join(format!("draupnir-seedtest-{}.img", std::process::id()));
let _ = std::fs::remove_file(&out);
let ci = CloudInit::user_data("#cloud-config\nruncmd:\n - [echo, hi]\n");
let path = KvmBoot::build_seed_image(&out, &ci).unwrap();
assert_eq!(path, out);
let img = std::fs::File::options()
.read(true)
.write(true)
.open(&out)
.unwrap();
let fs = fatfs::FileSystem::new(img, fatfs::FsOptions::new()).unwrap();
assert_eq!(fs.volume_label().to_ascii_lowercase(), "cidata");
let mut names: Vec<String> = fs
.root_dir()
.iter()
.map(|e| e.unwrap().file_name())
.collect();
names.sort();
assert!(
names.iter().any(|n| n == "user-data"),
"user-data present, got {names:?}"
);
assert!(
names.iter().any(|n| n == "meta-data"),
"meta-data present, got {names:?}"
);
// Content survives the round-trip.
use std::io::Read;
let mut buf = String::new();
fs.root_dir()
.open_file("user-data")
.unwrap()
.read_to_string(&mut buf)
.unwrap();
assert!(
buf.contains("runcmd"),
"user-data content survives: {buf:?}"
);
let _ = std::fs::remove_file(&out);
}
/// A spec carrying cloud-init makes `to_tunnr_spec` attach the seed image
/// through tunnr's native `seed` field (`with_seed`); a spec without it leaves
/// the field `None` and adds no seed `extra_qemu_args`. This pins the
/// seed-drift fix: the seed rides tunnr's owned seam, not hand-rolled argv.
#[test]
fn to_tunnr_spec_attaches_the_seed_drive_only_when_cloud_init_is_present() {
let plain = BootSpec::kvm_kernel_rootfs("plain", "/bzImage", "/rootfs.cpio.gz");
let t = KvmBoot::new().to_tunnr_spec(&plain).unwrap();
assert!(t.seed.is_none(), "no seed without cloud-init");
assert!(
!t.extra_qemu_args.iter().any(|a| a.contains("seed.img")),
"no hand-rolled seed argv"
);
let seeded = BootSpec::kvm_kernel_rootfs("seeded", "/bzImage", "/rootfs.cpio.gz")
.with_cloud_init(CloudInit::user_data("#cloud-config\n"));
let t = KvmBoot::new().to_tunnr_spec(&seeded).unwrap();
// The seed rides tunnr's native `seed` field, not `extra_qemu_args`.
let seed = t
.seed
.as_ref()
.expect("seed image attached via tunnr's with_seed");
assert!(
seed.to_string_lossy().contains("draupnir-seeded-seed.img"),
"the authored seed path is carried: {seed:?}"
);
assert!(
!t.extra_qemu_args.iter().any(|a| a.contains("seed.img")),
"seed no longer hand-rolled into extra_qemu_args"
);
let _ = std::fs::remove_file(KvmBoot::seed_path("seeded"));
}
/// A bootloader-in-disk spec validates (no kernel required) and maps to a
/// tunnr spec with an EMPTY kernel — tunnr's firmware-boot path — and the
/// disk as `rootfs_or_disk`.
#[test]
fn kvm_spec_maps_a_bootloader_disk_onto_an_empty_kernel_tunnr_spec() {
let spec = BootSpec::kvm_bootloader_disk("selfboot", "/disk.qcow2");
spec.validate()
.expect("a bootloader-in-disk spec validates without a kernel");
let t = KvmBoot::new().to_tunnr_spec(&spec).unwrap();
assert!(
t.kernel.as_os_str().is_empty(),
"firmware boot: empty kernel, got {:?}",
t.kernel
);
assert!(
!t.is_direct_kernel_boot(),
"empty kernel => tunnr firmware-boots the disk"
);
assert_eq!(t.rootfs_or_disk, std::path::PathBuf::from("/disk.qcow2"));
assert!(
t.base_snapshot,
"a bootloader-in-disk boot COW-protects its (shared) base disk (snapshot=on)"
);
}
/// RED-when-broken — `persist_disk` is the opt-out that makes **two boots of one
/// machine** expressible.
///
/// Without it the second boot of an installed disk silently gets a pristine image,
/// re-runs first-boot provisioning, and reports plausible 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 to the base). The assertion that matters is
/// that `persist_disk` actually reaches tunnr's drive option, not merely that the
/// field exists.
#[test]
fn persist_disk_turns_the_cow_guard_off_for_a_disk_the_caller_owns() {
let mut spec = BootSpec::kvm_bootloader_disk("selfboot", "/per-run-copy.qcow2");
assert!(!spec.persist_disk, "COW protection stays the DEFAULT");
assert!(
KvmBoot::new().to_tunnr_spec(&spec).unwrap().base_snapshot,
"default: writes are thrown away"
);
spec.persist_disk = true;
assert!(
!KvmBoot::new().to_tunnr_spec(&spec).unwrap().base_snapshot,
"persist_disk: the caller owns this disk, so its writes must SURVIVE the boot"
);
}
/// The opt-out may only ever turn COW **off**, never on: an image source that
/// already persists must not become COW-protected by anyone setting the flag.
#[test]
fn persist_disk_never_turns_cow_protection_on() {
for persist in [false, true] {
let mut spec = BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz");
spec.persist_disk = persist;
assert!(
!KvmBoot::new().to_tunnr_spec(&spec).unwrap().base_snapshot,
"kernel+rootfs never COW-protects, persist_disk={persist}"
);
}
}
#[test]
fn kvm_spec_maps_kernel_rootfs_mem_and_cores_onto_tunnr() {
let mut spec = BootSpec::kvm_kernel_rootfs("appliance", "/bzImage", "/rootfs.cpio.gz");
spec.mem_mb = 1024;
spec.cores = 4;
spec.cmdline = "korp.smoke=1".into();
let t = KvmBoot::new().to_tunnr_spec(&spec).unwrap();
assert_eq!(t.kernel, std::path::PathBuf::from("/bzImage"));
assert_eq!(
t.rootfs_or_disk,
std::path::PathBuf::from("/rootfs.cpio.gz")
);
assert_eq!(t.mem_mb, 1024);
assert_eq!(t.cores, 4);
assert_eq!(t.display, tunnr_vm::GuestDisplay::None);
assert_eq!(t.console, tunnr_vm::GuestConsole::Stdio);
assert_eq!(t.firmware, tunnr_vm::GuestFirmware::Uefi);
assert_eq!(t.kernel_cmdline, "korp.smoke=1");
}
#[test]
fn kvm_spec_maps_a_disk_boot_with_its_kernel_onto_tunnr() {
// Regression: a Disk boot must carry a real `-kernel` onto tunnr, not an
// empty string (the old cmdline_kernel() stub returned "").
let spec = BootSpec::kvm_disk("disky", "/bzImage", "/disk.qcow2");
let t = KvmBoot::new().to_tunnr_spec(&spec).unwrap();
assert_eq!(t.kernel, std::path::PathBuf::from("/bzImage"));
assert_eq!(t.rootfs_or_disk, std::path::PathBuf::from("/disk.qcow2"));
assert!(
!t.kernel.as_os_str().is_empty(),
"disk boot kernel must be non-empty"
);
}
/// The medium must reach tunnr's actual argv, not merely exist as a field.
#[test]
fn the_install_leg_reaches_tunnrs_argv_with_the_target_disk_and_the_cdrom() {
let iso = write_probe_iso("install-leg");
let spec = BootSpec::kvm_install_from_medium(
"app",
iso.to_string_lossy(),
"/target.qcow2",
);
let t = KvmBoot::new().to_tunnr_spec(&spec).unwrap();
// The PAYLOAD is the target disk (firmware boot, no -kernel)...
assert!(t.kernel.as_os_str().is_empty(), "firmware boot");
assert_eq!(t.rootfs_or_disk, std::path::PathBuf::from("/target.qcow2"));
// ...and the medium rides as -cdrom + -boot d.
assert_eq!(
t.extra_qemu_args,
vec![
"-cdrom".to_string(),
iso.to_string_lossy().into_owned(),
"-boot".to_string(),
"d".to_string()
]
);
// The install's writes must SURVIVE, or nothing was installed.
assert!(!t.base_snapshot, "persist_disk turns the COW guard off");
let _ = std::fs::remove_file(&iso);
}
#[test]
fn the_boot_installed_leg_reaches_tunnrs_argv_with_boot_c_and_no_cdrom() {
let spec = BootSpec::kvm_boot_installed_disk("app", "/target.qcow2");
let t = KvmBoot::new().to_tunnr_spec(&spec).unwrap();
assert_eq!(t.extra_qemu_args, vec!["-boot".to_string(), "c".to_string()]);
assert!(!t.base_snapshot, "the installed disk persists");
}
#[test]
fn a_callers_own_extra_qemu_args_still_ride_and_ride_last() {
// The pre-existing seam is kept whole: the caller's args are appended AFTER
// the medium's, so a caller can still override (QEMU takes the last -boot).
let spec = BootSpec::kvm_boot_installed_disk("app", "/target.qcow2")
.with_extra_qemu_args(["-cpu", "host"]);
let t = KvmBoot::new().to_tunnr_spec(&spec).unwrap();
assert_eq!(
t.extra_qemu_args,
vec!["-boot", "c", "-cpu", "host"]
.into_iter()
.map(String::from)
.collect::<Vec<_>>()
);
}
#[test]
fn an_iso_payload_maps_onto_tunnrs_own_firmware_cdrom_path() {
// tunnr renders a `.iso` payload itself; draupnir must hand it over as the
// payload and add only the boot order.
let iso = write_probe_iso("payload");
let spec = BootSpec::iso_boot("live", iso.to_string_lossy());
let t = KvmBoot::new().to_tunnr_spec(&spec).unwrap();
assert!(t.kernel.as_os_str().is_empty(), "firmware boots the ISO");
assert_eq!(t.rootfs_or_disk, iso);
assert_eq!(t.extra_qemu_args, vec!["-boot".to_string(), "d".to_string()]);
// ...and tunnr's own pure argv builder turns that into the real flags.
let argv = tunnr_vm::boot_primitive::build_qemu_args(
&t,
&tunnr_vm::StagedFirmware::Uefi {
code: "/c.fd".into(),
vars: "/v.fd".into(),
},
None,
);
let cdroms = argv.iter().filter(|a| *a == "-cdrom").count();
assert_eq!(cdroms, 1, "exactly one -cdrom, never two: {argv:?}");
let _ = std::fs::remove_file(&iso);
}
/// **The firmware choice survives the draupnir→tunnr seam and reaches the
/// final argv** — the pflash pair, not a field that was merely set.
///
/// The twin of `an_iso_payload_maps_onto_tunnrs_own_firmware_cdrom_path`: map
/// the spec, then run tunnr's OWN staging + pure argv builder over it and read
/// the two `-drive if=pflash` units back.
#[test]
fn a_secure_boot_choice_reaches_the_pflash_units_in_the_final_argv() {
let fw = tempfile::tempdir().unwrap();
for f in [
"OVMF_CODE_4M.fd",
"OVMF_VARS_4M.fd",
"OVMF_CODE_4M.secboot.fd",
"OVMF_VARS_4M.ms.fd",
] {
std::fs::write(fw.path().join(f), b"fw").unwrap();
}
let dirs = [fw.path().to_path_buf()];
let spec = BootSpec::kvm_kernel_rootfs("sb", "/vmlinuz", "/rootfs.cpio.gz")
.with_firmware(crate::Firmware::SecureBoot);
let t = KvmBoot::new().to_tunnr_spec_in(&spec, &dirs).unwrap();
assert_eq!(t.ovmf_code.as_deref(), Some(fw.path().join("OVMF_CODE_4M.secboot.fd").as_path()));
assert_eq!(
t.ovmf_vars_template.as_deref(),
Some(fw.path().join("OVMF_VARS_4M.ms.fd").as_path())
);
// tunnr's own staging: resolve the pair and COPY the vars template into
// this guest's private workdir (a shared writable vars file across two VMs
// is corruption waiting to happen).
let work = tempfile::tempdir().unwrap();
let staged = tunnr_vm::boot_primitive::stage_firmware(&t, work.path()).unwrap();
let tunnr_vm::StagedFirmware::Uefi { code, vars } = staged.clone() else {
panic!("a UEFI spec must stage a UEFI firmware, got {staged:?}");
};
assert_eq!(code, fw.path().join("OVMF_CODE_4M.secboot.fd"));
assert_eq!(vars, work.path().join("OVMF_VARS.fd"), "per-guest vars COPY");
assert_ne!(vars, fw.path().join("OVMF_VARS_4M.ms.fd"), "never the template itself");
let argv = tunnr_vm::boot_primitive::build_qemu_args(&t, &staged, None);
assert!(
argv.contains(&format!(
"if=pflash,format=raw,readonly=on,file={}",
fw.path().join("OVMF_CODE_4M.secboot.fd").display()
)),
"the ENFORCING code image is the read-only pflash: {argv:?}"
);
assert!(
argv.contains(&format!("if=pflash,format=raw,file={}", vars.display())),
"the per-guest copy of the ms vars template is the writable pflash: {argv:?}"
);
assert!(
!argv.iter().any(|a| a.contains("OVMF_VARS_4M.fd")),
"the stock, empty vars template must never ride the enforcing code: {argv:?}"
);
}
/// A firmware this host has not got is refused BY NAME before the guest is
/// launched — never swapped for whatever OVMF happens to be installed.
#[test]
fn to_tunnr_spec_refuses_a_firmware_this_host_has_not_got() {
let fw = tempfile::tempdir().unwrap();
// Stock only — the enforcing build is absent.
std::fs::write(fw.path().join("OVMF_CODE_4M.fd"), b"fw").unwrap();
std::fs::write(fw.path().join("OVMF_VARS_4M.fd"), b"fw").unwrap();
let dirs = [fw.path().to_path_buf()];
let spec = BootSpec::kvm_kernel_rootfs("sb", "/vmlinuz", "/rootfs.cpio.gz")
.with_firmware(crate::Firmware::SecureBoot);
let err = KvmBoot::new().to_tunnr_spec_in(&spec, &dirs).unwrap_err().to_string();
assert!(err.contains("OVMF_CODE_4M.secboot.fd"), "names the file: {err}");
assert!(err.contains(&fw.path().display().to_string()), "names where it looked: {err}");
}
/// The default names nothing: tunnr's stock discovery still owns the pair, so
/// the argv is byte-identical to a spec written before the field existed.
#[test]
fn an_unset_firmware_leaves_tunnrs_own_discovery_untouched() {
let spec = BootSpec::kvm_kernel_rootfs("plain", "/vmlinuz", "/rootfs.cpio.gz");
let t = KvmBoot::new().to_tunnr_spec(&spec).unwrap();
assert_eq!(t.ovmf_code, None);
assert_eq!(t.ovmf_vars_template, None);
}
#[test]
fn to_tunnr_spec_refuses_a_medium_that_is_not_on_disk() {
// The check runs BEFORE any launch, so a missing ISO is a spec fault here
// rather than a mystery boot failure later.
let spec = BootSpec::kvm_install_from_medium("app", "/no/such.iso", "/target.qcow2");
let err = KvmBoot::new().to_tunnr_spec(&spec).unwrap_err();
assert!(format!("{err}").contains("/no/such.iso"), "{err}");
}
/// The caller's OWN marker + budget reach tunnr's spec (the two fields a pane
/// had to reach past draupnir for), and an unset pair leaves tunnr's stock
/// values — byte-identical to every spec written before the fields existed.
#[test]
fn success_marker_and_boot_timeout_reach_tunnrs_spec_and_unset_leaves_the_defaults() {
let stock = tunnr_vm::BootSpec::smoke(std::path::PathBuf::new(), std::path::PathBuf::new());
let plain = BootSpec::kvm_kernel_rootfs("plain", "/bzImage", "/rootfs.cpio.gz");
let t = KvmBoot::new().to_tunnr_spec(&plain).unwrap();
assert_eq!(t.success_marker, stock.success_marker, "unset marker = tunnr's stock marker");
assert_eq!(t.timeout, stock.timeout, "unset budget = tunnr's default");
let named = BootSpec::kvm_kernel_rootfs("named", "/bzImage", "/rootfs.cpio.gz")
.with_success_marker("INSTALLER: INSTALL-OK")
.with_boot_timeout(std::time::Duration::from_secs(300));
let t = KvmBoot::new().to_tunnr_spec(&named).unwrap();
assert_eq!(t.success_marker, "INSTALLER: INSTALL-OK");
assert_eq!(t.timeout, std::time::Duration::from_secs(300));
}
/// **The default shape still produces the machine every earlier boot used.**
/// If this goes red, the new field changed an existing caller's guest.
#[test]
fn the_default_guest_shape_is_the_uefi_serial_machine() {
let spec = BootSpec::kvm_kernel_rootfs("d", "/vmlinuz", "/rootfs.cpio.gz");
assert_eq!(spec.guest_shape, crate::GuestShape::UefiSerial);
let t = KvmBoot::new().to_tunnr_spec(&spec).unwrap();
assert_eq!(t.firmware, tunnr_vm::GuestFirmware::Uefi);
assert_eq!(t.console, tunnr_vm::GuestConsole::Stdio);
assert_eq!(t.display, tunnr_vm::GuestDisplay::None);
}
/// **The console-only cloud machine reaches the final argv**: no pflash, no
/// serial port, a real VGA. Asserted on tunnr's OWN argv writer, not on
/// fields that were merely set.
#[test]
fn the_bios_framebuffer_shape_reaches_the_final_argv() {
let spec = BootSpec::kvm_kernel_rootfs("d", "/vmlinuz", "/rootfs.cpio.gz")
.with_guest_shape(crate::GuestShape::BiosFramebuffer);
let t = KvmBoot::new().to_tunnr_spec(&spec).unwrap();
assert!(matches!(
t.firmware,
tunnr_vm::GuestFirmware::Bios { rom: None }
));
let argv = tunnr_vm::boot_primitive::build_qemu_args(
&t,
&tunnr_vm::StagedFirmware::Bios { rom: None },
None,
);
assert_eq!(argv.iter().filter(|a| a.contains("if=pflash")).count(), 0);
let after = |flag: &str| {
argv.iter()
.position(|a| a == flag)
.and_then(|i| argv.get(i + 1))
.cloned()
};
assert_eq!(after("-serial"), Some("none".to_string()), "{argv:?}");
assert_eq!(after("-vga"), Some("std".to_string()), "{argv:?}");
assert!(!argv.contains(&"-nographic".to_string()), "{argv:?}");
}
/// **REFUSED [no-serial-console]** — a success marker on a machine with no
/// serial port. Refused at the draupnir→tunnr seam, before a guest exists,
/// and NOT quietly downgraded to a boot whose verdict would be invented.
#[test]
fn a_marker_on_the_console_less_shape_is_refused_by_name() {
let spec = BootSpec::kvm_kernel_rootfs("d", "/vmlinuz", "/rootfs.cpio.gz")
.with_guest_shape(crate::GuestShape::BiosFramebuffer)
.with_success_marker("KORP-APPLIANCE BOOT-OK");
let e = KvmBoot::new()
.to_tunnr_spec(&spec)
.expect_err("a marker that can never arrive must be refused");
let msg = e.to_string();
assert!(msg.contains("REFUSED [no-serial-console]"), "{msg}");
assert!(msg.contains("KORP-APPLIANCE BOOT-OK"), "{msg}");
}
/// **REFUSED [secure-boot-on-bios]** — an enforcing UEFI pair named on a
/// BIOS machine. Dropping it would turn a run that must enforce signatures
/// into one that checks nothing and still comes back green.
#[test]
fn secure_boot_firmware_on_the_bios_shape_is_refused_by_name() {
let spec = BootSpec::kvm_kernel_rootfs("d", "/vmlinuz", "/rootfs.cpio.gz")
.with_guest_shape(crate::GuestShape::BiosFramebuffer)
.with_firmware(crate::Firmware::SecureBoot);
let e = KvmBoot::new()
.to_tunnr_spec(&spec)
.expect_err("a UEFI key store on a BIOS machine must be refused");
assert!(
e.to_string().contains("REFUSED [secure-boot-on-bios]"),
"{e}"
);
}
/// **0.2.0's `Firmware::Bios` IS the BiosFramebuffer machine's firmware**, so
/// the two travel together and a named SeaBIOS blob reaches tunnr's own
/// `GuestFirmware::Bios { rom }` instead of being refused as if it were a
/// UEFI key store.
#[test]
fn bios_firmware_rides_the_bios_framebuffer_shape_with_its_rom() {
let spec = BootSpec::kvm_kernel_rootfs("d", "/vmlinuz", "/rootfs.cpio.gz")
.with_guest_shape(crate::GuestShape::BiosFramebuffer)
.with_firmware(crate::Firmware::Bios {
rom: Some("/usr/share/seabios/bios-256k.bin".into()),
});
let t = KvmBoot::new().to_tunnr_spec(&spec).unwrap();
assert_eq!(
t.firmware,
tunnr_vm::GuestFirmware::Bios {
rom: Some("/usr/share/seabios/bios-256k.bin".into())
}
);
assert_eq!(t.console, tunnr_vm::GuestConsole::None);
assert_eq!(t.display, tunnr_vm::GuestDisplay::Framebuffer);
assert!(t.ovmf_code.is_none() && t.ovmf_vars_template.is_none());
}
/// **REFUSED [bios-on-uefi-shape]** — a BIOS firmware on the default UEFI
/// shape. tunnr would boot the UEFI the shape names and drop the BIOS in
/// silence, so the contradiction is named instead.
#[test]
fn bios_firmware_on_the_uefi_serial_shape_is_refused_by_name() {
let spec = BootSpec::kvm_kernel_rootfs("d", "/vmlinuz", "/rootfs.cpio.gz")
.with_firmware(crate::Firmware::Bios { rom: None });
let e = KvmBoot::new()
.to_tunnr_spec(&spec)
.expect_err("a BIOS firmware on a UEFI machine must be refused");
assert!(
e.to_string().contains("REFUSED [bios-on-uefi-shape]"),
"{e}"
);
}
/// A shape named on a backend that has no machine is refused by
/// [`BootSpec::validate`] rather than silently dropped — the same rule the
/// `firmware` field already carries.
#[test]
fn a_guest_shape_on_a_container_is_refused_by_name() {
let spec = BootSpec::container("c", "docker.io/library/alpine:3.20")
.with_guest_shape(crate::GuestShape::BiosFramebuffer);
let e = spec.validate().expect_err("a container has no machine shape");
assert!(e.to_string().contains("REFUSED [shape-not-supported]"), "{e}");
}
/// `screendump` on a machine this backend never booted is a named error, not
/// an empty file a report could quote as evidence.
#[test]
fn a_screendump_of_an_unknown_machine_is_refused_by_name() {
let backend = KvmBoot::new();
let m = Machine::started(
"never-booted",
&BootSpec::kvm_kernel_rootfs("nb", "/vmlinuz", "/rootfs.cpio.gz"),
);
let e = backend
.screendump(&m, std::path::Path::new("/tmp/nope.ppm"))
.expect_err("no live VM → no pixels");
assert!(e.to_string().contains("REFUSED [no-live-vm]"), "{e}");
}
/// `booted_ok` / `exit_reason` / `shutdown` are honest about a machine this
/// backend never booted: no false "booted", no invented reason, a named error.
#[test]
fn boot_verdict_readers_are_honest_about_an_unknown_machine() {
let kvm = KvmBoot::new();
let spec = BootSpec::kvm_kernel_rootfs("ghost", "/bzImage", "/rootfs.cpio.gz");
let ghost = Machine::started("ghost-1", &spec);
assert!(!kvm.booted_ok(&ghost), "a VM that was never booted did not boot OK");
assert_eq!(kvm.exit_reason(&ghost), None);
let err = kvm.shutdown(&ghost, std::time::Duration::from_secs(1)).unwrap_err();
assert!(format!("{err}").contains("ghost-1"), "{err}");
}
/// A throwaway file to stand in for an ISO where only its EXISTENCE is under
/// test (the argv shape), not its contents. The real boot proof uses a real ISO.
fn write_probe_iso(tag: &str) -> std::path::PathBuf {
let p = std::env::temp_dir().join(format!(
"draupnir-probe-{tag}-{}.iso",
std::process::id()
));
std::fs::write(&p, b"not really an iso").unwrap();
p
}
#[test]
fn kvm_spec_rejects_a_non_kvm_image() {
let mut spec = BootSpec::kvm_kernel_rootfs("bad", "/k", "/r");
spec.image = ImageSource::OciImage("redis:7".into());
assert!(matches!(
KvmBoot::new().to_tunnr_spec(&spec),
Err(Error::Spec(_))
));
}
#[test]
fn serial_log_of_an_unknown_machine_is_none() {
// No live boot for this id → no console (the guard the jera adapter relies on
// to return an empty drain rather than panic).
let kvm = KvmBoot::new();
let m = Machine {
id: "boot-not-live".into(),
spec_name: "x".into(),
kind: crate::Kind::Vm,
power: PowerState::Unknown,
};
assert!(kvm.serial_log(&m).is_none(), "no live handle → no console");
}
#[test]
fn status_of_an_unknown_machine_is_unknown() {
let kvm = KvmBoot::new();
let m = Machine {
id: "boot-does-not-exist".into(),
spec_name: "x".into(),
kind: crate::Kind::Vm,
power: PowerState::Unknown,
};
assert_eq!(kvm.status(&m).unwrap(), PowerState::Unknown);
}
#[test]
fn map_status_folds_live_states_to_on_and_every_terminal_to_off() {
// The two live states are `On`; every terminal (failed for any reason,
// killed, timed-out) collapses to `Off` — a booted VM that later died is
// powered off, not still on.
assert_eq!(map_status(tunnr_vm::BootStatus::Booting), PowerState::On);
assert_eq!(map_status(tunnr_vm::BootStatus::BootedOk), PowerState::On);
assert_eq!(
map_status(tunnr_vm::BootStatus::Failed("qemu exited 1".into())),
PowerState::Off
);
assert_eq!(map_status(tunnr_vm::BootStatus::Killed), PowerState::Off);
assert_eq!(map_status(tunnr_vm::BootStatus::TimedOut), PowerState::Off);
}
// ── host→guest forwards on the KVM path ──────────────────────────────────
//
// The seam that made gunnar's install arm bypass this crate. Every one of
// these asserts the FINAL ARGV (tunnr's own `build_qemu_args`), not that a
// field was set: a forward that reaches `NetConfig` and not the netdev is
// exactly the silent drop the arm's own header warned about.
/// The `-netdev` line tunnr renders for a spec, or `None` when the spec
/// asked for `-nic none`. Shared by the forward tests so they cannot
/// disagree about where to look (LAW 5).
#[cfg(feature = "backend-tunnr")]
fn netdev_of(spec: &BootSpec) -> Option<String> {
let dirs = [std::path::PathBuf::from("/nonexistent-ovmf")];
let t = KvmBoot::new().to_tunnr_spec_in(spec, &dirs).unwrap();
let argv = tunnr_vm::boot_primitive::build_qemu_args(
&t,
&tunnr_vm::StagedFirmware::Uefi {
code: "/code.fd".into(),
vars: "/vars.fd".into(),
},
None,
);
let i = argv.iter().position(|a| a == "-netdev")?;
argv.get(i + 1).cloned()
}
/// **A `PortMap` reaches QEMU's `hostfwd`.** `PortMap { host, container }`
/// is `(host_port, guest_port)`, so 2222→22 must render `tcp::2222-:22` and
/// never the transpose — a reversed pair binds the wrong host port and the
/// test that uses it hangs rather than fails.
#[test]
#[cfg(feature = "backend-tunnr")]
fn a_port_map_reaches_the_qemu_hostfwd_in_the_final_argv() {
let spec = BootSpec::kvm_kernel_rootfs("fwd", "/vmlinuz", "/rootfs.cpio.gz")
.with_port_map(2222, 22)
.with_port_map(15051, 50051);
let netdev = netdev_of(&spec).expect("a forwarding guest has a user netdev");
assert!(netdev.contains("hostfwd=tcp::2222-:22"), "host 2222 → guest 22: {netdev}");
assert!(
netdev.contains("hostfwd=tcp::15051-:50051"),
"host 15051 → guest 50051: {netdev}"
);
assert!(
!netdev.contains("tcp::22-:2222"),
"the pair must not be transposed: {netdev}"
);
}
/// The bare `ports` form is the `host == guest` special case — the same
/// folding the container backend does, so the two backends cannot disagree
/// about what a published port means.
#[test]
#[cfg(feature = "backend-tunnr")]
fn the_bare_ports_form_forwards_a_host_port_to_the_same_guest_port() {
let spec = BootSpec::kvm_kernel_rootfs("fwd", "/vmlinuz", "/rootfs.cpio.gz").with_port(50051);
let netdev = netdev_of(&spec).unwrap();
assert!(netdev.contains("hostfwd=tcp::50051-:50051"), "{netdev}");
}
/// **Additive parity.** A KVM spec that forwards nothing must render the
/// netdev it rendered before this seam existed — the guard that keeps a new
/// field from moving a byte for every existing caller.
#[test]
#[cfg(feature = "backend-tunnr")]
fn a_kvm_boot_that_forwards_no_port_leaves_the_netdev_untouched() {
let spec = BootSpec::kvm_kernel_rootfs("plain", "/vmlinuz", "/rootfs.cpio.gz");
assert_eq!(netdev_of(&spec).as_deref(), Some("user,id=n0"));
}
/// The airgap still wins where it is asked for alone: `NetMode::None` is
/// `-nic none`, and a spec that names no port never reaches the forward arm.
#[test]
#[cfg(feature = "backend-tunnr")]
fn an_airgapped_kvm_boot_still_gets_no_nic_at_all() {
let spec = BootSpec::kvm_kernel_rootfs("dark", "/vmlinuz", "/rootfs.cpio.gz")
.with_net(crate::NetMode::None);
assert_eq!(netdev_of(&spec), None, "`-nic none` renders no -netdev");
}
// ── the medium's second face ─────────────────────────────────────────────
/// **A medium can ride BOTH trays.** The firmware boots El Torito off the
/// cdrom; the appliance's PID 1 reads the same bytes over virtio because its
/// kernel has no ATA. Rendering only the cdrom is what made an install
/// through this backend die with `INSTALLER: volume: no block device carries
/// the ISO-9660 magic (CD001)` on 2026-08-15.
#[test]
fn a_medium_can_be_attached_on_both_the_cdrom_and_virtio() {
let spec = BootSpec::kvm_disk("inst", "/bzImage", "/target.raw")
.with_medium("/boot.iso")
.with_medium_attach(crate::MediumAttach::Both);
let args = KvmBoot::medium_qemu_args(&spec);
let i = args.iter().position(|a| a == "-cdrom").expect("the firmware's face");
assert_eq!(args[i + 1], "/boot.iso");
assert!(
args.iter()
.any(|a| a == "file=/boot.iso,if=virtio,format=raw,readonly=on"),
"the system's face, read-only so the installer's disk-picker cannot \
choose it as a target: {args:?}"
);
}
/// The virtio face alone, for a guest that boots something else entirely.
#[test]
fn the_virtio_only_attachment_emits_no_cdrom() {
let spec = BootSpec::kvm_disk("inst", "/bzImage", "/target.raw")
.with_medium("/boot.iso")
.with_medium_attach(crate::MediumAttach::Virtio);
let args = KvmBoot::medium_qemu_args(&spec);
assert!(!args.iter().any(|a| a == "-cdrom"), "no IDE tray: {args:?}");
assert!(
args.iter().any(|a| a.starts_with("file=/boot.iso,if=virtio")),
"{args:?}"
);
}
/// **Additive parity.** A spec that never names an attachment renders the
/// cdrom and nothing else — byte-identical to every spec written before
/// `MediumAttach` existed.
#[test]
fn a_medium_with_no_attachment_named_renders_the_cdrom_alone() {
let spec = BootSpec::kvm_disk("inst", "/bzImage", "/target.raw").with_medium("/boot.iso");
let args = KvmBoot::medium_qemu_args(&spec);
assert_eq!(args, vec!["-cdrom".to_string(), "/boot.iso".to_string()]);
assert!(crate::MediumAttach::default().is_default());
}
}