qcow2 SAL + rhai script to test functionality
This commit is contained in:
		| @@ -24,6 +24,7 @@ | ||||
| pub mod buildah; | ||||
| pub mod nerdctl; | ||||
| pub mod rfs; | ||||
| pub mod qcow2; | ||||
|  | ||||
| pub mod rhai; | ||||
|  | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| pub mod buildah; | ||||
| pub mod nerdctl; | ||||
| pub mod rfs; | ||||
| pub mod rfs; | ||||
| pub mod qcow2; | ||||
							
								
								
									
										200
									
								
								packages/system/virt/src/qcow2/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								packages/system/virt/src/qcow2/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -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::CommandResult, Qcow2Error> { | ||||
|     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<String, Qcow2Error> { | ||||
|     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<Value, Qcow2Error> { | ||||
|     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::<Value>(&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<String>, | ||||
|     pub name: Option<String>, | ||||
|     pub vm_state_size: Option<i64>, | ||||
|     pub date_sec: Option<i64>, | ||||
|     pub date_nsec: Option<i64>, | ||||
|     pub vm_clock_nsec: Option<i64>, | ||||
| } | ||||
|  | ||||
| /// List snapshots on a qcow2 image (offline) | ||||
| pub fn snapshot_list(path: &str) -> Result<Vec<Qcow2Snapshot>, 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<i64>, | ||||
| } | ||||
|  | ||||
| /// 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<i64>) -> Result<BuildBaseResult, Qcow2Error> { | ||||
|     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), | ||||
|     }) | ||||
| } | ||||
| @@ -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<EvalAltResult | ||||
|     // Register RFS module functions | ||||
|     rfs::register_rfs_module(engine)?; | ||||
|  | ||||
|     // Register QCOW2 module functions | ||||
|     qcow2::register_qcow2_module(engine)?; | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| @@ -35,3 +39,4 @@ pub fn register_virt_module(engine: &mut Engine) -> Result<(), Box<EvalAltResult | ||||
| pub use buildah::{bah_new, register_bah_module}; | ||||
| pub use nerdctl::register_nerdctl_module; | ||||
| pub use rfs::register_rfs_module; | ||||
| pub use qcow2::register_qcow2_module; | ||||
|   | ||||
							
								
								
									
										139
									
								
								packages/system/virt/src/rhai/qcow2.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								packages/system/virt/src/rhai/qcow2.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,139 @@ | ||||
| use crate::qcow2; | ||||
| use crate::qcow2::{BuildBaseResult, Qcow2Error, Qcow2Snapshot}; | ||||
| use rhai::{Array, Dynamic, Engine, EvalAltResult, Map}; | ||||
| use serde_json::Value; | ||||
|  | ||||
| // Convert Qcow2Error to Rhai error | ||||
| fn qcow2_error_to_rhai<T>(result: Result<T, Qcow2Error>) -> Result<T, Box<EvalAltResult>> { | ||||
|     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<String, Box<EvalAltResult>> { | ||||
|     qcow2_error_to_rhai(qcow2::create(path, size_gb)) | ||||
| } | ||||
|  | ||||
| pub fn qcow2_info(path: &str) -> Result<Dynamic, Box<EvalAltResult>> { | ||||
|     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<EvalAltResult>> { | ||||
|     qcow2_error_to_rhai(qcow2::snapshot_create(path, name)) | ||||
| } | ||||
|  | ||||
| pub fn qcow2_snapshot_delete(path: &str, name: &str) -> Result<(), Box<EvalAltResult>> { | ||||
|     qcow2_error_to_rhai(qcow2::snapshot_delete(path, name)) | ||||
| } | ||||
|  | ||||
| pub fn qcow2_snapshot_list(path: &str) -> Result<Array, Box<EvalAltResult>> { | ||||
|     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<Map, Box<EvalAltResult>> { | ||||
|     // 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<EvalAltResult>> { | ||||
|     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(()) | ||||
| } | ||||
							
								
								
									
										82
									
								
								packages/system/virt/tests/rhai/04_qcow2_basic.rhai
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								packages/system/virt/tests/rhai/04_qcow2_basic.rhai
									
									
									
									
									
										Normal file
									
								
							| @@ -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 ==="); | ||||
		Reference in New Issue
	
	Block a user