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 {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use plasmavmc_types::DiskSpec;
|
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;
|
use tokio::net::UnixListener;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1383,42 +1392,251 @@ mod tests {
|
||||||
.expect("qmp became ready");
|
.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]
|
#[tokio::test]
|
||||||
#[ignore]
|
#[ignore]
|
||||||
async fn integration_create_start_status_stop() {
|
async fn integration_create_start_status_stop() {
|
||||||
let _guard = crate::env::env_test_lock().lock().unwrap();
|
let _guard = crate::env::env_test_lock().lock().unwrap();
|
||||||
let qemu = std::env::var(env::ENV_QEMU_PATH)
|
let qemu = match qemu_binary_for_integration() {
|
||||||
.unwrap_or_else(|_| "/usr/bin/qemu-system-x86_64".into());
|
Some(path) => path,
|
||||||
let qcow = match std::env::var(env::ENV_QCOW2_PATH) {
|
None => {
|
||||||
Ok(path) => path,
|
eprintln!("Skipping integration: qemu-system-x86_64 not available");
|
||||||
Err(_) => {
|
|
||||||
eprintln!("Skipping integration: {} not set", env::ENV_QCOW2_PATH);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let qcow = match resolve_vm_guest_image() {
|
||||||
if !Path::new(&qemu).exists() || !Path::new(&qcow).exists() {
|
Some(path) => path,
|
||||||
eprintln!("Skipping integration: qemu or qcow2 path missing");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let backend = KvmBackend::new(qemu, tempfile::tempdir().unwrap().into_path());
|
let workspace = IntegrationWorkspace::new();
|
||||||
let vm = VirtualMachine::new("int", "org", "proj", VmSpec::default());
|
let runtime_dir = workspace.path.join("runtime");
|
||||||
let handle = backend.create(&vm, &[]).await.expect("create vm");
|
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");
|
backend.start(&handle).await.expect("start vm");
|
||||||
let status = backend.status(&handle).await.expect("status vm");
|
let console_log = PathBuf::from(
|
||||||
assert!(
|
handle
|
||||||
matches!(
|
.backend_state
|
||||||
status.actual_state,
|
.get("console_log")
|
||||||
VmState::Running | VmState::Stopped | VmState::Error
|
.expect("console log path")
|
||||||
),
|
.clone(),
|
||||||
"unexpected state: {:?}",
|
|
||||||
status.actual_state
|
|
||||||
);
|
);
|
||||||
// The smoke path may use an empty qcow2, so there is no guest userspace to
|
wait_for_console_marker(
|
||||||
// honor ACPI powerdown. Use force-kill here to validate QEMU launch
|
&console_log,
|
||||||
// without depending on guest shutdown behavior.
|
"PHOTON_VM_SMOKE_DATA_READY",
|
||||||
backend.kill(&handle).await.expect("kill vm");
|
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