diff --git a/packages/system/virt/src/lib.rs b/packages/system/virt/src/lib.rs index 977ab1c..b146f75 100644 --- a/packages/system/virt/src/lib.rs +++ b/packages/system/virt/src/lib.rs @@ -24,6 +24,7 @@ pub mod buildah; pub mod nerdctl; pub mod rfs; +pub mod qcow2; pub mod rhai; diff --git a/packages/system/virt/src/mod.rs b/packages/system/virt/src/mod.rs index 6f7bf89..77924f6 100644 --- a/packages/system/virt/src/mod.rs +++ b/packages/system/virt/src/mod.rs @@ -1,3 +1,4 @@ pub mod buildah; pub mod nerdctl; -pub mod rfs; \ No newline at end of file +pub mod rfs; +pub mod qcow2; \ No newline at end of file diff --git a/packages/system/virt/src/qcow2/mod.rs b/packages/system/virt/src/qcow2/mod.rs new file mode 100644 index 0000000..2a0ee6b --- /dev/null +++ b/packages/system/virt/src/qcow2/mod.rs @@ -0,0 +1,200 @@ +use serde_json::Value; +use std::error::Error; +use std::fmt; +use std::fs; +use std::path::Path; + +use sal_os; +use sal_process::{self, RunError}; + +/// Error type for qcow2 operations +#[derive(Debug)] +pub enum Qcow2Error { + /// Failed to execute a system command + CommandExecutionFailed(String), + /// Command executed but returned non-zero or failed semantics + CommandFailed(String), + /// JSON parsing error + JsonParseError(String), + /// IO error (filesystem) + IoError(String), + /// Dependency missing or invalid input + Other(String), +} + +impl fmt::Display for Qcow2Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Qcow2Error::CommandExecutionFailed(e) => write!(f, "Command execution failed: {}", e), + Qcow2Error::CommandFailed(e) => write!(f, "{}", e), + Qcow2Error::JsonParseError(e) => write!(f, "JSON parse error: {}", e), + Qcow2Error::IoError(e) => write!(f, "IO error: {}", e), + Qcow2Error::Other(e) => write!(f, "{}", e), + } + } +} + +impl Error for Qcow2Error {} + +fn from_run_error(e: RunError) -> Qcow2Error { + Qcow2Error::CommandExecutionFailed(e.to_string()) +} + +fn ensure_parent_dir(path: &str) -> Result<(), Qcow2Error> { + if let Some(parent) = Path::new(path).parent() { + fs::create_dir_all(parent).map_err(|e| Qcow2Error::IoError(e.to_string()))?; + } + Ok(()) +} + +fn ensure_qemu_img() -> Result<(), Qcow2Error> { + if sal_process::which("qemu-img").is_none() { + return Err(Qcow2Error::Other( + "qemu-img not found on PATH. Please install qemu-utils (Debian/Ubuntu) or the QEMU tools for your distro.".to_string(), + )); + } + Ok(()) +} + +fn run_quiet(cmd: &str) -> Result { + sal_process::run(cmd) + .silent(true) + .execute() + .map_err(from_run_error) + .and_then(|res| { + if res.success { + Ok(res) + } else { + Err(Qcow2Error::CommandFailed(format!( + "Command failed (code {}): {}\n{}", + res.code, cmd, res.stderr + ))) + } + }) +} + +/// Create a qcow2 image at path with a given virtual size (in GiB) +pub fn create(path: &str, size_gb: i64) -> Result { + ensure_qemu_img()?; + if size_gb <= 0 { + return Err(Qcow2Error::Other( + "size_gb must be > 0 for qcow2.create".to_string(), + )); + } + ensure_parent_dir(path)?; + let cmd = format!("qemu-img create -f qcow2 {} {}G", path, size_gb); + run_quiet(&cmd)?; + Ok(path.to_string()) +} + +/// Return qemu-img info as a JSON value +pub fn info(path: &str) -> Result { + ensure_qemu_img()?; + if !Path::new(path).exists() { + return Err(Qcow2Error::IoError(format!("Image not found: {}", path))); + } + let cmd = format!("qemu-img info --output=json {}", path); + let res = run_quiet(&cmd)?; + serde_json::from_str::(&res.stdout).map_err(|e| Qcow2Error::JsonParseError(e.to_string())) +} + +/// Create an offline snapshot on a qcow2 image +pub fn snapshot_create(path: &str, name: &str) -> Result<(), Qcow2Error> { + ensure_qemu_img()?; + if name.trim().is_empty() { + return Err(Qcow2Error::Other("snapshot name cannot be empty".to_string())); + } + let cmd = format!("qemu-img snapshot -c {} {}", name, path); + run_quiet(&cmd).map(|_| ()) +} + +/// Delete a snapshot on a qcow2 image +pub fn snapshot_delete(path: &str, name: &str) -> Result<(), Qcow2Error> { + ensure_qemu_img()?; + if name.trim().is_empty() { + return Err(Qcow2Error::Other("snapshot name cannot be empty".to_string())); + } + let cmd = format!("qemu-img snapshot -d {} {}", name, path); + run_quiet(&cmd).map(|_| ()) +} + +/// Snapshot representation (subset of qemu-img info snapshots) +#[derive(Debug, Clone)] +pub struct Qcow2Snapshot { + pub id: Option, + pub name: Option, + pub vm_state_size: Option, + pub date_sec: Option, + pub date_nsec: Option, + pub vm_clock_nsec: Option, +} + +/// List snapshots on a qcow2 image (offline) +pub fn snapshot_list(path: &str) -> Result, Qcow2Error> { + let v = info(path)?; + let mut out = Vec::new(); + if let Some(snaps) = v.get("snapshots").and_then(|s| s.as_array()) { + for s in snaps { + let snap = Qcow2Snapshot { + id: s.get("id").and_then(|x| x.as_str()).map(|s| s.to_string()), + name: s.get("name").and_then(|x| x.as_str()).map(|s| s.to_string()), + vm_state_size: s.get("vm-state-size").and_then(|x| x.as_i64()), + date_sec: s.get("date-sec").and_then(|x| x.as_i64()), + date_nsec: s.get("date-nsec").and_then(|x| x.as_i64()), + vm_clock_nsec: s.get("vm-clock-nsec").and_then(|x| x.as_i64()), + }; + out.push(snap); + } + } + Ok(out) +} + +/// Result for building the base image +#[derive(Debug, Clone)] +pub struct BuildBaseResult { + pub base_image_path: String, + pub snapshot: String, + pub url: String, + pub resized_to_gb: Option, +} + +/// Build/download Ubuntu 24.04 base image (Noble cloud image), optionally resize, and create a base snapshot +pub fn build_ubuntu_24_04_base(dest_dir: &str, size_gb: Option) -> Result { + ensure_qemu_img()?; + + // Ensure destination directory exists + sal_os::mkdir(dest_dir).map_err(|e| Qcow2Error::IoError(e.to_string()))?; + + // Canonical Ubuntu Noble cloud image (amd64) + let url = "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"; + + // Build destination path + let dest_dir_sanitized = dest_dir.trim_end_matches('/'); + let dest_path = format!("{}/noble-server-cloudimg-amd64.img", dest_dir_sanitized); + + // Download if not present + let path_obj = Path::new(&dest_path); + if !path_obj.exists() { + // 50MB minimum for sanity; the actual image is much larger + sal_os::download_file(url, &dest_path, 50_000) + .map_err(|e| Qcow2Error::IoError(e.to_string()))?; + } + + // Resize if requested + if let Some(sz) = size_gb { + if sz > 0 { + let cmd = format!("qemu-img resize {} {}G", dest_path, sz); + run_quiet(&cmd)?; + } + } + + // Create "base" snapshot + snapshot_create(&dest_path, "base")?; + + Ok(BuildBaseResult { + base_image_path: dest_path, + snapshot: "base".to_string(), + url: url.to_string(), + resized_to_gb: size_gb.filter(|v| *v > 0), + }) +} \ No newline at end of file diff --git a/packages/system/virt/src/rhai.rs b/packages/system/virt/src/rhai.rs index d932a77..68e7de4 100644 --- a/packages/system/virt/src/rhai.rs +++ b/packages/system/virt/src/rhai.rs @@ -8,6 +8,7 @@ use rhai::{Engine, EvalAltResult}; pub mod buildah; pub mod nerdctl; pub mod rfs; +pub mod qcow2; /// Register all Virt module functions with the Rhai engine /// @@ -28,6 +29,9 @@ pub fn register_virt_module(engine: &mut Engine) -> Result<(), Box Result<(), Box(result: Result) -> Result> { + result.map_err(|e| { + Box::new(EvalAltResult::ErrorRuntime( + format!("qcow2 error: {}", e).into(), + rhai::Position::NONE, + )) + }) +} + +// Convert serde_json::Value to Rhai Dynamic recursively (maps, arrays, scalars) +fn json_to_dynamic(v: &Value) -> Dynamic { + match v { + Value::Null => Dynamic::UNIT, + Value::Bool(b) => (*b).into(), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + i.into() + } else { + // Avoid float dependency differences; fall back to string + n.to_string().into() + } + } + Value::String(s) => s.clone().into(), + Value::Array(arr) => { + let mut a = Array::new(); + for item in arr { + a.push(json_to_dynamic(item)); + } + a.into() + } + Value::Object(obj) => { + let mut m = Map::new(); + for (k, val) in obj { + m.insert(k.into(), json_to_dynamic(val)); + } + m.into() + } + } +} + +// Wrappers exposed to Rhai + +pub fn qcow2_create(path: &str, size_gb: i64) -> Result> { + qcow2_error_to_rhai(qcow2::create(path, size_gb)) +} + +pub fn qcow2_info(path: &str) -> Result> { + let v = qcow2_error_to_rhai(qcow2::info(path))?; + Ok(json_to_dynamic(&v)) +} + +pub fn qcow2_snapshot_create(path: &str, name: &str) -> Result<(), Box> { + qcow2_error_to_rhai(qcow2::snapshot_create(path, name)) +} + +pub fn qcow2_snapshot_delete(path: &str, name: &str) -> Result<(), Box> { + qcow2_error_to_rhai(qcow2::snapshot_delete(path, name)) +} + +pub fn qcow2_snapshot_list(path: &str) -> Result> { + let snaps = qcow2_error_to_rhai(qcow2::snapshot_list(path))?; + let mut arr = Array::new(); + for s in snaps { + arr.push(snapshot_to_map(&s).into()); + } + Ok(arr) +} + +fn snapshot_to_map(s: &Qcow2Snapshot) -> Map { + let mut m = Map::new(); + if let Some(id) = &s.id { + m.insert("id".into(), id.clone().into()); + } else { + m.insert("id".into(), Dynamic::UNIT); + } + if let Some(name) = &s.name { + m.insert("name".into(), name.clone().into()); + } else { + m.insert("name".into(), Dynamic::UNIT); + } + if let Some(v) = s.vm_state_size { + m.insert("vm_state_size".into(), v.into()); + } else { + m.insert("vm_state_size".into(), Dynamic::UNIT); + } + if let Some(v) = s.date_sec { + m.insert("date_sec".into(), v.into()); + } else { + m.insert("date_sec".into(), Dynamic::UNIT); + } + if let Some(v) = s.date_nsec { + m.insert("date_nsec".into(), v.into()); + } else { + m.insert("date_nsec".into(), Dynamic::UNIT); + } + if let Some(v) = s.vm_clock_nsec { + m.insert("vm_clock_nsec".into(), v.into()); + } else { + m.insert("vm_clock_nsec".into(), Dynamic::UNIT); + } + m +} + +pub fn qcow2_build_ubuntu_24_04_base( + dest_dir: &str, + size_gb: i64, +) -> Result> { + // size_gb: pass None if <=0 + let size_opt = if size_gb > 0 { Some(size_gb) } else { None }; + let r: BuildBaseResult = qcow2_error_to_rhai(qcow2::build_ubuntu_24_04_base(dest_dir, size_opt))?; + let mut m = Map::new(); + m.insert("base_image_path".into(), r.base_image_path.into()); + m.insert("snapshot".into(), r.snapshot.into()); + m.insert("url".into(), r.url.into()); + if let Some(sz) = r.resized_to_gb { + m.insert("resized_to_gb".into(), sz.into()); + } else { + m.insert("resized_to_gb".into(), Dynamic::UNIT); + } + Ok(m) +} + +// Module registration + +pub fn register_qcow2_module(engine: &mut Engine) -> Result<(), Box> { + engine.register_fn("qcow2_create", qcow2_create); + engine.register_fn("qcow2_info", qcow2_info); + engine.register_fn("qcow2_snapshot_create", qcow2_snapshot_create); + engine.register_fn("qcow2_snapshot_delete", qcow2_snapshot_delete); + engine.register_fn("qcow2_snapshot_list", qcow2_snapshot_list); + engine.register_fn("qcow2_build_ubuntu_24_04_base", qcow2_build_ubuntu_24_04_base); + 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 new file mode 100644 index 0000000..696f93b --- /dev/null +++ b/packages/system/virt/tests/rhai/04_qcow2_basic.rhai @@ -0,0 +1,82 @@ +// Basic tests for QCOW2 SAL (offline, will skip if qemu-img is not present) + +print("=== QCOW2 Basic Tests ==="); + +// Dependency check +let qemu = which("qemu-img"); +if qemu == () { + print("⚠️ qemu-img not available - skipping QCOW2 tests"); + print("Install qemu-utils (Debian/Ubuntu) or QEMU tools for your distro."); + print("=== QCOW2 Tests Skipped ==="); + exit(); +} + +// Helper: unique temp path +let now = 0; +try { + // if process module exists you could pull a timestamp; fallback to random-ish suffix + now = 100000 + (rand() % 100000); +} catch (err) { + now = 100000 + (rand() % 100000); +} +let img_path = `/tmp/qcow2_test_${now}.img`; + +print("\n--- Test 1: Create image ---"); +let create_res = qcow2_create(img_path, 1); +if create_res.is_err() { + print(`❌ Create failed: ${create_res.unwrap_err()}`); + exit(); +} +print(`✓ Created qcow2: ${img_path}`); + +print("\n--- Test 2: Info ---"); +let info_res = qcow2_info(img_path); +if info_res.is_err() { + print(`❌ Info failed: ${info_res.unwrap_err()}`); + exit(); +} +let info = info_res.unwrap(); +print("✓ Info fetched"); +if info.format != () { print(` format: ${info.format}`); } +if info["virtual-size"] != () { print(` virtual-size: ${info["virtual-size"]}`); } + +print("\n--- Test 3: Snapshot create/list/delete (offline) ---"); +let snap_name = "s1"; +let screate = qcow2_snapshot_create(img_path, snap_name); +if screate.is_err() { + print(`❌ snapshot_create failed: ${screate.unwrap_err()}`); + exit(); +} +print("✓ snapshot created: s1"); + +let slist = qcow2_snapshot_list(img_path); +if slist.is_err() { + print(`❌ snapshot_list failed: ${slist.unwrap_err()}`); + exit(); +} +let snaps = slist.unwrap(); +print(`✓ snapshot_list ok, count=${snaps.len()}`); + +let sdel = qcow2_snapshot_delete(img_path, snap_name); +if sdel.is_err() { + print(`❌ snapshot_delete failed: ${sdel.unwrap_err()}`); + exit(); +} +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=== QCOW2 Basic Tests Completed ==="); \ No newline at end of file