diff --git a/crates/bcvk-qemu/src/lib.rs b/crates/bcvk-qemu/src/lib.rs index 7d811e8..91cbffe 100644 --- a/crates/bcvk-qemu/src/lib.rs +++ b/crates/bcvk-qemu/src/lib.rs @@ -5,8 +5,8 @@ //! //! # Features //! -//! - **QEMU VM Management**: Launch VMs with direct kernel boot, virtio devices, -//! and automatic resource cleanup +//! - **QEMU VM Management**: Launch VMs via direct kernel boot or ISO boot, +//! with virtio devices and automatic resource cleanup //! - **VirtioFS Mounts**: Spawn and manage virtiofsd processes for sharing host //! directories with the guest //! - **SMBIOS Credentials**: Inject systemd credentials via QEMU SMBIOS interface @@ -53,8 +53,8 @@ pub use credentials::{ }; pub use qemu::{ - BootMode, DiskFormat, DisplayMode, NetworkMode, QemuConfig, ResourceLimits, RunningQemu, - VirtioBlkDevice, VirtioSerialOut, VirtiofsMount, VHOST_VSOCK, + BootMode, DiskFormat, DisplayMode, MachineType, NetworkMode, QemuConfig, ResourceLimits, + RunningQemu, VirtioBlkDevice, VirtioSerialOut, VirtiofsMount, VHOST_VSOCK, }; pub use virtiofsd::{spawn_virtiofsd_async, validate_virtiofsd_config, VirtiofsConfig}; diff --git a/crates/bcvk-qemu/src/qemu.rs b/crates/bcvk-qemu/src/qemu.rs index 475d721..7760a23 100644 --- a/crates/bcvk-qemu/src/qemu.rs +++ b/crates/bcvk-qemu/src/qemu.rs @@ -118,7 +118,39 @@ pub struct ResourceLimits { pub nice_level: Option, } -/// VM boot configuration: direct kernel boot. +/// QEMU machine type selection. +#[derive(Debug, Clone, Default)] +pub enum MachineType { + /// Auto-detect based on host architecture (recommended). + #[default] + Auto, + /// Use a specific machine type string (e.g., "q35", "pc-q35-9.2"). + Explicit(String), +} + +impl MachineType { + /// Resolve to a QEMU `-machine` argument for the current host. + /// + /// Returns `None` for unknown architectures, letting QEMU use its default. + pub fn resolve(&self) -> Option<&str> { + // xref: https://github.com/coreos/coreos-assembler/blob/main/mantle/platform/qemu.go + match self { + Self::Auto => match std::env::consts::ARCH { + "x86_64" => Some("q35"), + // gic-version=max selects the best available GIC for the host + "aarch64" => Some("virt,gic-version=max"), + "s390x" => Some("s390-ccw-virtio"), + // kvm-type=HV ensures bare metal KVM, not user mode + // ic-mode=xics for interrupt controller + "powerpc64" => Some("pseries,kvm-type=HV,ic-mode=xics"), + _ => None, + }, + Self::Explicit(name) => Some(name.as_str()), + } + } +} + +/// VM boot configuration. #[derive(Debug)] pub enum BootMode { /// Direct kernel boot (fast, testing-focused). @@ -139,6 +171,15 @@ pub enum BootMode { /// VirtIO-FS socket for root filesystem. virtiofs_socket: Utf8PathBuf, }, + /// Boot from an ISO image (e.g. for Anaconda installer testing). + /// + /// The ISO is attached as a CDROM device. Unlike DirectBoot, there is no + /// root virtiofs socket — the installer boots from the ISO and installs + /// to a disk device added via [`QemuConfig::add_virtio_blk_device`]. + IsoBoot { + /// Path to the ISO image file. + iso_path: String, + }, } /// Complete QEMU VM configuration with builder pattern. @@ -148,6 +189,8 @@ pub struct QemuConfig { pub memory_mb: u32, /// Number of vCPUs (1-256). pub vcpus: u32, + /// Machine type (default: auto-detect based on host architecture). + pub machine_type: MachineType, boot_mode: Option, /// Main VirtioFS configuration for root filesystem (handled separately from additional mounts). pub main_virtiofs_config: Option, @@ -171,6 +214,11 @@ pub struct QemuConfig { pub enable_console: bool, /// SMBIOS credentials for systemd. smbios_credentials: Vec, + /// Path to write serial console output (if set, `-serial file:` + /// is used instead of `-serial none`). + pub serial_log: Option, + /// Prevent automatic reboot (useful for debugging or post-install inspection). + pub no_reboot: bool, /// Write systemd notifications to this file. pub systemd_notify: Option, @@ -200,6 +248,16 @@ impl QemuConfig { } } + /// Create a new config for ISO boot (e.g. Anaconda installer). + pub fn new_iso_boot(memory_mb: u32, vcpus: u32, iso_path: String) -> Self { + Self { + memory_mb, + vcpus, + boot_mode: Some(BootMode::IsoBoot { iso_path }), + ..Default::default() + } + } + /// Enable vsock support. pub fn enable_vsock(&mut self) -> Result<()> { let fd = OpenOptions::new() @@ -249,6 +307,39 @@ impl QemuConfig { return Err(eyre!("vCPU count too high: {} (maximum 256)", self.vcpus)); } + // Validate boot mode specifics + match &self.boot_mode { + Some(BootMode::IsoBoot { iso_path }) => { + if iso_path.is_empty() { + return Err(eyre!("ISO path cannot be empty")); + } + if !std::path::Path::new(iso_path).exists() { + return Err(eyre!("ISO image not found: {}", iso_path)); + } + // main_virtiofs_config is for the root filesystem in DirectBoot; + // it has no meaning for ISO boot. + if self.main_virtiofs_config.is_some() { + return Err(eyre!( + "main_virtiofs_config is not supported with ISO boot \ + (the root filesystem comes from the ISO)" + )); + } + } + Some(BootMode::DirectBoot { + kernel_path, + initramfs_path, + .. + }) => { + if kernel_path.is_empty() { + return Err(eyre!("Kernel path cannot be empty")); + } + if initramfs_path.is_empty() { + return Err(eyre!("Initramfs path cannot be empty")); + } + } + None => {} + } + // Validate virtiofs mounts for mount in &self.additional_mounts { if mount.tag.is_empty() { @@ -430,6 +521,12 @@ fn spawn( .map_err(Into::into) }); } + + // Set machine type (auto-detected or explicit) + if let Some(machine) = config.machine_type.resolve() { + cmd.args(["-machine", machine]); + } + cmd.args([ "-m", &memory_arg, @@ -446,6 +543,10 @@ fn spawn( "node,memdev=mem", ]); + if config.no_reboot { + cmd.arg("-no-reboot"); + } + for (idx, fd) in config.fdset.iter().enumerate() { let fd_id = 100 + idx as u32; // Start at 100 to avoid conflicts let set_id = idx + 1; // fdset starts at 1 @@ -498,6 +599,9 @@ fn spawn( let append_str = kernel_cmdline.join(" "); cmd.args(["-append", &append_str]); } + Some(BootMode::IsoBoot { iso_path }) => { + cmd.args(["-cdrom", iso_path]); + } None => {} } @@ -562,8 +666,13 @@ fn spawn( } } - // No GUI, and no emulated serial ports by default. - cmd.args(["-serial", "none", "-nographic", "-display", "none"]); + // No GUI; serial console either to a log file or disabled. + if let Some(ref serial_path) = config.serial_log { + cmd.args(["-serial", &format!("file:{}", serial_path)]); + } else { + cmd.args(["-serial", "none"]); + } + cmd.args(["-nographic", "-display", "none"]); match &config.display_mode { DisplayMode::None => { @@ -868,6 +977,17 @@ mod tests { ); } + #[test] + fn test_iso_boot_config() { + let config = QemuConfig::new_iso_boot(2048, 2, "/test/image.iso".to_string()); + assert_eq!(config.memory_mb, 2048); + assert_eq!(config.vcpus, 2); + assert!(matches!( + &config.boot_mode, + Some(BootMode::IsoBoot { iso_path }) if iso_path == "/test/image.iso" + )); + } + #[test] fn test_disk_format() { assert_eq!(DiskFormat::Raw.as_str(), "raw");