diff --git a/packages/system/virt/src/cloudhv/mod.rs b/packages/system/virt/src/cloudhv/mod.rs new file mode 100644 index 0000000..c57ec92 --- /dev/null +++ b/packages/system/virt/src/cloudhv/mod.rs @@ -0,0 +1,459 @@ +use serde::{Deserialize, Serialize}; +use std::error::Error; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use sal_os; +use sal_process; + +/// Error type for Cloud Hypervisor operations +#[derive(Debug)] +pub enum CloudHvError { + CommandFailed(String), + IoError(String), + JsonError(String), + DependencyMissing(String), + InvalidSpec(String), + NotFound(String), +} + +impl fmt::Display for CloudHvError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CloudHvError::CommandFailed(e) => write!(f, "{}", e), + CloudHvError::IoError(e) => write!(f, "IO error: {}", e), + CloudHvError::JsonError(e) => write!(f, "JSON error: {}", e), + CloudHvError::DependencyMissing(e) => write!(f, "Dependency missing: {}", e), + CloudHvError::InvalidSpec(e) => write!(f, "Invalid spec: {}", e), + CloudHvError::NotFound(e) => write!(f, "{}", e), + } + } +} + +impl Error for CloudHvError {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VmSpec { + pub id: String, + /// Optional for firmware boot; required for direct kernel boot + pub kernel_path: Option, + /// Optional for direct kernel boot; required for firmware boot + pub firmware_path: Option, + /// Disk image path (qcow2 or raw) + pub disk_path: String, + /// API socket path for ch-remote and management + pub api_socket: String, + /// vCPUs to boot with + pub vcpus: u32, + /// Memory in MB + pub memory_mb: u32, + /// Kernel cmdline (only used for direct kernel boot) + pub cmdline: Option, + /// Extra args (raw) if you need to extend; keep minimal for Phase 2 + pub extra_args: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VmRuntime { + /// PID of cloud-hypervisor process if running + pub pid: Option, + /// Last known status: "stopped" | "running" + pub status: String, + /// Console log file path + pub log_file: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VmRecord { + pub spec: VmSpec, + pub runtime: VmRuntime, +} + +fn ensure_deps() -> Result<(), CloudHvError> { + if sal_process::which("cloud-hypervisor-static").is_none() { + return Err(CloudHvError::DependencyMissing( + "cloud-hypervisor-static not found on PATH. Install Cloud Hypervisor static binary.".into(), + )); + } + if sal_process::which("ch-remote-static").is_none() { + return Err(CloudHvError::DependencyMissing( + "ch-remote-static not found on PATH. Install Cloud Hypervisor tools (static).".into(), + )); + } + Ok(()) +} + +fn hero_vm_root() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); + Path::new(&home).join("hero/virt/vms") +} + +fn vm_dir(id: &str) -> PathBuf { + hero_vm_root().join(id) +} + +fn vm_json_path(id: &str) -> PathBuf { + vm_dir(id).join("vm.json") +} + +fn vm_log_path(id: &str) -> PathBuf { + vm_dir(id).join("logs/console.log") +} + +fn vm_pid_path(id: &str) -> PathBuf { + vm_dir(id).join("pid") +} + +fn write_json(path: &Path, value: &serde_json::Value) -> Result<(), CloudHvError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| CloudHvError::IoError(e.to_string()))?; + } + let s = serde_json::to_string_pretty(value).map_err(|e| CloudHvError::JsonError(e.to_string()))?; + fs::write(path, s).map_err(|e| CloudHvError::IoError(e.to_string())) +} + +fn read_json(path: &Path) -> Result { + let content = fs::read_to_string(path).map_err(|e| CloudHvError::IoError(e.to_string()))?; + serde_json::from_str(&content).map_err(|e| CloudHvError::JsonError(e.to_string())) +} + +fn proc_exists(pid: i64) -> bool { + #[cfg(target_os = "linux")] + { + Path::new(&format!("/proc/{}", pid)).exists() + } + #[cfg(not(target_os = "linux"))] + { + // Minimal check for non-Linux; try a kill -0 style command + let res = sal_process::run(&format!("kill -0 {}", pid)).die(false).silent(true).execute(); + res.map(|r| r.success).unwrap_or(false) + } +} + +/// Create and persist a VM spec +pub fn vm_create(spec: &VmSpec) -> Result { + // Validate inputs minimally + if spec.id.trim().is_empty() { + return Err(CloudHvError::InvalidSpec("spec.id must not be empty".into())); + } + // Validate boot method: either firmware_path exists or kernel_path exists + let has_fw = spec + .firmware_path + .as_ref() + .map(|p| Path::new(p).exists()) + .unwrap_or(false); + let has_kernel = spec + .kernel_path + .as_ref() + .map(|p| Path::new(p).exists()) + .unwrap_or(false); + + if !(has_fw || has_kernel) { + return Err(CloudHvError::InvalidSpec( + "either firmware_path or kernel_path must be set to an existing file".into(), + )); + } + + if !Path::new(&spec.disk_path).exists() { + return Err(CloudHvError::InvalidSpec(format!( + "disk_path not found: {}", + &spec.disk_path + ))); + } + if spec.vcpus == 0 { + return Err(CloudHvError::InvalidSpec("vcpus must be >= 1".into())); + } + if spec.memory_mb == 0 { + return Err(CloudHvError::InvalidSpec("memory_mb must be >= 128".into())); + } + + // Prepare directory layout + let dir = vm_dir(&spec.id); + sal_os::mkdir( + dir.to_str() + .unwrap_or_else(|| "/tmp/hero/virt/vms/__invalid__"), + ) + .map_err(|e| CloudHvError::IoError(e.to_string()))?; + let log_dir = dir.join("logs"); + sal_os::mkdir(log_dir.to_str().unwrap()).map_err(|e| CloudHvError::IoError(e.to_string()))?; + + // Persist initial record + let rec = VmRecord { + spec: spec.clone(), + runtime: VmRuntime { + pid: None, + status: "stopped".into(), + log_file: vm_log_path(&spec.id).to_string_lossy().into_owned(), + }, + }; + let value = serde_json::to_value(&rec).map_err(|e| CloudHvError::JsonError(e.to_string()))?; + write_json(&vm_json_path(&spec.id), &value)?; + + Ok(spec.id.clone()) +} + +/// Start a VM using cloud-hypervisor +pub fn vm_start(id: &str) -> Result<(), CloudHvError> { + ensure_deps()?; + + // Load record + let p = vm_json_path(id); + if !p.exists() { + return Err(CloudHvError::NotFound(format!("VM '{}' not found", id))); + } + let value = read_json(&p)?; + let mut rec: VmRecord = + serde_json::from_value(value).map_err(|e| CloudHvError::JsonError(e.to_string()))?; + + // Prepare invocation + let api_socket = if rec.spec.api_socket.trim().is_empty() { + vm_dir(id).join("api.sock").to_string_lossy().into_owned() + } else { + rec.spec.api_socket.clone() + }; + let log_file = vm_log_path(id).to_string_lossy().into_owned(); + + // Build command (minimal args for Phase 2) + // We redirect all output to log_file via shell and keep process in background with nohup + + // CH CLI flags (very common subset) + // --disk path=... uses virtio-blk by default + let mut parts: Vec = vec![ + "cloud-hypervisor-static".into(), + "--api-socket".into(), + api_socket.clone(), + ]; + + if let Some(fw) = rec.spec.firmware_path.clone() { + // Firmware boot path + parts.push("--firmware".into()); + parts.push(fw); + } else if let Some(kpath) = rec.spec.kernel_path.clone() { + // Direct kernel boot path + let cmdline = rec + .spec + .cmdline + .clone() + .unwrap_or_else(|| "console=ttyS0 reboot=k panic=1".to_string()); + parts.push("--kernel".into()); + parts.push(kpath); + parts.push("--cmdline".into()); + parts.push(cmdline); + } else { + return Err(CloudHvError::InvalidSpec( + "neither firmware_path nor kernel_path set at start time".into(), + )); + } + + parts.push("--disk".into()); + parts.push(format!("path={}", rec.spec.disk_path)); + parts.push("--cpus".into()); + parts.push(format!("boot={}", rec.spec.vcpus)); + parts.push("--memory".into()); + parts.push(format!("size={}M", rec.spec.memory_mb)); + parts.push("--serial".into()); + parts.push("tty".into()); + parts.push("--console".into()); + parts.push("off".into()); + + if let Some(extra) = rec.spec.extra_args.clone() { + for e in extra { + parts.push(e); + } + } + + let args_str = shell_join(&parts); + let script = format!( + "#!/bin/bash -e +nohup {} > '{}' 2>&1 & +echo $! > '{}' +", + args_str, + log_file, + vm_pid_path(id).to_string_lossy() + ); + + // Execute script; this will background cloud-hypervisor and return + let result = sal_process::run(&script).execute(); + match result { + Ok(res) => { + if !res.success { + return Err(CloudHvError::CommandFailed(format!( + "Failed to start VM '{}': {}", + id, res.stderr + ))); + } + } + Err(e) => { + return Err(CloudHvError::CommandFailed(format!( + "Failed to start VM '{}': {}", + id, e + ))) + } + } + + // Read PID back + let pid = match fs::read_to_string(vm_pid_path(id)) { + Ok(s) => s.trim().parse::().ok(), + Err(_) => None, + }; + + // Update state + rec.runtime.pid = pid; + rec.runtime.status = if pid.is_some() { "running".into() } else { "stopped".into() }; + rec.runtime.log_file = log_file; + rec.spec.api_socket = api_socket.clone(); + + let value = serde_json::to_value(&rec).map_err(|e| CloudHvError::JsonError(e.to_string()))?; + write_json(&vm_json_path(id), &value)?; + + Ok(()) +} + +/// Return VM record info (spec + runtime) by id +pub fn vm_info(id: &str) -> Result { + let p = vm_json_path(id); + if !p.exists() { + return Err(CloudHvError::NotFound(format!("VM '{}' not found", id))); + } + let value = read_json(&p)?; + let rec: VmRecord = serde_json::from_value(value).map_err(|e| CloudHvError::JsonError(e.to_string()))?; + Ok(rec) +} + +/// Stop a VM via ch-remote (graceful), optionally force kill +pub fn vm_stop(id: &str, force: bool) -> Result<(), CloudHvError> { + ensure_deps().ok(); // best-effort; we might still force-kill + + let p = vm_json_path(id); + if !p.exists() { + return Err(CloudHvError::NotFound(format!("VM '{}' not found", id))); + } + let value = read_json(&p)?; + let mut rec: VmRecord = + serde_json::from_value(value).map_err(|e| CloudHvError::JsonError(e.to_string()))?; + + // Attempt graceful shutdown if api socket known + if !rec.spec.api_socket.trim().is_empty() { + let cmd = format!("ch-remote-static --api-socket {} shutdown", rec.spec.api_socket); + let _ = sal_process::run(&cmd).die(false).silent(true).execute(); + } + + // Wait a bit for process to exit + if let Some(pid) = rec.runtime.pid { + for _ in 0..20 { + if !proc_exists(pid) { + break; + } + thread::sleep(Duration::from_millis(200)); + } + // If still alive and force, kill -9 + if proc_exists(pid) && force { + let _ = sal_process::run(&format!("kill -9 {}", pid)) + .die(false) + .silent(true) + .execute(); + } + } + + // Update state + rec.runtime.status = "stopped".into(); + rec.runtime.pid = None; + let value = serde_json::to_value(&rec).map_err(|e| CloudHvError::JsonError(e.to_string()))?; + write_json(&vm_json_path(id), &value)?; + + // Remove pid file + let _ = fs::remove_file(vm_pid_path(id)); + + Ok(()) +} + +/// Delete a VM definition; optionally delete disks. +pub fn vm_delete(id: &str, delete_disks: bool) -> Result<(), CloudHvError> { + let p = vm_json_path(id); + if !p.exists() { + return Err(CloudHvError::NotFound(format!("VM '{}' not found", id))); + } + let rec: VmRecord = serde_json::from_value(read_json(&p)?) + .map_err(|e| CloudHvError::JsonError(e.to_string()))?; + + // Refuse to delete if still running + if let Some(pid) = rec.runtime.pid { + if proc_exists(pid) { + return Err(CloudHvError::CommandFailed( + "VM appears to be running; stop it first".into(), + )); + } + } + + if delete_disks { + let _ = fs::remove_file(&rec.spec.disk_path); + } + + let d = vm_dir(id); + fs::remove_dir_all(&d).map_err(|e| CloudHvError::IoError(e.to_string()))?; + Ok(()) +} + +/// List all VMs +pub fn vm_list() -> Result, CloudHvError> { + let root = hero_vm_root(); + if !root.exists() { + return Ok(vec![]); + } + let mut out = vec![]; + for entry in fs::read_dir(&root).map_err(|e| CloudHvError::IoError(e.to_string()))? { + let entry = entry.map_err(|e| CloudHvError::IoError(e.to_string()))?; + let p = entry.path(); + if !p.is_dir() { + continue; + } + let vm_json = p.join("vm.json"); + if !vm_json.exists() { + continue; + } + let rec: VmRecord = serde_json::from_value(read_json(&vm_json)?) + .map_err(|e| CloudHvError::JsonError(e.to_string()))?; + + out.push(rec); + } + Ok(out) +} + +/// Render a shell-safe command string from vector of tokens +fn shell_join(parts: &Vec) -> String { + let mut s = String::new(); + for (i, p) in parts.iter().enumerate() { + if i > 0 { + s.push(' '); + } + s.push_str(&shell_escape(p)); + } + s +} + +fn shell_escape(s: &str) -> String { + if s.is_empty() { + return "''".into(); + } + if s + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_./=:".contains(c)) + { + return s.into(); + } + // single-quote wrap, escape existing quotes + let mut out = String::from("'"); + for ch in s.chars() { + if ch == '\'' { + out.push_str("'\"'\"'"); + } else { + out.push(ch); + } + } + out.push('\''); + out +} \ No newline at end of file diff --git a/packages/system/virt/src/lib.rs b/packages/system/virt/src/lib.rs index b146f75..103edbf 100644 --- a/packages/system/virt/src/lib.rs +++ b/packages/system/virt/src/lib.rs @@ -25,6 +25,7 @@ pub mod buildah; pub mod nerdctl; pub mod rfs; pub mod qcow2; +pub mod cloudhv; pub mod rhai; diff --git a/packages/system/virt/src/mod.rs b/packages/system/virt/src/mod.rs index 77924f6..28fae1e 100644 --- a/packages/system/virt/src/mod.rs +++ b/packages/system/virt/src/mod.rs @@ -1,4 +1,5 @@ pub mod buildah; pub mod nerdctl; pub mod rfs; -pub mod qcow2; \ No newline at end of file +pub mod qcow2; +pub mod cloudhv; \ No newline at end of file diff --git a/packages/system/virt/src/rhai.rs b/packages/system/virt/src/rhai.rs index 68e7de4..e6642d0 100644 --- a/packages/system/virt/src/rhai.rs +++ b/packages/system/virt/src/rhai.rs @@ -9,6 +9,7 @@ pub mod buildah; pub mod nerdctl; pub mod rfs; pub mod qcow2; +pub mod cloudhv; /// Register all Virt module functions with the Rhai engine /// @@ -32,6 +33,9 @@ pub fn register_virt_module(engine: &mut Engine) -> Result<(), Box(r: Result) -> Result> { + r.map_err(|e| { + Box::new(EvalAltResult::ErrorRuntime( + format!("cloudhv error: {}", e).into(), + rhai::Position::NONE, + )) + }) +} + +// Map conversions + +fn map_to_vmspec(spec: Map) -> Result> { + let id = must_get_string(&spec, "id")?; + let kernel_path = get_string(&spec, "kernel_path"); + let firmware_path = get_string(&spec, "firmware_path"); + let disk_path = must_get_string(&spec, "disk_path")?; + let api_socket = get_string(&spec, "api_socket").unwrap_or_else(|| "".to_string()); + let vcpus = get_int(&spec, "vcpus").unwrap_or(1) as u32; + let memory_mb = get_int(&spec, "memory_mb").unwrap_or(512) as u32; + let cmdline = get_string(&spec, "cmdline"); + let extra_args = get_string_array(&spec, "extra_args"); + + Ok(VmSpec { + id, + kernel_path, + firmware_path, + disk_path, + api_socket, + vcpus, + memory_mb, + cmdline, + extra_args, + }) +} + +fn vmspec_to_map(s: &VmSpec) -> Map { + let mut m = Map::new(); + m.insert("id".into(), s.id.clone().into()); + if let Some(k) = &s.kernel_path { + m.insert("kernel_path".into(), k.clone().into()); + } else { + m.insert("kernel_path".into(), Dynamic::UNIT); + } + if let Some(fw) = &s.firmware_path { + m.insert("firmware_path".into(), fw.clone().into()); + } else { + m.insert("firmware_path".into(), Dynamic::UNIT); + } + m.insert("disk_path".into(), s.disk_path.clone().into()); + m.insert("api_socket".into(), s.api_socket.clone().into()); + m.insert("vcpus".into(), (s.vcpus as i64).into()); + m.insert("memory_mb".into(), (s.memory_mb as i64).into()); + if let Some(c) = &s.cmdline { + m.insert("cmdline".into(), c.clone().into()); + } else { + m.insert("cmdline".into(), Dynamic::UNIT); + } + if let Some(arr) = &s.extra_args { + let mut a = Array::new(); + for s in arr { + a.push(s.clone().into()); + } + m.insert("extra_args".into(), a.into()); + } else { + m.insert("extra_args".into(), Dynamic::UNIT); + } + m +} + +fn vmruntime_to_map(r: &VmRuntime) -> Map { + let mut m = Map::new(); + match r.pid { + Some(p) => m.insert("pid".into(), (p as i64).into()), + None => m.insert("pid".into(), Dynamic::UNIT), + }; + m.insert("status".into(), r.status.clone().into()); + m.insert("log_file".into(), r.log_file.clone().into()); + m +} + +fn vmrecord_to_map(rec: &VmRecord) -> Map { + let mut m = Map::new(); + m.insert("spec".into(), vmspec_to_map(&rec.spec).into()); + m.insert("runtime".into(), vmruntime_to_map(&rec.runtime).into()); + m +} + +// Helpers for reading Rhai Map fields + +fn must_get_string(m: &Map, k: &str) -> Result> { + match m.get(k) { + Some(v) if v.is_string() => Ok(v.clone().cast::()), + _ => Err(Box::new(EvalAltResult::ErrorRuntime( + format!("missing or non-string field '{}'", k).into(), + rhai::Position::NONE, + ))), + } +} + +fn get_string(m: &Map, k: &str) -> Option { + m.get(k).and_then(|v| if v.is_string() { Some(v.clone().cast::()) } else { None }) +} + +fn get_int(m: &Map, k: &str) -> Option { + m.get(k).and_then(|v| v.as_int().ok()) +} + +fn get_string_array(m: &Map, k: &str) -> Option> { + m.get(k).and_then(|v| { + if v.is_array() { + let arr = v.clone().cast::(); + let mut out = vec![]; + for it in arr { + if it.is_string() { + out.push(it.cast::()); + } + } + Some(out) + } else { + None + } + }) +} + +// Rhai-exposed functions + +pub fn cloudhv_vm_create(spec: Map) -> Result> { + let s = map_to_vmspec(spec)?; + hv_to_rhai(cloudhv::vm_create(&s)) +} + +pub fn cloudhv_vm_start(id: &str) -> Result<(), Box> { + hv_to_rhai(cloudhv::vm_start(id)) +} + +pub fn cloudhv_vm_stop(id: &str, force: bool) -> Result<(), Box> { + hv_to_rhai(cloudhv::vm_stop(id, force)) +} + +pub fn cloudhv_vm_delete(id: &str, delete_disks: bool) -> Result<(), Box> { + hv_to_rhai(cloudhv::vm_delete(id, delete_disks)) +} + +pub fn cloudhv_vm_list() -> Result> { + let vms = hv_to_rhai(cloudhv::vm_list())?; + let mut arr = Array::new(); + for rec in vms { + arr.push(vmrecord_to_map(&rec).into()); + } + Ok(arr) +} + +pub fn cloudhv_vm_info(id: &str) -> Result> { + let rec = hv_to_rhai(cloudhv::vm_info(id))?; + Ok(vmrecord_to_map(&rec)) +} + +// Module registration + +pub fn register_cloudhv_module(engine: &mut Engine) -> Result<(), Box> { + engine.register_fn("cloudhv_vm_create", cloudhv_vm_create); + engine.register_fn("cloudhv_vm_start", cloudhv_vm_start); + engine.register_fn("cloudhv_vm_stop", cloudhv_vm_stop); + engine.register_fn("cloudhv_vm_delete", cloudhv_vm_delete); + engine.register_fn("cloudhv_vm_list", cloudhv_vm_list); + engine.register_fn("cloudhv_vm_info", cloudhv_vm_info); + Ok(()) +} \ No newline at end of file diff --git a/packages/system/virt/tests/rhai/04_qcow2_basic.rhai b/packages/system/virt/tests/rhai/04_qcow2_basic.rhai index 13845e3..85c3a32 100644 --- a/packages/system/virt/tests/rhai/04_qcow2_basic.rhai +++ b/packages/system/virt/tests/rhai/04_qcow2_basic.rhai @@ -67,17 +67,18 @@ print("✓ snapshot deleted: s1"); // Optional: Base image builder (commented to avoid big downloads by default) // Uncomment to test manually on a dev machine with bandwidth. -// print("\n--- Optional: Build Ubuntu 24.04 Base ---"); -// let base_dir = "/tmp/virt_images"; -// let base = qcow2_build_ubuntu_24_04_base(base_dir, 10); -// if base.is_err() { -// print(`⚠️ base build failed or skipped: ${base.unwrap_err()}`); -// } else { -// let m = base.unwrap(); -// print(`✓ Base image path: ${m.base_image_path}`); -// print(`✓ Base snapshot: ${m.snapshot}`); -// print(`✓ Source URL: ${m.url}`); -// if m.resized_to_gb != () { print(`✓ Resized to: ${m.resized_to_gb}G`); } -// } +print("\n--- Optional: Build Ubuntu 24.04 Base ---"); +let base_dir = "/tmp/virt_images"; +let m; +try { + m = qcow2_build_ubuntu_24_04_base(base_dir, 10); +} catch (err) { + print(`⚠️ base build failed or skipped: ${err}`); + exit(); +} +print(`✓ Base image path: ${m.base_image_path}`); +print(`✓ Base snapshot: ${m.snapshot}`); +print(`✓ Source URL: ${m.url}`); +if m.resized_to_gb != () { print(`✓ Resized to: ${m.resized_to_gb}G`); } print("\n=== QCOW2 Basic Tests Completed ==="); \ No newline at end of file diff --git a/packages/system/virt/tests/rhai/05_cloudhv_basic.rhai b/packages/system/virt/tests/rhai/05_cloudhv_basic.rhai new file mode 100644 index 0000000..cb2510f --- /dev/null +++ b/packages/system/virt/tests/rhai/05_cloudhv_basic.rhai @@ -0,0 +1,129 @@ +// Basic Cloud Hypervisor SAL smoke test (minimal) +// - Skips gracefully if dependencies or inputs are missing +// - Creates a VM spec, optionally starts/stops it if all inputs are available + +print("=== Cloud Hypervisor Basic Tests ==="); + +// Dependency checks (static binaries only) +let chs = which("cloud-hypervisor-static"); +let chrs = which("ch-remote-static"); + +// Normalize which() results: () or "" both mean missing (depending on SAL which variant) +let ch_missing = (chs == () || chs == ""); +let chr_missing = (chrs == () || chrs == ""); + +if ch_missing || chr_missing { + print("⚠️ cloud-hypervisor-static and/or ch-remote-static not available - skipping CloudHV tests"); + print("Install Cloud Hypervisor static binaries to run these tests."); + print("=== CloudHV Tests Skipped ==="); + exit(); +} + +// Inputs (adjust these for your environment) +// Prefer firmware boot if firmware is available; otherwise fallback to direct kernel boot. +let firmware_path = "/tmp/virt_images/hypervisor-fw"; +let kernel_path = "/path/to/vmlinux"; // optional when firmware_path is present + +// We can reuse the base image from the QCOW2 test/builder if present. +let disk_path = "/tmp/virt_images/noble-server-cloudimg-amd64.img"; + +// Validate inputs +let missing = false; +let have_firmware = exist(firmware_path); +let have_kernel = exist(kernel_path); +if !have_firmware && !have_kernel { + print(`⚠️ neither firmware_path (${firmware_path}) nor kernel_path (${kernel_path}) found (start/stop will be skipped)`); + missing = true; +} +if !exist(disk_path) { + print(`⚠️ disk_path not found: ${disk_path} (start/stop will be skipped)`); + missing = true; +} + +// Unique id +let rid = run_silent("date +%s%N"); +let suffix = if rid.success && rid.stdout != "" { rid.stdout.trim() } else { "100000" }; +let vm_id = `testvm_${suffix}`; + +print("\n--- Test 1: Create VM definition ---"); +let spec = #{ + "id": vm_id, + "disk_path": disk_path, + "api_socket": "", // default under VM dir + "vcpus": 1, + "memory_mb": 1024, + // For firmware boot: + // Provide firmware_path only if it exists + // For kernel boot: + // Provide kernel_path and optionally a cmdline +}; +if have_firmware { + spec.firmware_path = firmware_path; +} else if have_kernel { + spec.kernel_path = kernel_path; + spec.cmdline = "console=ttyS0 reboot=k panic=1"; +} +// "extra_args": can be added if needed, e.g.: +// spec.extra_args = ["--rng", "src=/dev/urandom"]; + +try { + let created_id = cloudhv_vm_create(spec); + print(`✓ VM created: ${created_id}`); +} catch (err) { + print(`❌ VM create failed: ${err}`); + print("=== CloudHV Tests Aborted ==="); + exit(); +} + +print("\n--- Test 2: VM info ---"); +try { + let info = cloudhv_vm_info(vm_id); + print(`✓ VM info loaded: id=${info.spec.id}, status=${info.runtime.status}`); +} catch (err) { + print(`❌ VM info failed: ${err}`); + print("=== CloudHV Tests Aborted ==="); + exit(); +} + +print("\n--- Test 3: VM list ---"); +try { + let vms = cloudhv_vm_list(); + print(`✓ VM list size: ${vms.len()}`); +} catch (err) { + print(`❌ VM list failed: ${err}`); + print("=== CloudHV Tests Aborted ==="); + exit(); +} + +// Start/Stop only if inputs exist +if !missing { + print("\n--- Test 4: Start VM ---"); + try { + cloudhv_vm_start(vm_id); + print("✓ VM start invoked"); + } catch (err) { + print(`⚠️ VM start failed (this can happen if kernel/cmdline are incompatible): ${err}`); + } + + print("\n--- Test 5: Stop VM (graceful) ---"); + try { + cloudhv_vm_stop(vm_id, false); + print("✓ VM stop invoked (graceful)"); + } catch (err) { + print(`⚠️ VM stop failed: ${err}`); + } +} else { + print("\n⚠️ Skipping start/stop because required inputs are missing."); +} + +print("\n--- Test 6: Delete VM definition ---"); +try { + cloudhv_vm_delete(vm_id, false); + print("✓ VM deleted"); +} catch (err) { + print(`❌ VM delete failed: ${err}`); + print("=== CloudHV Tests Aborted ==="); + exit(); +} + +print("\n=== Cloud Hypervisor Basic Tests Completed ==="); \ No newline at end of file