crates/nornir-build-thing/src/vm.rs
//! **VM / appliance BOOT build** — a third build *target kind* alongside the
//! cargo [`BuildVariant`](crate::variant::BuildVariant) and the
//! [`ContainerBuild`](crate::container::ContainerBuild) image.
//!
//! Where a `ContainerBuild` describes an OCI/appliance *image*, a [`VmBuild`]
//! describes a **bootable virtual machine**: a `kernel`, an optional `rootfs`
//! (initramfs) and/or `disk` image, the machine size (`mem_mb`, `cores`), the
//! kernel `boot_params` (cmdline), and the boot `format` (a plain QEMU boot, a
//! microVM, or a full appliance). It is the "vm type in the build-thing
//! structure": the descriptor a nornir **test-boot** maps onto tunnr's boot
//! primitive (`BootSpec`) to spin the built target up as a KVM guest.
//!
//! Like [`container`](crate::container) it is *data* (pure serde) and
//! dependency-light: it materializes the QEMU **argv** ([`VmBuild::qemu_args`])
//! but runs nothing itself — the host (nornir's test-boot, or tunnr) drives the
//! hypervisor. It is content-addressable ([`VmBuild::content_id`]) so a VM boot
//! can carry provenance the same way a cargo/image build does. A `rootfs`/`disk`
//! layer can chain the artifact a cargo [`variant`](crate::variant) produced via
//! [`from_variant`](VmBuild::from_variant) — the "build holger, then boot it as a
//! VM" chain.
//!
//! ```
//! use nornir_build_thing::{VmBuild, VmFormat};
//!
//! let vm = VmBuild {
//! name: "holger-appliance".into(),
//! kernel: "/boot/vmlinuz".into(),
//! rootfs: "artifacts/rootfs.cpio.gz".into(),
//! mem_mb: 512,
//! cores: 2,
//! boot_params: vec!["console=ttyS0".into(), "panic=-1".into()],
//! format: VmFormat::Qemu,
//! ..Default::default()
//! };
//! assert!(vm.qemu_args().iter().any(|a| a == "-kernel"));
//! assert_eq!(vm.content_id().len(), 64);
//! ```
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
/// The boot flavor a [`VmBuild`] describes. Deserializes from a bare TOML string
/// (`format = "qemu"`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum VmFormat {
/// A plain QEMU/KVM boot (kernel + rootfs/disk) — the portable default.
Qemu,
/// A lightweight microVM (`-machine microvm`), minimal device model.
MicroVm,
/// A full appliance boot (UEFI/OVMF firmware, a real disk image).
Appliance,
}
impl Default for VmFormat {
fn default() -> Self {
VmFormat::Qemu
}
}
impl From<String> for VmFormat {
fn from(s: String) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"" | "qemu" | "kvm" => VmFormat::Qemu,
"microvm" | "micro" => VmFormat::MicroVm,
"appliance" | "uefi" | "disk" => VmFormat::Appliance,
_ => VmFormat::Qemu,
}
}
}
impl From<VmFormat> for String {
fn from(f: VmFormat) -> Self {
f.as_str().to_string()
}
}
impl VmFormat {
/// Stable label (matches the string form).
pub fn as_str(&self) -> &'static str {
match self {
VmFormat::Qemu => "qemu",
VmFormat::MicroVm => "microvm",
VmFormat::Appliance => "appliance",
}
}
/// The `-machine` type QEMU boots this flavor with.
pub fn machine(&self) -> &'static str {
match self {
VmFormat::Qemu => "q35,accel=kvm:tcg",
VmFormat::MicroVm => "microvm,accel=kvm:tcg",
VmFormat::Appliance => "q35,accel=kvm:tcg",
}
}
}
/// One concrete **VM boot point** — the bootable sibling of a cargo
/// [`BuildVariant`](crate::variant::BuildVariant) and a
/// [`ContainerBuild`](crate::container::ContainerBuild).
///
/// Serializes as a `[[build.vm]]` table:
/// ```toml
/// [[build.vm]]
/// name = "holger-appliance"
/// kernel = "/boot/vmlinuz"
/// rootfs = "artifacts/rootfs.cpio.gz"
/// mem_mb = 512
/// cores = 2
/// boot_params = ["console=ttyS0", "panic=-1"]
/// format = "qemu"
/// from_variant = "full" # boot the cargo `full` variant's artifact
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VmBuild {
/// Stable handle (`nornir build --vm <name>` / the test-boot row).
pub name: String,
/// Path to the kernel image (`-kernel`), e.g. a Buildroot `bzImage`.
pub kernel: String,
/// Path to the initramfs / rootfs (`-initrd`), or `""` when the VM boots a
/// full `disk` image instead.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub rootfs: String,
/// Path to a bootable disk image (`-drive`), or `""` when the VM boots
/// `kernel` + `rootfs` directly.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub disk: String,
/// Guest memory in MiB (`-m`).
#[serde(default = "default_mem_mb")]
pub mem_mb: u32,
/// Guest vCPU count (`-smp`).
#[serde(default = "default_cores")]
pub cores: u32,
/// Kernel command-line parameters (`-append`), joined with spaces.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub boot_params: Vec<String>,
/// The boot flavor.
#[serde(default, skip_serializing_if = "is_qemu")]
pub format: VmFormat,
/// When set, the `kernel`/`rootfs`/`disk` payload is (or is produced by) the
/// artifact this cargo build variant produced — the "build it, then boot it"
/// chain. The host stages the variant's artifact before booting.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from_variant: Option<String>,
/// **Consumption edges** (`[build.vm.edges.<feature>]`) — a root feature
/// bound to another build thing's artifact, `repo/variant : typ`,
/// type-checked at load against the donor's declaration (design f3, the
/// same edge every kind carries). Empty for every existing descriptor.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub edges: std::collections::BTreeMap<String, crate::tree::EdgeDecl>,
}
fn default_mem_mb() -> u32 {
512
}
fn default_cores() -> u32 {
1
}
fn is_qemu(f: &VmFormat) -> bool {
*f == VmFormat::Qemu
}
impl Default for VmBuild {
fn default() -> Self {
VmBuild {
name: String::new(),
kernel: String::new(),
rootfs: String::new(),
disk: String::new(),
mem_mb: default_mem_mb(),
cores: default_cores(),
boot_params: Vec::new(),
format: VmFormat::default(),
from_variant: None,
edges: std::collections::BTreeMap::new(),
}
}
}
impl VmBuild {
/// This descriptor's artifact kind — always
/// [`ArtifactKind::Vm`](crate::variant::ArtifactKind::Vm). The unifying label
/// that lets one dispatcher / enumeration / provenance `kind` span vms
/// alongside the cargo variants.
pub fn artifact_kind(&self) -> crate::variant::ArtifactKind {
crate::variant::ArtifactKind::Vm
}
/// The cargo build variant this VM chains as its payload (via
/// [`from_variant`](Self::from_variant)) — the build that must run first so
/// its artifact can be staged into the boot image.
pub fn variant_deps(&self) -> Vec<&str> {
self.from_variant.as_deref().into_iter().collect()
}
/// The kernel command line (`boot_params` joined with spaces).
pub fn cmdline(&self) -> String {
self.boot_params.join(" ")
}
/// Materialize the QEMU **argv** to boot this VM (headless serial console) —
/// the analogue of [`ContainerBuild::build_args`](crate::container::ContainerBuild::build_args).
/// Deterministic, so the same descriptor always renders the same argv (the
/// input a content-addressed boot hashes). The host prepends the `qemu-system-*`
/// binary and drives it.
pub fn qemu_args(&self) -> Vec<String> {
let mut a = vec![
"-machine".to_string(),
self.format.machine().to_string(),
"-m".to_string(),
self.mem_mb.to_string(),
"-smp".to_string(),
self.cores.to_string(),
"-nographic".to_string(),
"-no-reboot".to_string(),
];
if !self.kernel.is_empty() {
a.push("-kernel".to_string());
a.push(self.kernel.clone());
}
if !self.rootfs.is_empty() {
a.push("-initrd".to_string());
a.push(self.rootfs.clone());
}
if !self.disk.is_empty() {
a.push("-drive".to_string());
a.push(format!("file={},format=raw", self.disk));
}
a.push("-serial".to_string());
a.push("mon:stdio".to_string());
if !self.boot_params.is_empty() {
a.push("-append".to_string());
a.push(self.cmdline());
}
a
}
/// A human-readable one-liner, e.g. `qemu-system-x86_64 -machine q35… -kernel …`.
pub fn command_line(&self, qemu_bin: &str) -> String {
format!("{qemu_bin} {}", self.qemu_args().join(" "))
}
/// Validate the descriptor: a name, a kernel, and a rootfs or a disk to boot.
pub fn validate(&self) -> Result<()> {
if self.name.trim().is_empty() {
return Err(anyhow!("vm build: empty name"));
}
crate::tree::validate_edge_rows(&format!("vm '{}'", self.name), None, &self.edges, &[])?;
if self.kernel.trim().is_empty() && self.disk.trim().is_empty() {
return Err(anyhow!(
"vm '{}': needs a `kernel` (with `rootfs`) or a bootable `disk`",
self.name
));
}
if self.disk.trim().is_empty() && self.rootfs.trim().is_empty() && !self.kernel.is_empty() {
return Err(anyhow!(
"vm '{}': a `kernel` boot needs a `rootfs` (initramfs) or a `disk`",
self.name
));
}
if self.mem_mb == 0 {
return Err(anyhow!("vm '{}': mem_mb must be > 0", self.name));
}
if self.cores == 0 {
return Err(anyhow!("vm '{}': cores must be > 0", self.name));
}
Ok(())
}
/// The canonical bytes a VM boot's content id is taken over: the materialized
/// QEMU argv plus the format — a stable boot-spec fingerprint that survives
/// serde round-trips.
pub fn canonical_bytes(&self) -> Vec<u8> {
let mut s = self.qemu_args().join(" ");
s.push_str(&format!("#format:{}", self.format.as_str()));
if let Some(v) = &self.from_variant {
s.push_str(&format!("#from_variant:{v}"));
}
s.into_bytes()
}
/// A content-addressed id (lowercase-hex SHA-256 over [`canonical_bytes`]) —
/// the VM-boot analogue of a cargo/image build's provenance digest.
pub fn content_id(&self) -> String {
nornir_hash::sha256_hex(&self.canonical_bytes())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn appliance() -> VmBuild {
VmBuild {
name: "holger-appliance".to_string(),
kernel: "/boot/vmlinuz".to_string(),
rootfs: "artifacts/rootfs.cpio.gz".to_string(),
mem_mb: 512,
cores: 2,
boot_params: vec!["console=ttyS0".to_string(), "panic=-1".to_string()],
format: VmFormat::Qemu,
from_variant: Some("full".to_string()),
..Default::default()
}
}
#[test]
fn qemu_args_carry_machine_mem_cores_kernel_and_cmdline() {
let vm = appliance();
let a = vm.qemu_args();
assert!(a.windows(2).any(|w| w == ["-m", "512"]));
assert!(a.windows(2).any(|w| w == ["-smp", "2"]));
assert!(a.windows(2).any(|w| w == ["-kernel", "/boot/vmlinuz"]));
assert!(a.windows(2).any(|w| w == ["-initrd", "artifacts/rootfs.cpio.gz"]));
assert!(a.iter().any(|x| x == "-nographic"));
assert!(a.windows(2).any(|w| w == ["-append", "console=ttyS0 panic=-1"]));
// deterministic
assert_eq!(vm.qemu_args(), appliance().qemu_args());
assert_eq!(
vm.command_line("qemu-system-x86_64"),
format!("qemu-system-x86_64 {}", a.join(" "))
);
}
#[test]
fn variant_deps_lists_the_chained_cargo_build() {
assert_eq!(appliance().variant_deps(), vec!["full"]);
}
#[test]
fn validate_catches_missing_kernel_rootfs_and_zero_size() {
assert!(appliance().validate().is_ok());
let mut vm = appliance();
vm.kernel = String::new();
vm.disk = String::new();
assert!(vm.validate().is_err(), "no kernel and no disk rejected");
// kernel with neither rootfs nor disk is rejected.
let mut vm = appliance();
vm.rootfs = String::new();
vm.disk = String::new();
assert!(vm.validate().is_err(), "kernel needs a rootfs or disk");
// a pure disk boot (no kernel) is fine.
let disk_vm = VmBuild {
name: "disk".into(),
kernel: String::new(),
disk: "guest.raw".into(),
..Default::default()
};
assert!(disk_vm.validate().is_ok());
let mut vm = appliance();
vm.mem_mb = 0;
assert!(vm.validate().is_err(), "zero memory rejected");
let mut vm = appliance();
vm.cores = 0;
assert!(vm.validate().is_err(), "zero cores rejected");
}
#[test]
fn descriptor_and_content_id_round_trip() {
let vm = appliance();
let id = vm.content_id();
assert_eq!(id.len(), 64);
let json = serde_json::to_string_pretty(&vm).unwrap();
let back: VmBuild = serde_json::from_str(&json).unwrap();
assert_eq!(back, vm, "VmBuild survives a JSON round-trip");
assert_eq!(back.content_id(), id, "content id is stable across round-trip");
// A distinct VM has a distinct id.
let mut other = appliance();
other.mem_mb = 1024;
assert_ne!(other.content_id(), id, "distinct VMs have distinct ids");
}
#[test]
fn vm_format_parses_and_round_trips_all_spellings() {
assert_eq!(VmFormat::from("qemu".to_string()), VmFormat::Qemu);
assert_eq!(VmFormat::from("".to_string()), VmFormat::Qemu);
assert_eq!(VmFormat::from("MicroVM".to_string()), VmFormat::MicroVm);
assert_eq!(VmFormat::from("appliance".to_string()), VmFormat::Appliance);
assert_eq!(VmFormat::from("uefi".to_string()), VmFormat::Appliance);
assert_eq!(String::from(VmFormat::MicroVm), "microvm");
assert_eq!(VmFormat::default(), VmFormat::Qemu);
assert_eq!(VmFormat::MicroVm.machine(), "microvm,accel=kvm:tcg");
}
#[test]
fn microvm_boot_from_toml() {
let toml = r#"
name = "micro"
kernel = "/boot/vmlinuz"
rootfs = "rootfs.cpio.gz"
mem_mb = 256
cores = 1
format = "microvm"
"#;
let vm: VmBuild = toml::from_str(toml).unwrap();
assert_eq!(vm.format, VmFormat::MicroVm);
assert_eq!(vm.mem_mb, 256);
assert!(vm.qemu_args().windows(2).any(|w| w == ["-machine", "microvm,accel=kvm:tcg"]));
}
}