crates/jera/src/vm.rs
//! **No-shell KVM/VM boot** seam — map a [`BootSpec`] onto **draupnir**'s KVM
//! backend and drive its lifecycle as a job.
//!
//! A [`BootSpec`] is a self-contained boot recipe (kernel/rootfs/disk, machine
//! size, kernel cmdline). The [`VmBootRunner`] seam turns it into a live
//! [`BootProc`]:
//!
//! - [`StubVmBootRunner`] — the default when draupnir's KVM backend is not compiled
//! in: it maps the spec but reports a clear, honest failure rather than
//! pretending to boot.
//! - [`DraupnirVmBootRunner`] (feature `vm-tunnr`) — the REAL boot: hands the spec
//! to `draupnir::kvm::KvmBoot` (which drives tunnr's
//! `tunnr_vm::boot_test(BootSpec) -> BootHandle` KVM primitive) and adapts the
//! returned [`draupnir::Machine`] + [`draupnir::Lifecycle`] into a [`BootProc`],
//! so the job lifecycle drives the guest (job-kill → `Lifecycle::power_off`;
//! poll → `Lifecycle::status`).
//!
//! **One KVM boot path.** jera does **not** call tunnr directly — instance-firing
//! lives in exactly one place, draupnir. jera keeps only the job policy: turn a VM
//! spec into a tracked boot job. This is the "repoint jera's VM seam at draupnir"
//! step of the boot-library extraction (see draupnir's `design.md`).
//!
//! **Zero-shell**: draupnir drives the guest over tunnr's Rust API — never a
//! `qemu-system-*` subprocess spawned from jera.
use crate::boot::BootProc;
#[cfg(feature = "vm-tunnr")]
use crate::boot::BootStatus;
/// **The UEFI firmware choice**, re-exported from draupnir so a consumer picks a
/// firmware without depending on the boot library directly — the same way it
/// already reaches draupnir's KVM backend only through [`VmBootRunner`].
///
/// It is draupnir's own type, not a jera twin: a second enum here would be a
/// second mapping, and the one that mattered would be the one nobody tested.
/// draupnir resolves the CODE/VARS pair against THIS host and refuses an absent
/// firmware by name; jera only carries the choice.
pub use draupnir::Firmware;
/// nornir/jera's mapping of a VM build onto the boot recipe — the shape handed to
/// draupnir's KVM backend.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BootSpec {
/// The VM handle / guest name.
pub name: String,
/// Kernel image path (`-kernel`).
pub kernel: String,
/// Rootfs/initramfs path (`-initrd`), or empty for a disk boot.
pub rootfs: String,
/// Disk image path (`-drive`), or empty for a kernel+rootfs boot.
pub disk: String,
/// Guest memory (MiB).
pub mem_mb: u32,
/// Guest vCPU count.
pub cores: u32,
/// Kernel command line (`-append`).
pub cmdline: String,
/// Boot flavor (`qemu` / `microvm` / `appliance`).
pub format: String,
/// The materialized QEMU argv (the fallback boot recipe when draupnir/tunnr is absent).
pub qemu_args: Vec<String>,
/// A bootable **medium** (ISO). With an empty `kernel`: alone it is a LIVE boot
/// off the medium (`-cdrom … -boot d`, no target disk); together with `disk` it
/// is the INSTALL shape (the ISO installs onto the blank `disk`, whose writes
/// persist). Empty (the default) keeps the kernel/rootfs/disk shapes exactly as
/// they were.
pub iso: String,
/// Host-specific QEMU devices the spec does not model natively (a scratch
/// virtio disk, `-device virtio-gpu-pci`, a `-qmp` control socket, an ssh
/// hostfwd netdev), appended verbatim after tunnr's own argv — draupnir's
/// `BootSpec::extra_qemu_args`. Empty leaves the argv byte-identical.
pub extra_qemu_args: Vec<String>,
/// The console line that means "this guest booted OK" (draupnir's
/// `success_marker`): the runner's [`BootProc::poll`] reports
/// [`BootStatus::BootedOk`] once it streams past. Empty (the default) keeps
/// tunnr's stock marker.
pub success_marker: String,
/// The wall-clock budget for the boot in seconds (draupnir's `boot_timeout`,
/// tunnr's SIGKILL backstop). `0` (the default) keeps tunnr's default (60 s).
pub timeout_secs: u64,
/// Cut the guest off from every network (`-nic none`; draupnir's
/// `NetMode::None`) — either the airgap shape, or because the caller authors
/// its own netdev in `extra_qemu_args`. `false` leaves tunnr's default
/// user-mode virtio-net.
pub net_none: bool,
/// **The UEFI firmware the guest boots on** — draupnir's [`Firmware`], carried
/// as ONE choice because the OVMF CODE and VARS images are a pair (see the type:
/// the enforcing CODE with the stock, empty VARS template enforces against
/// nothing and looks exactly like Secure Boot).
///
/// [`Firmware::HostDefault`] (the default) names nothing and leaves tunnr's own
/// stock OVMF discovery in place — argv byte-identical to a spec written before
/// this field existed. This is the field a GUI firmware picker sets: without it
/// the two `tunnr_vm::BootSpec` firmware fields were unreachable from any layer
/// above tunnr, so no caller could ask for Secure Boot at all.
pub firmware: Firmware,
}
impl BootSpec {
/// A kernel+rootfs boot recipe with QEMU defaults. `qemu_args` is left for the
/// caller (nornir fills it from its `VmBuild::qemu_args()`); this keeps jera free
/// of nornir's build-descriptor types.
pub fn kernel_rootfs(name: impl Into<String>, kernel: impl Into<String>, rootfs: impl Into<String>) -> Self {
BootSpec {
name: name.into(),
kernel: kernel.into(),
rootfs: rootfs.into(),
disk: String::new(),
mem_mb: 512,
cores: 1,
cmdline: String::new(),
format: "qemu".into(),
qemu_args: Vec::new(),
iso: String::new(),
extra_qemu_args: Vec::new(),
success_marker: String::new(),
timeout_secs: 0,
net_none: false,
firmware: Firmware::HostDefault,
}
}
/// Choose the guest's UEFI [`firmware`](BootSpec::firmware), builder-style —
/// the call a firmware picker makes:
/// `BootSpec::live_iso(name, iso).with_firmware(Firmware::SecureBoot)`.
pub fn with_firmware(mut self, firmware: Firmware) -> Self {
self.firmware = firmware;
self
}
/// A **LIVE boot off a medium**: firmware-boot `iso` (`-cdrom … -boot d`),
/// no target disk, nothing installed — the run-from-RAM / airgap shape.
pub fn live_iso(name: impl Into<String>, iso: impl Into<String>) -> Self {
let mut s = Self::kernel_rootfs(name, "", "");
s.iso = iso.into();
s
}
/// The **INSTALL shape**: firmware-boot `iso` with the blank `disk` attached as
/// the virtio target (vda) — the installer writes onto it and the writes
/// PERSIST (draupnir's `kvm_install_from_medium`, `persist_disk = true`).
pub fn install_from_medium(
name: impl Into<String>,
iso: impl Into<String>,
disk: impl Into<String>,
) -> Self {
let mut s = Self::kernel_rootfs(name, "", "");
s.iso = iso.into();
s.disk = disk.into();
s
}
/// **Boot OFF an installed disk** — no kernel, no medium: OVMF firmware-boots the
/// ESP on `disk`, whose writes persist (draupnir's `kvm_boot_installed_disk`).
/// The second half of install → reboot-into-the-product.
pub fn installed_disk(name: impl Into<String>, disk: impl Into<String>) -> Self {
let mut s = Self::kernel_rootfs(name, "", "");
s.disk = disk.into();
s
}
/// Map this jera VM spec onto a `draupnir::BootSpec` (the KVM appliance path).
/// tunnr takes a single boot image (`rootfs_or_disk`): prefer the rootfs
/// (initramfs), else the disk. Only compiled with `vm-tunnr` (the draupnir KVM
/// backend types come with that feature).
///
/// The SHAPE is decided by which of kernel / iso / disk are named — the same
/// four shapes draupnir's constructors spell out, picked here once so a
/// consumer never has to:
/// * a kernel ⇒ direct-kernel boot (`kvm_kernel_rootfs`, rootfs else disk);
/// * no kernel, an iso, no disk ⇒ LIVE boot off the medium (`iso_boot`);
/// * no kernel, an iso AND a disk ⇒ INSTALL (`kvm_install_from_medium`);
/// * no kernel, no iso, a disk ⇒ boot the INSTALLED disk (`kvm_boot_installed_disk`).
///
/// Marker, budget, extra args, `net_none` and the [`Firmware`] choice ride
/// through when set; unset leaves draupnir's (and so tunnr's) defaults — argv
/// byte-identical to before.
#[cfg(feature = "vm-tunnr")]
pub fn to_draupnir(&self) -> draupnir::BootSpec {
let mut d = if !self.kernel.is_empty() {
let image = if !self.rootfs.is_empty() { &self.rootfs } else { &self.disk };
draupnir::BootSpec::kvm_kernel_rootfs(&self.name, &self.kernel, image)
} else if !self.iso.is_empty() && self.disk.is_empty() {
draupnir::BootSpec::iso_boot(&self.name, &self.iso)
} else if !self.iso.is_empty() {
draupnir::BootSpec::kvm_install_from_medium(&self.name, &self.iso, &self.disk)
} else if !self.disk.is_empty() {
draupnir::BootSpec::kvm_boot_installed_disk(&self.name, &self.disk)
} else {
// Nothing bootable named: keep the historical mapping, which draupnir's
// `validate` refuses by name ("needs a non-empty kernel path").
draupnir::BootSpec::kvm_kernel_rootfs(&self.name, &self.kernel, &self.rootfs)
};
d.mem_mb = self.mem_mb;
d.cores = self.cores;
d.cmdline = self.cmdline.clone();
if !self.extra_qemu_args.is_empty() {
d.extra_qemu_args = self.extra_qemu_args.clone();
}
if !self.success_marker.is_empty() {
d.success_marker = Some(self.success_marker.clone());
}
if self.timeout_secs > 0 {
d.boot_timeout = Some(std::time::Duration::from_secs(self.timeout_secs));
}
if self.net_none {
d.net = draupnir::NetMode::None;
}
// The firmware choice travels whole; draupnir resolves it into a CODE/VARS
// pair on the host and hands it to tunnr's pflash seam. `HostDefault` is
// draupnir's own default, so an untouched spec stays byte-identical.
d.firmware = self.firmware.clone();
d
}
}
/// The VM boot seam: turn a [`BootSpec`] into a live [`BootProc`].
pub trait VmBootRunner: Send + Sync {
/// Boot the VM described by `spec`, returning the live [`BootProc`].
fn boot(&self, spec: &BootSpec) -> Result<Box<dyn BootProc>, String>;
}
/// The default VM runner when draupnir's KVM backend is not compiled in (`vm-tunnr`
/// off): it maps the spec but reports a clear, honest failure rather than
/// pretending to boot.
pub struct StubVmBootRunner;
impl VmBootRunner for StubVmBootRunner {
fn boot(&self, spec: &BootSpec) -> Result<Box<dyn BootProc>, String> {
Err(format!(
"vm boot '{}' not wired in this build: rebuild with `--features vm-tunnr` to boot \
THROUGH draupnir's KVM backend (draupnir::kvm::KvmBoot → tunnr_vm::boot_test)",
spec.name,
))
}
}
/// The REAL VM runner (feature `vm-tunnr`): maps jera's [`BootSpec`] onto a
/// `draupnir::BootSpec`, boots it via `draupnir::kvm::KvmBoot`, and adapts the
/// returned [`draupnir::Machine`] into a [`BootProc`] — so the job lifecycle drives
/// the KVM through draupnir: job-kill → `Lifecycle::power_off`; poll →
/// `Lifecycle::status` (which polls tunnr's QEMU pid, so an externally-dead VM flips
/// the job too).
#[cfg(feature = "vm-tunnr")]
#[derive(Default)]
pub struct DraupnirVmBootRunner {
/// The KVM backend; its live-handle registry is shared (cheap `Arc` clone) with
/// each spawned [`DraupnirVmProc`] so poll/kill address the exact VM booted.
kvm: draupnir::kvm::KvmBoot,
}
/// Back-compat alias — jera's VM boot now runs THROUGH draupnir (which drives
/// tunnr), so the runner is named for draupnir; the old name still resolves.
#[cfg(feature = "vm-tunnr")]
pub type TunnrVmBootRunner = DraupnirVmBootRunner;
#[cfg(feature = "vm-tunnr")]
impl VmBootRunner for DraupnirVmBootRunner {
fn boot(&self, spec: &BootSpec) -> Result<Box<dyn BootProc>, String> {
use draupnir::Boot;
let machine = self
.kvm
.boot(&spec.to_draupnir())
.map_err(|e| format!("draupnir kvm boot: {e}"))?;
Ok(Box::new(DraupnirVmProc {
kvm: self.kvm.clone(),
machine,
killed: false,
terminal: None,
lines_emitted: 0,
pending: Vec::new(),
}))
}
}
/// Adapts a draupnir KVM [`draupnir::Machine`] (backed by a live tunnr handle inside
/// the shared `KvmBoot` registry) to jera's [`BootProc`] (feature `vm-tunnr`).
#[cfg(feature = "vm-tunnr")]
struct DraupnirVmProc {
kvm: draupnir::kvm::KvmBoot,
machine: draupnir::Machine,
killed: bool,
terminal: Option<BootStatus>,
/// How many newline-TERMINATED console lines [`drain_log`](BootProc::drain_log)
/// has already handed out, so each poll returns only the fresh tail (never a
/// still-growing partial line twice).
lines_emitted: usize,
/// Console lines captured at teardown, BEFORE the handle was released: `kill` /
/// `shutdown` drop draupnir's live handle, after which `serial_log` is `None`,
/// so the tail the guest wrote last would be lost to the next `drain_log`.
pending: Vec<String>,
}
#[cfg(feature = "vm-tunnr")]
impl DraupnirVmProc {
/// The newly-completed console lines since the last hand-out (empty when the
/// handle is gone). `split('\n')`'s last element is the text after the final
/// newline (a partial, still-filling line, or empty) — only the elements BEFORE
/// it are complete.
fn fresh_lines(&mut self) -> Vec<String> {
let Some(full) = self.kvm.serial_log(&self.machine) else {
return Vec::new();
};
let parts: Vec<&str> = full.split('\n').collect();
let complete = &parts[..parts.len().saturating_sub(1)];
if complete.len() <= self.lines_emitted {
return Vec::new();
}
let fresh: Vec<String> =
complete[self.lines_emitted..].iter().map(|s| s.to_string()).collect();
self.lines_emitted = complete.len();
fresh
}
/// Tear down through `stop` (power_off or shutdown), keeping the console tail
/// and the verdict honest: a boot that already reached `BootedOk` stays
/// `BootedOk` (a clean stop after success is not a failure); anything else
/// becomes `Killed`.
fn teardown(&mut self, stop: impl FnOnce(&draupnir::kvm::KvmBoot, &draupnir::Machine)) {
if self.killed {
return;
}
// Settle the verdict first: the handle answers `booted_ok` only while live.
if self.terminal.is_none() && self.kvm.booted_ok(&self.machine) {
self.terminal = Some(BootStatus::BootedOk);
}
let tail = self.fresh_lines();
self.pending.extend(tail);
stop(&self.kvm, &self.machine);
self.killed = true;
if self.terminal.is_none() {
self.terminal = Some(BootStatus::Killed);
}
}
}
#[cfg(feature = "vm-tunnr")]
impl BootProc for DraupnirVmProc {
fn poll(&mut self) -> BootStatus {
use draupnir::{Lifecycle, PowerState};
if let Some(t) = &self.terminal {
return t.clone();
}
// The boot VERDICT comes first: tunnr flips to BootedOk the moment the
// spec's success marker streams past, and draupnir's `status` folds that into
// `On` (a booted guest is a powered guest) — so ask the verdict reader, not
// the power state. Terminal: the job is over, the guest may keep serving.
if self.kvm.booted_ok(&self.machine) {
self.terminal = Some(BootStatus::BootedOk);
return BootStatus::BootedOk;
}
match self.kvm.status(&self.machine) {
Ok(PowerState::On) => BootStatus::Running,
// Not yet observed by the backend — still coming up.
Ok(PowerState::Unknown) => BootStatus::Booting,
// The VM is powered off: a deliberate teardown is Killed; otherwise the
// guest went down on its own (the boot job is over / it failed) — in
// tunnr's own words when it has them (timed out / killed / failed).
Ok(PowerState::Off) => {
let t = if self.killed {
BootStatus::Killed
} else {
BootStatus::Failed(
self.kvm
.exit_reason(&self.machine)
.unwrap_or_else(|| "vm powered off".into()),
)
};
self.terminal = Some(t.clone());
t
}
Err(e) => {
let t = BootStatus::Failed(format!("vm status: {e}"));
self.terminal = Some(t.clone());
t
}
}
}
fn kill(&mut self) {
use draupnir::Lifecycle;
self.teardown(|kvm, m| {
let _ = kvm.power_off(m);
});
}
fn shutdown(&mut self, grace: std::time::Duration) {
self.teardown(|kvm, m| {
let _ = kvm.shutdown(m, grace);
});
}
fn drain_log(&mut self) -> Vec<String> {
// The KVM console: read the serial capture off draupnir (which reads it off
// tunnr's live BootHandle) and return only the newly-completed lines since the
// last drain, so a VM boot job accumulates the guest console the same way an
// Exe boot accumulates stdout — the surface the forge (dwarves) renders. Lines
// captured at teardown (before the handle went away) come out first.
let mut out = std::mem::take(&mut self.pending);
out.extend(self.fresh_lines());
out
}
}
/// The default VM runner for this build: the real [`DraupnirVmBootRunner`] under
/// `vm-tunnr`, else the honest [`StubVmBootRunner`].
pub fn default_vm_runner() -> Box<dyn VmBootRunner> {
#[cfg(feature = "vm-tunnr")]
{
Box::new(DraupnirVmBootRunner::default())
}
#[cfg(not(feature = "vm-tunnr"))]
{
Box::new(StubVmBootRunner)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stub_vm_runner_reports_honest_not_wired() {
let spec = BootSpec::kernel_rootfs("vm", "/k", "r");
let err = match StubVmBootRunner.boot(&spec) {
Ok(_) => panic!("stub must not pretend to boot"),
Err(e) => e,
};
assert!(err.contains("draupnir"), "names the boot library it routes through: {err}");
assert!(err.contains("vm-tunnr"), "names the feature: {err}");
}
#[test]
fn bootspec_kernel_rootfs_defaults() {
let s = BootSpec::kernel_rootfs("g", "/boot/vmlinuz", "rootfs.cpio.gz");
assert_eq!(s.mem_mb, 512);
assert_eq!(s.cores, 1);
assert_eq!(s.format, "qemu");
assert!(s.disk.is_empty());
}
/// The three kernel-less shapes pick draupnir's own constructors, and marker /
/// budget / extra args / net_none ride through — none of it invented here.
#[cfg(feature = "vm-tunnr")]
#[test]
fn medium_and_disk_shapes_map_onto_draupnirs_own_constructors() {
use draupnir::{BootOrder, ImageSource, NetMode};
let live = BootSpec::live_iso("live", "/m.iso").to_draupnir();
assert_eq!(live.image, ImageSource::Iso("/m.iso".into()));
assert_eq!(live.boot_order, BootOrder::Medium);
assert_eq!(live.medium, None, "a live ISO is the payload, not a second -cdrom");
let install = BootSpec::install_from_medium("inst", "/m.iso", "/t.raw").to_draupnir();
assert_eq!(install.image, ImageSource::BootloaderDisk { disk: "/t.raw".into() });
assert_eq!(install.medium.as_deref(), Some("/m.iso"));
assert!(install.persist_disk, "an install whose writes are discarded is a false green");
let second = BootSpec::installed_disk("boot2", "/t.raw").to_draupnir();
assert_eq!(second.image, ImageSource::BootloaderDisk { disk: "/t.raw".into() });
assert_eq!(second.boot_order, BootOrder::Disk);
assert!(second.persist_disk);
let mut knobs = BootSpec::live_iso("k", "/m.iso");
knobs.success_marker = "APP-ALIVE".into();
knobs.timeout_secs = 120;
knobs.extra_qemu_args = vec!["-device".into(), "virtio-gpu-pci".into()];
knobs.net_none = true;
let d = knobs.to_draupnir();
assert_eq!(d.success_marker.as_deref(), Some("APP-ALIVE"));
assert_eq!(d.boot_timeout, Some(std::time::Duration::from_secs(120)));
assert_eq!(d.extra_qemu_args, vec!["-device", "virtio-gpu-pci"]);
assert_eq!(d.net, NetMode::None);
// Unset knobs leave draupnir's defaults — byte-identical argv to before.
let plain = BootSpec::kernel_rootfs("p", "/k", "/r").to_draupnir();
assert_eq!(plain.success_marker, None);
assert_eq!(plain.boot_timeout, None);
assert!(plain.extra_qemu_args.is_empty());
assert_eq!(plain.net, NetMode::Default);
}
/// **The firmware choice rides the same seam as `-device virtio-gpu-pci`** —
/// a GUI picker sets it on the jera spec and it arrives on draupnir's, which
/// is where the CODE/VARS pair is resolved and handed to tunnr's pflash.
///
/// Modelled on `medium_and_disk_shapes_map_onto_draupnirs_own_constructors`:
/// prove the ARGUMENT survives the journey, not that a field was assigned.
#[cfg(feature = "vm-tunnr")]
#[test]
fn the_firmware_choice_survives_the_jera_to_draupnir_seam() {
// Unset → draupnir's default → tunnr's stock discovery: byte-identical argv.
let plain = BootSpec::live_iso("p", "/m.iso").to_draupnir();
assert_eq!(plain.firmware, Firmware::HostDefault);
let sb = BootSpec::live_iso("sb", "/m.iso")
.with_firmware(Firmware::SecureBoot)
.to_draupnir();
assert_eq!(sb.firmware, Firmware::SecureBoot);
assert!(sb.firmware.enforces_secure_boot(), "the picker asked for enforcement");
// The caller's own enrolled PK/KEK/db store rides through whole — the vars
// path is data, and jera must not reinterpret it.
let enrolled = BootSpec::installed_disk("e", "/t.raw")
.with_firmware(Firmware::SecureBootEnrolled {
vars_template: "/keys/OVMF_VARS.enrolled.fd".into(),
})
.to_draupnir();
assert_eq!(
enrolled.firmware,
Firmware::SecureBootEnrolled { vars_template: "/keys/OVMF_VARS.enrolled.fd".into() }
);
}
/// The jera→draupnir spec mapping prefers the rootfs image and carries the
/// machine size + cmdline (feature `vm-tunnr`).
#[cfg(feature = "vm-tunnr")]
#[test]
fn maps_onto_a_draupnir_kvm_spec() {
let mut s = BootSpec::kernel_rootfs("holger", "/boot/vmlinuz", "rootfs.cpio.gz");
s.mem_mb = 2048;
s.cores = 4;
s.cmdline = "korp.smoke=1".into();
let d = s.to_draupnir();
assert_eq!(d.name, "holger");
assert_eq!(d.kind, draupnir::Kind::Vm);
assert_eq!(d.mem_mb, 2048);
assert_eq!(d.cores, 4);
assert_eq!(d.cmdline, "korp.smoke=1");
assert_eq!(d.image, draupnir::ImageSource::KernelRootfs {
kernel: "/boot/vmlinuz".into(),
rootfs: "rootfs.cpio.gz".into(),
});
}
}