From 83c34f8453c299dd1de5fff71a0829d54291a902 Mon Sep 17 00:00:00 2001 From: centra Date: Thu, 2 Apr 2026 13:54:01 +0900 Subject: [PATCH] add nix-backed kvm boot smoke coverage --- plasmavmc/crates/plasmavmc-kvm/src/lib.rs | 268 ++++++++++++++++++++-- 1 file changed, 243 insertions(+), 25 deletions(-) diff --git a/plasmavmc/crates/plasmavmc-kvm/src/lib.rs b/plasmavmc/crates/plasmavmc-kvm/src/lib.rs index 04b0b04..40d4d02 100644 --- a/plasmavmc/crates/plasmavmc-kvm/src/lib.rs +++ b/plasmavmc/crates/plasmavmc-kvm/src/lib.rs @@ -1128,6 +1128,15 @@ impl HypervisorBackend for KvmBackend { mod tests { use super::*; use plasmavmc_types::DiskSpec; + use std::{ + ffi::OsStr, + fs::{self, File}, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process::Command as StdCommand, + sync::OnceLock, + time::{SystemTime, UNIX_EPOCH}, + }; use tokio::net::UnixListener; #[test] @@ -1383,42 +1392,251 @@ mod tests { .expect("qmp became ready"); } - // Integration smoke: requires env to point to QEMU and a qcow2 image. + fn resolve_repo_root() -> Option { + let mut current = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + loop { + if current.join("flake.nix").exists() + && current.join("nix/test-cluster/flake.nix").exists() + { + return Some(current); + } + if !current.pop() { + return None; + } + } + } + + fn find_qcow2(path: &Path) -> Option { + if path.is_file() { + return path + .extension() + .and_then(|ext| (ext == OsStr::new("qcow2")).then(|| path.to_path_buf())); + } + + let entries = fs::read_dir(path).ok()?; + for entry in entries.flatten() { + let candidate = entry.path(); + if let Some(found) = find_qcow2(&candidate) { + return Some(found); + } + } + None + } + + fn resolve_vm_guest_image() -> Option { + static VM_GUEST_IMAGE: OnceLock> = OnceLock::new(); + + VM_GUEST_IMAGE + .get_or_init(|| { + if let Some(path) = std::env::var_os(env::ENV_QCOW2_PATH).map(PathBuf::from) { + if path.exists() { + return Some(path); + } + } + + let repo_root = resolve_repo_root()?; + let flake = repo_root.join("nix/test-cluster"); + let output = StdCommand::new("nix") + .arg("build") + .arg(format!("{}#vmGuestImage", flake.display())) + .arg("--no-link") + .arg("--print-out-paths") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let out_path = String::from_utf8_lossy(&output.stdout) + .lines() + .find(|line| !line.trim().is_empty()) + .map(str::trim) + .map(PathBuf::from)?; + + find_qcow2(&out_path) + }) + .clone() + } + + fn qemu_binary_for_integration() -> Option { + let qemu = + std::env::var(env::ENV_QEMU_PATH).unwrap_or_else(|_| "qemu-system-x86_64".to_string()); + StdCommand::new(&qemu) + .arg("--version") + .output() + .ok() + .filter(|output| output.status.success()) + .map(|_| qemu) + } + + async fn wait_for_console_marker( + console_log: &Path, + marker: &str, + timeout: Duration, + ) -> Result<()> { + let start = Instant::now(); + let mut last_console = String::new(); + + loop { + if let Ok(contents) = tokio::fs::read_to_string(console_log).await { + if contents.contains(marker) { + return Ok(()); + } + + let mut tail: Vec<&str> = contents.lines().rev().take(20).collect(); + tail.reverse(); + last_console = tail.join("\n"); + } + + if start.elapsed() >= timeout { + return Err(Error::HypervisorError(format!( + "Timed out waiting for console marker {marker} in {}. Last console output:\n{}", + console_log.display(), + last_console + ))); + } + + tokio::time::sleep(Duration::from_millis(250)).await; + } + } + + struct IntegrationWorkspace { + path: PathBuf, + } + + impl IntegrationWorkspace { + fn new() -> Self { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time") + .as_nanos(); + let path = PathBuf::from("/var/tmp").join(format!("plasmavmc-kvm-smoke-{unique}")); + fs::create_dir_all(&path).expect("create integration workspace"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)) + .expect("chmod integration workspace"); + Self { path } + } + } + + impl Drop for IntegrationWorkspace { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + // Integration smoke: boots a real guest image built by nix/test-cluster#vmGuestImage. #[tokio::test] #[ignore] async fn integration_create_start_status_stop() { let _guard = crate::env::env_test_lock().lock().unwrap(); - let qemu = std::env::var(env::ENV_QEMU_PATH) - .unwrap_or_else(|_| "/usr/bin/qemu-system-x86_64".into()); - let qcow = match std::env::var(env::ENV_QCOW2_PATH) { - Ok(path) => path, - Err(_) => { - eprintln!("Skipping integration: {} not set", env::ENV_QCOW2_PATH); + let qemu = match qemu_binary_for_integration() { + Some(path) => path, + None => { + eprintln!("Skipping integration: qemu-system-x86_64 not available"); return; } }; - - if !Path::new(&qemu).exists() || !Path::new(&qcow).exists() { - eprintln!("Skipping integration: qemu or qcow2 path missing"); + let qcow = match resolve_vm_guest_image() { + Some(path) => path, + None => { + eprintln!("Skipping integration: unable to resolve nix test guest image"); + return; + } + }; + if !qcow.exists() { + eprintln!( + "Skipping integration: guest image missing at {}", + qcow.display() + ); return; } - let backend = KvmBackend::new(qemu, tempfile::tempdir().unwrap().into_path()); - let vm = VirtualMachine::new("int", "org", "proj", VmSpec::default()); - let handle = backend.create(&vm, &[]).await.expect("create vm"); + let workspace = IntegrationWorkspace::new(); + let runtime_dir = workspace.path.join("runtime"); + let root_disk = workspace.path.join("root.qcow2"); + fs::copy(&qcow, &root_disk).expect("copy guest image"); + fs::set_permissions(&root_disk, fs::Permissions::from_mode(0o644)) + .expect("chmod guest image"); + let data_disk = workspace.path.join("data.raw"); + File::create(&data_disk) + .unwrap() + .set_len(128 * 1024 * 1024) + .unwrap(); + fs::set_permissions(&data_disk, fs::Permissions::from_mode(0o644)) + .expect("chmod data disk"); + + let backend = KvmBackend::new(qemu, runtime_dir); + let mut spec = VmSpec::default(); + spec.disks = vec![ + DiskSpec { + id: "root".into(), + source: plasmavmc_types::DiskSource::Volume { + volume_id: "root".into(), + }, + size_gib: 4, + bus: DiskBus::Virtio, + cache: DiskCache::Writeback, + boot_index: Some(1), + }, + DiskSpec { + id: "data".into(), + source: plasmavmc_types::DiskSource::Volume { + volume_id: "data".into(), + }, + size_gib: 1, + bus: DiskBus::Virtio, + cache: DiskCache::Writeback, + boot_index: None, + }, + ]; + let vm = VirtualMachine::new("int", "org", "proj", spec); + let disks = vec![ + AttachedDisk { + id: "root".into(), + attachment: DiskAttachment::File { + path: root_disk.display().to_string(), + format: VolumeFormat::Qcow2, + }, + bus: DiskBus::Virtio, + cache: DiskCache::Writeback, + boot_index: Some(1), + read_only: false, + }, + AttachedDisk { + id: "data".into(), + attachment: DiskAttachment::File { + path: data_disk.display().to_string(), + format: VolumeFormat::Raw, + }, + bus: DiskBus::Virtio, + cache: DiskCache::Writeback, + boot_index: None, + read_only: false, + }, + ]; + let handle = backend.create(&vm, &disks).await.expect("create vm"); backend.start(&handle).await.expect("start vm"); - let status = backend.status(&handle).await.expect("status vm"); - assert!( - matches!( - status.actual_state, - VmState::Running | VmState::Stopped | VmState::Error - ), - "unexpected state: {:?}", - status.actual_state + let console_log = PathBuf::from( + handle + .backend_state + .get("console_log") + .expect("console log path") + .clone(), ); - // The smoke path may use an empty qcow2, so there is no guest userspace to - // honor ACPI powerdown. Use force-kill here to validate QEMU launch - // without depending on guest shutdown behavior. - backend.kill(&handle).await.expect("kill vm"); + wait_for_console_marker( + &console_log, + "PHOTON_VM_SMOKE_DATA_READY", + Duration::from_secs(120), + ) + .await + .expect("guest boot marker"); + let status = backend.status(&handle).await.expect("status vm"); + assert_eq!(status.actual_state, VmState::Running); + backend + .stop(&handle, Duration::from_secs(60)) + .await + .expect("graceful stop vm"); + let stopped = backend.status(&handle).await.expect("stopped status"); + assert_eq!(stopped.actual_state, VmState::Stopped); + backend.delete(&handle).await.expect("delete vm"); } }