add nix-backed kvm boot smoke coverage

This commit is contained in:
centra 2026-04-02 13:54:01 +09:00
parent 311bcdf2c0
commit 83c34f8453
Signed by: centra
GPG key ID: 0C09689D20B25ACA

View file

@ -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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
static VM_GUEST_IMAGE: OnceLock<Option<PathBuf>> = 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<String> {
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");
}
}