draupnir 0.1.2

Draupnir — the nordisk boot/provisioning library: fire up a runtime from one BootSpec across three backends (KVM via tunnr · OCI container · Redfish bare-metal virtual-media) and drive its power lifecycle. Odin's ring that drips eight identical copies → boot a fleet of identical machines from one ISO.
Documentation
//! **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.

use crate::{Boot, BootSpec, Error, Lifecycle, Machine, PowerState, Result};
#[cfg(feature = "backend-tunnr")]
use crate::ImageSource;

#[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()
    }

    /// 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> {
        use std::path::PathBuf;
        use std::time::Duration;

        let (kernel, rootfs_or_disk) = match &spec.image {
            ImageSource::KernelRootfs { kernel, rootfs } => (kernel.clone(), rootfs.clone()),
            ImageSource::Disk(disk) => {
                // tunnr attaches a non-cpio image as a virtio -drive; a kernel is
                // still required for the direct-kernel boot path.
                (spec.cmdline_kernel(), disk.clone())
            }
            other => {
                return Err(Error::Spec(format!("kvm backend cannot boot {other:?}")))
            }
        };
        let mut boot = tunnr_vm::BootSpec::smoke(PathBuf::from(kernel), PathBuf::from(rootfs_or_disk));
        boot.mem_mb = spec.mem_mb;
        boot.cores = spec.cores;
        boot.headless = true;
        // Let a slow appliance boot be tuned without a rebuild (parity with jera).
        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();
        }
        Ok(boot)
    }
}

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 {
        tunnr_vm::BootStatus::Booting | tunnr_vm::BootStatus::BootedOk => PowerState::On,
        tunnr_vm::BootStatus::Failed(_)
        | tunnr_vm::BootStatus::Killed
        | tunnr_vm::BootStatus::TimedOut => PowerState::Off,
    }
}

#[cfg(feature = "backend-tunnr")]
impl BootSpec {
    /// Kernel path for a disk boot (tunnr's direct-kernel launch still needs a
    /// `-kernel`). Placeholder until the disk-boot kernel is threaded through the
    /// spec; kept private-ish behind the feature.
    fn cmdline_kernel(&self) -> String {
        // A disk-image boot still requires a kernel for tunnr's direct-kernel
        // launch; carrying it explicitly is a follow-up (see design.md).
        String::new()
    }
}

#[cfg(all(test, feature = "backend-tunnr"))]
mod tests {
    use super::*;
    use crate::ImageSource;

    #[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!(t.headless);
        assert_eq!(t.kernel_cmdline, "korp.smoke=1");
    }

    #[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 status_of_an_unknown_machine_is_unknown() {
        let kvm = KvmBoot::new();
        let m = Machine {
            id: "boot-does-not-exist".into(),
            spec_name: "x".into(),
            backend: crate::Backend::Kvm,
            power: PowerState::Unknown,
        };
        assert_eq!(kvm.status(&m).unwrap(), PowerState::Unknown);
    }
}