add nix-backed kvm boot smoke coverage
This commit is contained in:
parent
311bcdf2c0
commit
83c34f8453
1 changed files with 243 additions and 25 deletions
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue