WIP: automating VM deployment
This commit is contained in:
		
							
								
								
									
										170
									
								
								packages/system/virt/src/cloudhv/builder.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										170
									
								
								packages/system/virt/src/cloudhv/builder.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,170 @@ | ||||
| use crate::cloudhv::{vm_create, vm_start, CloudHvError, VmSpec}; | ||||
| use crate::image_prep::{image_prepare, Flavor as ImgFlavor, ImagePrepOptions, NetPlanOpts}; | ||||
| use sal_process; | ||||
|  | ||||
| /// Cloud Hypervisor VM Builder focused on Rhai ergonomics. | ||||
| /// | ||||
| /// Defaults enforced: | ||||
| /// - kernel: /images/hypervisor-fw (firmware file in images directory) | ||||
| /// - seccomp: false (pushed via extra args) | ||||
| /// - serial: tty, console: off (already added by vm_start) | ||||
| /// - cmdline: "console=ttyS0 root=/dev/vda1 rw" | ||||
| /// - vcpus: 2 | ||||
| /// - memory_mb: 2048 | ||||
| /// | ||||
| /// Disk can be provided directly or prepared from a flavor (/images source). | ||||
| #[derive(Debug, Clone)] | ||||
| pub struct CloudHvBuilder { | ||||
|     id: String, | ||||
|     disk_path: Option<String>, | ||||
|     flavor: Option<ImgFlavor>, | ||||
|     memory_mb: u32, | ||||
|     vcpus: u32, | ||||
|     cmdline: Option<String>, | ||||
|     extra_args: Vec<String>, | ||||
|     no_default_net: bool, | ||||
| } | ||||
|  | ||||
| impl CloudHvBuilder { | ||||
|     pub fn new(id: &str) -> Self { | ||||
|         Self { | ||||
|             id: id.to_string(), | ||||
|             disk_path: None, | ||||
|             flavor: None, | ||||
|             memory_mb: 2048, | ||||
|             vcpus: 2, | ||||
|             cmdline: Some("console=ttyS0 root=/dev/vda1 rw".to_string()), | ||||
|             // Enforce --seccomp false by default using extra args | ||||
|             extra_args: vec!["--seccomp".into(), "false".into()], | ||||
|             no_default_net: false, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     pub fn disk(&mut self, path: &str) -> &mut Self { | ||||
|         self.disk_path = Some(path.to_string()); | ||||
|         self.flavor = None; | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     pub fn disk_from_flavor(&mut self, flavor: &str) -> &mut Self { | ||||
|         let f = match flavor { | ||||
|             "ubuntu" | "Ubuntu" | "UBUNTU" => ImgFlavor::Ubuntu, | ||||
|             "alpine" | "Alpine" | "ALPINE" => ImgFlavor::Alpine, | ||||
|             _ => ImgFlavor::Ubuntu, | ||||
|         }; | ||||
|         self.flavor = Some(f); | ||||
|         self.disk_path = None; | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     pub fn memory_mb(&mut self, mb: u32) -> &mut Self { | ||||
|         if mb > 0 { | ||||
|             self.memory_mb = mb; | ||||
|         } | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     pub fn vcpus(&mut self, v: u32) -> &mut Self { | ||||
|         if v > 0 { | ||||
|             self.vcpus = v; | ||||
|         } | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     pub fn cmdline(&mut self, c: &str) -> &mut Self { | ||||
|         self.cmdline = Some(c.to_string()); | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     pub fn extra_arg(&mut self, a: &str) -> &mut Self { | ||||
|         if !a.trim().is_empty() { | ||||
|             self.extra_args.push(a.to_string()); | ||||
|         } | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     /// Suppress the default host networking provisioning and NIC injection. | ||||
|     /// Internally, we set a sentinel consumed by vm_start. | ||||
|     pub fn no_default_net(&mut self) -> &mut Self { | ||||
|         self.no_default_net = true; | ||||
|         // add sentinel consumed in vm_start | ||||
|         if !self | ||||
|             .extra_args | ||||
|             .iter() | ||||
|             .any(|e| e.as_str() == "--no-default-net") | ||||
|         { | ||||
|             self.extra_args.push("--no-default-net".into()); | ||||
|         } | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     /// Resolve absolute path to hypervisor-fw from /images | ||||
|     fn resolve_hypervisor_fw() -> Result<String, CloudHvError> { | ||||
|         let p = "/images/hypervisor-fw"; | ||||
|         if std::path::Path::new(p).exists() { | ||||
|             Ok(p.to_string()) | ||||
|         } else { | ||||
|             Err(CloudHvError::DependencyMissing(format!( | ||||
|                 "firmware not found: {} (expected hypervisor-fw in /images)", | ||||
|                 p | ||||
|             ))) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Prepare disk if needed and return final disk path. | ||||
|     /// For Ubuntu flavor, this will: | ||||
|     ///  - copy source to per-VM work qcow2 | ||||
|     ///  - mount, retag UUIDs, fstab/grub/netplan adjustments | ||||
|     ///  - convert to raw under the VM dir and return that raw path | ||||
|     fn ensure_disk(&self) -> Result<String, CloudHvError> { | ||||
|         if let Some(p) = &self.disk_path { | ||||
|             return Ok(p.clone()); | ||||
|         } | ||||
|         if let Some(f) = &self.flavor { | ||||
|             // Use defaults: DHCPv4, placeholder static IPv6 | ||||
|             let opts = ImagePrepOptions { | ||||
|                 flavor: f.clone(), | ||||
|                 id: self.id.clone(), | ||||
|                 source: None, | ||||
|                 target_dir: None, | ||||
|                 net: NetPlanOpts::default(), | ||||
|                 disable_cloud_init_net: true, | ||||
|             }; | ||||
|             let res = image_prepare(&opts).map_err(|e| CloudHvError::CommandFailed(e.to_string()))?; | ||||
|             return Ok(res.raw_disk); | ||||
|         } | ||||
|         Err(CloudHvError::InvalidSpec( | ||||
|             "no disk configured; set .disk(path) or .disk_from_flavor(flavor)".into(), | ||||
|         )) | ||||
|     } | ||||
|  | ||||
|     /// Build final VmSpec and start the VM. | ||||
|     pub fn launch(&mut self) -> Result<String, CloudHvError> { | ||||
|         // Resolve hypervisor-fw absolute path | ||||
|         let kernel_path = Self::resolve_hypervisor_fw()?; | ||||
|         // Disk | ||||
|         let disk_path = self.ensure_disk()?; | ||||
|  | ||||
|         let spec = VmSpec { | ||||
|             id: self.id.clone(), | ||||
|             // We use direct kernel boot with hypervisor-fw per requirements. | ||||
|             kernel_path: Some(kernel_path), | ||||
|             initramfs_path: None, | ||||
|             firmware_path: None, | ||||
|             disk_path, | ||||
|             api_socket: "".into(), | ||||
|             vcpus: self.vcpus, | ||||
|             memory_mb: self.memory_mb, | ||||
|             cmdline: self.cmdline.clone(), | ||||
|             extra_args: if self.extra_args.is_empty() { | ||||
|                 None | ||||
|             } else { | ||||
|                 Some(self.extra_args.clone()) | ||||
|             }, | ||||
|         }; | ||||
|  | ||||
|         let id = vm_create(&spec)?; | ||||
|         vm_start(&id)?; | ||||
|         Ok(id) | ||||
|     } | ||||
| } | ||||
| @@ -12,6 +12,8 @@ use sal_os; | ||||
| use sal_process; | ||||
| use crate::qcow2; | ||||
|  | ||||
| pub mod builder; | ||||
|  | ||||
| /// Error type for Cloud Hypervisor operations | ||||
| #[derive(Debug)] | ||||
| pub enum CloudHvError { | ||||
| @@ -316,6 +318,10 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> { | ||||
|                     i += 1; | ||||
|                     continue; | ||||
|                 } | ||||
|             } else if tok == "--no-default-net" { | ||||
|                 // sentinel: suppress default networking; do not pass to CH CLI | ||||
|                 i += 1; | ||||
|                 continue; | ||||
|             } else if let Some(rest) = tok.strip_prefix("--disk=") { | ||||
|                 if !rest.is_empty() { | ||||
|                     extra_disk_vals.push(rest.to_string()); | ||||
| @@ -385,7 +391,7 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> { | ||||
|         .spec | ||||
|         .extra_args | ||||
|         .as_ref() | ||||
|         .map(|v| v.iter().any(|tok| tok == "--net")) | ||||
|         .map(|v| v.iter().any(|tok| tok == "--net" || tok == "--no-default-net")) | ||||
|         .unwrap_or(false); | ||||
|  | ||||
|     if !has_user_net { | ||||
| @@ -718,6 +724,10 @@ ip link show \"$BR\" >/dev/null 2>&1 || ip link add name \"$BR\" type bridge | ||||
| ip addr replace \"$BR_ADDR\" dev \"$BR\" | ||||
| ip link set \"$BR\" up | ||||
|  | ||||
| # IPv6 placeholder address + forward (temporary) | ||||
| ip -6 addr add 400::1/64 dev \"$BR\" 2>/dev/null || true | ||||
| sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null || true | ||||
|  | ||||
| # IPv4 forwarding | ||||
| sysctl -w net.ipv4.ip_forward=1 >/dev/null | ||||
|  | ||||
|   | ||||
							
								
								
									
										153
									
								
								packages/system/virt/src/hostcheck/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								packages/system/virt/src/hostcheck/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,153 @@ | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use std::fs; | ||||
| use std::path::Path; | ||||
|  | ||||
| use sal_os; | ||||
| use sal_process; | ||||
|  | ||||
| /// Host dependency check error | ||||
| #[derive(Debug)] | ||||
| pub enum HostCheckError { | ||||
|     Io(String), | ||||
| } | ||||
|  | ||||
| impl std::fmt::Display for HostCheckError { | ||||
|     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||
|         match self { | ||||
|             HostCheckError::Io(e) => write!(f, "IO error: {}", e), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl std::error::Error for HostCheckError {} | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||||
| pub struct HostCheckReport { | ||||
|     pub ok: bool, | ||||
|     pub critical: Vec<String>, | ||||
|     pub optional: Vec<String>, | ||||
|     pub notes: Vec<String>, | ||||
| } | ||||
|  | ||||
| fn hero_vm_root() -> String { | ||||
|     let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); | ||||
|     format!("{}/hero/virt/vms", home.trim_end_matches('/')) | ||||
| } | ||||
|  | ||||
| fn bin_missing(name: &str) -> bool { | ||||
|     sal_process::which(name).is_none() | ||||
| } | ||||
|  | ||||
| /// Perform host dependency checks required for image preparation and Cloud Hypervisor run. | ||||
| /// Returns a structured report that Rhai can consume easily. | ||||
| pub fn host_check_deps() -> Result<HostCheckReport, HostCheckError> { | ||||
|     let mut critical: Vec<String> = Vec::new(); | ||||
|     let mut optional: Vec<String> = Vec::new(); | ||||
|     let mut notes: Vec<String> = Vec::new(); | ||||
|  | ||||
|     // Must run as root | ||||
|     let uid_res = sal_process::run("id -u").silent(true).die(false).execute(); | ||||
|     match uid_res { | ||||
|         Ok(r) if r.success => { | ||||
|             let uid_s = r.stdout.trim(); | ||||
|             if uid_s != "0" { | ||||
|                 critical.push("not running as root (required for nbd/mount/network)".into()); | ||||
|             } | ||||
|         } | ||||
|         _ => { | ||||
|             notes.push("failed to determine uid via `id -u`".into()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Core binaries required for CH and image manipulation | ||||
|     let core_bins = [ | ||||
|         "cloud-hypervisor",        // CH binary (dynamic) | ||||
|         "cloud-hypervisor-static", // CH static (if present) | ||||
|         "ch-remote", | ||||
|         "ch-remote-static", | ||||
|         // hypervisor-fw is expected at /images/hypervisor-fw (not on PATH) | ||||
|         "qemu-img", | ||||
|         "qemu-nbd", | ||||
|         "blkid", | ||||
|         "tune2fs", | ||||
|         "partprobe", | ||||
|         "mount", | ||||
|         "umount", | ||||
|         "sed", | ||||
|         "awk", | ||||
|         "modprobe", | ||||
|     ]; | ||||
|  | ||||
|     // Networking helpers (for default bridge + NAT path) | ||||
|     let net_bins = ["ip", "nft", "dnsmasq", "systemctl"]; | ||||
|  | ||||
|     // Evaluate presence | ||||
|     let mut have_any_ch = false; | ||||
|     if !bin_missing("cloud-hypervisor") || !bin_missing("cloud-hypervisor-static") { | ||||
|         have_any_ch = true; | ||||
|     } | ||||
|     if !have_any_ch { | ||||
|         critical.push("cloud-hypervisor or cloud-hypervisor-static not found on PATH".into()); | ||||
|     } | ||||
|     if bin_missing("ch-remote") && bin_missing("ch-remote-static") { | ||||
|         critical.push("ch-remote or ch-remote-static not found on PATH".into()); | ||||
|     } | ||||
|  | ||||
|     for b in [&core_bins[4..], &net_bins[..]].concat() { | ||||
|         if bin_missing(b) { | ||||
|             // treat qemu/img/nbd stack and filesystem tools as critical | ||||
|             // treat networking tools as critical too since default path provisions bridge/DHCP | ||||
|             critical.push(format!("missing binary: {}", b)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Filesystem/path checks | ||||
|     // Ensure /images exists and expected image files are present (ubuntu, alpine, hypervisor-fw) | ||||
|     let images_root = "/images"; | ||||
|     if !Path::new(images_root).exists() { | ||||
|         critical.push(format!("{} not found (expected base images directory)", images_root)); | ||||
|     } else { | ||||
|         let ubuntu_path = format!("{}/noble-server-cloudimg-amd64.img", images_root); | ||||
|         let alpine_path = format!("{}/alpine-virt-cloudimg-amd64.qcow2", images_root); | ||||
|         let fw_path = format!("{}/hypervisor-fw", images_root); | ||||
|         if !Path::new(&ubuntu_path).exists() { | ||||
|             critical.push(format!("missing base image: {}", ubuntu_path)); | ||||
|         } | ||||
|         if !Path::new(&alpine_path).exists() { | ||||
|             critical.push(format!("missing base image: {}", alpine_path)); | ||||
|         } | ||||
|         if !Path::new(&fw_path).exists() { | ||||
|             critical.push(format!("missing firmware: {}", fw_path)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Ensure VM root directory is writable/creatable | ||||
|     let vm_root = hero_vm_root(); | ||||
|     if let Err(e) = sal_os::mkdir(&vm_root) { | ||||
|         critical.push(format!( | ||||
|             "cannot create/access VM root directory {}: {}", | ||||
|             vm_root, e | ||||
|         )); | ||||
|     } else { | ||||
|         // also try writing a small file | ||||
|         let probe_path = format!("{}/.__hero_probe", vm_root); | ||||
|         if let Err(e) = fs::write(&probe_path, b"ok") { | ||||
|             critical.push(format!( | ||||
|                 "VM root not writable {}: {}", | ||||
|                 vm_root, e | ||||
|             )); | ||||
|         } else { | ||||
|             let _ = fs::remove_file(&probe_path); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Summarize ok flag | ||||
|     let ok = critical.is_empty(); | ||||
|  | ||||
|     Ok(HostCheckReport { | ||||
|         ok, | ||||
|         critical, | ||||
|         optional, | ||||
|         notes, | ||||
|     }) | ||||
| } | ||||
							
								
								
									
										349
									
								
								packages/system/virt/src/image_prep/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										349
									
								
								packages/system/virt/src/image_prep/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,349 @@ | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use std::fs; | ||||
| use std::path::Path; | ||||
|  | ||||
| use sal_os; | ||||
| use sal_process; | ||||
|  | ||||
| #[derive(Debug)] | ||||
| pub enum ImagePrepError { | ||||
|     Io(String), | ||||
|     InvalidInput(String), | ||||
|     CommandFailed(String), | ||||
|     NotImplemented(String), | ||||
| } | ||||
|  | ||||
| impl std::fmt::Display for ImagePrepError { | ||||
|     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||
|         match self { | ||||
|             ImagePrepError::Io(e) => write!(f, "IO error: {}", e), | ||||
|             ImagePrepError::InvalidInput(e) => write!(f, "Invalid input: {}", e), | ||||
|             ImagePrepError::CommandFailed(e) => write!(f, "Command failed: {}", e), | ||||
|             ImagePrepError::NotImplemented(e) => write!(f, "Not implemented: {}", e), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl std::error::Error for ImagePrepError {} | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||||
| #[serde(rename_all = "lowercase")] | ||||
| pub enum Flavor { | ||||
|     Ubuntu, | ||||
|     Alpine, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||||
| pub struct NetPlanOpts { | ||||
|     #[serde(default = "default_dhcp4")] | ||||
|     pub dhcp4: bool, | ||||
|     #[serde(default)] | ||||
|     pub dhcp6: bool, | ||||
|     /// Static IPv6 address to assign in guest (temporary behavior) | ||||
|     pub ipv6_addr: Option<String>, // e.g., "400::10/64" | ||||
|     pub gw6: Option<String>,       // e.g., "400::1" | ||||
| } | ||||
|  | ||||
| fn default_dhcp4() -> bool { | ||||
|     true | ||||
| } | ||||
|  | ||||
| impl Default for NetPlanOpts { | ||||
|     fn default() -> Self { | ||||
|         Self { | ||||
|             dhcp4: true, | ||||
|             dhcp6: false, | ||||
|             ipv6_addr: Some("400::10/64".into()), | ||||
|             gw6: Some("400::1".into()), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||||
| pub struct ImagePrepOptions { | ||||
|     pub flavor: Flavor, | ||||
|     /// VM id (used for working directory layout and tap/mac derivations) | ||||
|     pub id: String, | ||||
|     /// Optional source path override, defaults to /images/<flavor default filename> | ||||
|     pub source: Option<String>, | ||||
|     /// Optional VM target directory, defaults to $HOME/hero/virt/vms/<id> | ||||
|     pub target_dir: Option<String>, | ||||
|     /// Netplan options | ||||
|     #[serde(default)] | ||||
|     pub net: NetPlanOpts, | ||||
|     /// Disable cloud-init networking | ||||
|     #[serde(default = "default_disable_cloud_init_net")] | ||||
|     pub disable_cloud_init_net: bool, | ||||
| } | ||||
|  | ||||
| fn default_disable_cloud_init_net() -> bool { | ||||
|     true | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||||
| pub struct ImagePrepResult { | ||||
|     pub raw_disk: String, | ||||
|     pub root_uuid: String, | ||||
|     pub boot_uuid: String, | ||||
|     pub work_qcow2: String, | ||||
| } | ||||
|  | ||||
| fn hero_vm_root() -> String { | ||||
|     let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()); | ||||
|     format!("{}/hero/virt/vms", home.trim_end_matches('/')) | ||||
| } | ||||
|  | ||||
| fn default_source_for_flavor(flavor: &Flavor) -> (&'static str, bool) { | ||||
|     match flavor { | ||||
|         Flavor::Ubuntu => ("/images/noble-server-cloudimg-amd64.img", true), | ||||
|         Flavor::Alpine => ("/images/alpine-virt-cloudimg-amd64.qcow2", true), | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn fail(e: &str) -> ImagePrepError { | ||||
|     ImagePrepError::CommandFailed(e.to_string()) | ||||
| } | ||||
|  | ||||
| fn run_script(script: &str) -> Result<sal_process::CommandResult, ImagePrepError> { | ||||
|     match sal_process::run(script).silent(true).die(false).execute() { | ||||
|         Ok(res) => { | ||||
|             if res.success { | ||||
|                 Ok(res) | ||||
|             } else { | ||||
|                 Err(ImagePrepError::CommandFailed(format!( | ||||
|                     "{}{}", | ||||
|                     res.stdout, res.stderr | ||||
|                 ))) | ||||
|             } | ||||
|         } | ||||
|         Err(e) => Err(ImagePrepError::CommandFailed(e.to_string())), | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Prepare a base cloud image for booting under Cloud Hypervisor: | ||||
| /// - make a per-VM working copy | ||||
| /// - attach via nbd, mount root/boot | ||||
| /// - retag UUIDs, update fstab, write minimal grub.cfg | ||||
| /// - generate netplan (DHCPv4, static IPv6 placeholder), disable cloud-init net | ||||
| /// - convert to raw disk in VM dir | ||||
| pub fn image_prepare(opts: &ImagePrepOptions) -> Result<ImagePrepResult, ImagePrepError> { | ||||
|     // Resolve source image | ||||
|     let (def_src, _must_exist) = default_source_for_flavor(&opts.flavor); | ||||
|     let src = opts.source.clone().unwrap_or_else(|| def_src.to_string()); | ||||
|     if !Path::new(&src).exists() { | ||||
|         return Err(ImagePrepError::InvalidInput(format!( | ||||
|             "source image not found: {}", | ||||
|             src | ||||
|         ))); | ||||
|     } | ||||
|  | ||||
|     // Resolve VM dir | ||||
|     let vm_dir = opts | ||||
|         .target_dir | ||||
|         .clone() | ||||
|         .unwrap_or_else(|| format!("{}/{}", hero_vm_root(), opts.id)); | ||||
|     sal_os::mkdir(&vm_dir).map_err(|e| ImagePrepError::Io(e.to_string()))?; | ||||
|  | ||||
|     // Work qcow2 copy path and mount points | ||||
|     let work_qcow2 = format!("{}/work.qcow2", vm_dir); | ||||
|     let raw_path = format!("{}/disk.raw", vm_dir); | ||||
|     let mnt_root = format!("/mnt/hero-img/{}/root", opts.id); | ||||
|     let mnt_boot = format!("/mnt/hero-img/{}/boot", opts.id); | ||||
|  | ||||
|     // Only Ubuntu implemented for now | ||||
|     match opts.flavor { | ||||
|         Flavor::Ubuntu => { | ||||
|             // Build bash script that performs all steps and echos "RAW|ROOT_UUID|BOOT_UUID" at end | ||||
|             let net_ipv6 = opts.net.ipv6_addr.clone().unwrap_or_else(|| "400::10/64".into()); | ||||
|             let gw6 = opts.net.gw6.clone().unwrap_or_else(|| "400::1".into()); | ||||
|             let disable_ci_net = opts.disable_cloud_init_net; | ||||
|  | ||||
|             // Keep script small and robust; avoid brace-heavy awk to simplify escaping. | ||||
|             let script = format!( | ||||
|                 "#!/bin/bash -e | ||||
| set -euo pipefail | ||||
|  | ||||
| SRC={src} | ||||
| VM_DIR={vm_dir} | ||||
| WORK={work} | ||||
| MNT_ROOT={mnt_root} | ||||
| MNT_BOOT={mnt_boot} | ||||
| RAW={raw} | ||||
|  | ||||
| mkdir -p \"$VM_DIR\" | ||||
| mkdir -p \"$(dirname \"$MNT_ROOT\")\" | ||||
| mkdir -p \"$MNT_ROOT\" \"$MNT_BOOT\" | ||||
|  | ||||
| # Make per-VM working copy (reflink if supported) | ||||
| cp --reflink=auto -f \"$SRC\" \"$WORK\" | ||||
|  | ||||
| # Load NBD with sufficient partitions | ||||
| modprobe nbd max_part=63 | ||||
|  | ||||
| # Pick a free /dev/nbdX and connect the qcow2 | ||||
| NBD=\"\" | ||||
| for i in $(seq 0 15); do | ||||
|   DEV=\"/dev/nbd$i\" | ||||
|   qemu-nbd --disconnect \"$DEV\" >/dev/null 2>&1 || true | ||||
|   if qemu-nbd --connect=\"$DEV\" \"$WORK\"; then | ||||
|     NBD=\"$DEV\" | ||||
|     break | ||||
|   fi | ||||
| done | ||||
| if [ -z \"$NBD\" ]; then | ||||
|   echo \"No free /dev/nbdX device available\" >&2 | ||||
|   exit 1 | ||||
| fi | ||||
|  | ||||
| # Settle and probe partitions | ||||
| udevadm settle >/dev/null 2>&1 || true | ||||
| partprobe \"$NBD\" >/dev/null 2>&1 || true | ||||
| for t in 1 2 3 4 5 6 7 8 9 10; do | ||||
|   [ -b \"${{NBD}}p1\" ] && break | ||||
|   sleep 0.3 | ||||
|   udevadm settle >/dev/null 2>&1 || true | ||||
|   partprobe \"$NBD\" >/dev/null 2>&1 || true | ||||
| done | ||||
|  | ||||
| ROOT_DEV=\"${{NBD}}p1\" | ||||
| # Prefer p16, else p15 | ||||
| if [ -b \"${{NBD}}p16\" ]; then | ||||
|   BOOT_DEV=\"${{NBD}}p16\" | ||||
| elif [ -b \"${{NBD}}p15\" ]; then | ||||
|   BOOT_DEV=\"${{NBD}}p15\" | ||||
| else | ||||
|   echo \"Boot partition not found on $NBD (tried p16 and p15)\" >&2 | ||||
|   exit 33 | ||||
| fi | ||||
|  | ||||
| if [ ! -b \"$ROOT_DEV\" ]; then | ||||
|   echo \"Root partition not found: $ROOT_DEV\" >&2 | ||||
|   exit 32 | ||||
| fi | ||||
|  | ||||
| cleanup() {{ | ||||
|   set +e | ||||
|   umount \"$MNT_BOOT\" 2>/dev/null || true | ||||
|   umount \"$MNT_ROOT\" 2>/dev/null || true | ||||
|   [ -n \"$NBD\" ] && qemu-nbd --disconnect \"$NBD\" 2>/dev/null || true | ||||
|   rmmod nbd 2>/dev/null || true | ||||
| }} | ||||
| trap cleanup EXIT | ||||
|  | ||||
| # Mount and mutate | ||||
| mount \"$ROOT_DEV\" \"$MNT_ROOT\" | ||||
| mount \"$BOOT_DEV\" \"$MNT_BOOT\" | ||||
|  | ||||
| # Change UUIDs (best-effort) | ||||
| tune2fs -U random \"$ROOT_DEV\" || true | ||||
| tune2fs -U random \"$BOOT_DEV\" || true | ||||
|  | ||||
| ROOT_UUID=$(blkid -o value -s UUID \"$ROOT_DEV\") | ||||
| BOOT_UUID=$(blkid -o value -s UUID \"$BOOT_DEV\") | ||||
|  | ||||
| # Update fstab | ||||
| sed -i \"s/UUID=[a-f0-9-]* \\/ /UUID=$ROOT_UUID \\/ /\" \"$MNT_ROOT/etc/fstab\" | ||||
| sed -i \"s/UUID=[a-f0-9-]* \\/boot /UUID=$BOOT_UUID \\/boot /\" \"$MNT_ROOT/etc/fstab\" | ||||
|  | ||||
| # Minimal grub.cfg (note: braces escaped for Rust format!) | ||||
| mkdir -p \"$MNT_BOOT/grub\" | ||||
| KERNEL=$(ls -1 \"$MNT_BOOT\"/vmlinuz-* | sort -V | tail -n1 | xargs -n1 basename) | ||||
| INITRD=$(ls -1 \"$MNT_BOOT\"/initrd.img-* | sort -V | tail -n1 | xargs -n1 basename) | ||||
| cat > \"$MNT_BOOT/grub/grub.cfg\" << EOF | ||||
| set default=0 | ||||
| set timeout=3 | ||||
| menuentry 'Ubuntu Cloud' {{ | ||||
|     insmod part_gpt | ||||
|     insmod ext2 | ||||
|     insmod gzio | ||||
|     search --no-floppy --fs-uuid --set=root $BOOT_UUID | ||||
|     linux /$KERNEL root=/dev/vda1 ro console=ttyS0 | ||||
|     initrd /$INITRD | ||||
| }} | ||||
| EOF | ||||
|  | ||||
| # Netplan config | ||||
| rm -f \"$MNT_ROOT\"/etc/netplan/*.yaml | ||||
| mkdir -p \"$MNT_ROOT\"/etc/netplan | ||||
| cat > \"$MNT_ROOT/etc/netplan/01-netconfig.yaml\" << EOF | ||||
| network: | ||||
|   version: 2 | ||||
|   ethernets: | ||||
|     eth0: | ||||
|       dhcp4: {dhcp4} | ||||
|       dhcp6: {dhcp6} | ||||
|       addresses: | ||||
|         - {ipv6} | ||||
|       routes: | ||||
|         - to: \"::/0\" | ||||
|           via: {gw6} | ||||
|       nameservers: | ||||
|         addresses: [8.8.8.8, 1.1.1.1, 2001:4860:4860::8888] | ||||
| EOF | ||||
|  | ||||
| # Disable cloud-init networking (optional but default) | ||||
| if [ \"{disable_ci_net}\" = \"true\" ]; then | ||||
|   mkdir -p \"$MNT_ROOT/etc/cloud/cloud.cfg.d\" | ||||
|   echo \"network: {{config: disabled}}\" > \"$MNT_ROOT/etc/cloud/cloud.cfg.d/99-disable-network-config.cfg\" | ||||
| fi | ||||
|  | ||||
| # Convert prepared image to raw | ||||
| rm -f \"$RAW\" | ||||
| qemu-img convert -O raw \"$WORK\" \"$RAW\" | ||||
|  | ||||
| # Output result triple | ||||
| echo \"$RAW|$ROOT_UUID|$BOOT_UUID\" | ||||
| ", | ||||
|                 src = shell_escape(&src), | ||||
|                 vm_dir = shell_escape(&vm_dir), | ||||
|                 work = shell_escape(&work_qcow2), | ||||
|                 mnt_root = shell_escape(&mnt_root), | ||||
|                 mnt_boot = shell_escape(&mnt_boot), | ||||
|                 raw = shell_escape(&raw_path), | ||||
|                 dhcp4 = if opts.net.dhcp4 { "true" } else { "false" }, | ||||
|                 dhcp6 = if opts.net.dhcp6 { "true" } else { "false" }, | ||||
|                 ipv6 = shell_escape(&net_ipv6), | ||||
|                 gw6 = shell_escape(&gw6), | ||||
|                 disable_ci_net = if disable_ci_net { "true" } else { "false" }, | ||||
|             ); | ||||
|  | ||||
|             let res = run_script(&script)?; | ||||
|             let line = res.stdout.trim().lines().last().unwrap_or("").trim().to_string(); | ||||
|             let parts: Vec<_> = line.split('|').map(|s| s.to_string()).collect(); | ||||
|             if parts.len() != 3 { | ||||
|                 return Err(fail(&format!( | ||||
|                     "unexpected output from image_prepare script, expected RAW|ROOT_UUID|BOOT_UUID, got: {}", | ||||
|                     line | ||||
|                 ))); | ||||
|             } | ||||
|             Ok(ImagePrepResult { | ||||
|                 raw_disk: parts[0].clone(), | ||||
|                 root_uuid: parts[1].clone(), | ||||
|                 boot_uuid: parts[2].clone(), | ||||
|                 work_qcow2, | ||||
|             }) | ||||
|         } | ||||
|         Flavor::Alpine => Err(ImagePrepError::NotImplemented( | ||||
|             "Alpine image_prepare not implemented yet".into(), | ||||
|         )), | ||||
|     } | ||||
| } | ||||
|  | ||||
| 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(); | ||||
|     } | ||||
|     let mut out = String::from("'"); | ||||
|     for ch in s.chars() { | ||||
|         if ch == '\'' { | ||||
|             out.push_str("'\"'\"'"); | ||||
|         } else { | ||||
|             out.push(ch); | ||||
|         } | ||||
|     } | ||||
|     out.push('\''); | ||||
|     out | ||||
| } | ||||
| @@ -26,6 +26,8 @@ pub mod nerdctl; | ||||
| pub mod rfs; | ||||
| pub mod qcow2; | ||||
| pub mod cloudhv; | ||||
| pub mod hostcheck; | ||||
| pub mod image_prep; | ||||
|  | ||||
| pub mod rhai; | ||||
|  | ||||
|   | ||||
| @@ -10,6 +10,9 @@ pub mod nerdctl; | ||||
| pub mod rfs; | ||||
| pub mod qcow2; | ||||
| pub mod cloudhv; | ||||
| pub mod hostcheck; | ||||
| pub mod image_prep; | ||||
| pub mod cloudhv_builder; | ||||
|  | ||||
| /// Register all Virt module functions with the Rhai engine | ||||
| /// | ||||
| @@ -35,6 +38,15 @@ pub fn register_virt_module(engine: &mut Engine) -> Result<(), Box<EvalAltResult | ||||
|  | ||||
|     // Register Cloud Hypervisor module functions | ||||
|     cloudhv::register_cloudhv_module(engine)?; | ||||
|  | ||||
|     // Register Host dependency checker | ||||
|     hostcheck::register_hostcheck_module(engine)?; | ||||
|  | ||||
|     // Register Image preparation functions | ||||
|     image_prep::register_image_prep_module(engine)?; | ||||
|  | ||||
|     // Register Cloud Hypervisor builder and easy wrapper | ||||
|     cloudhv_builder::register_cloudhv_builder_module(engine)?; | ||||
|      | ||||
|     Ok(()) | ||||
| } | ||||
|   | ||||
							
								
								
									
										136
									
								
								packages/system/virt/src/rhai/cloudhv_builder.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								packages/system/virt/src/rhai/cloudhv_builder.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,136 @@ | ||||
| use crate::cloudhv::builder::CloudHvBuilder; | ||||
| use crate::hostcheck::host_check_deps; | ||||
| use crate::image_prep::{image_prepare, Flavor as ImgFlavor, ImagePrepOptions, NetPlanOpts}; | ||||
| use rhai::{Engine, EvalAltResult, Map}; | ||||
|  | ||||
| fn builder_new(id: &str) -> CloudHvBuilder { | ||||
|     CloudHvBuilder::new(id) | ||||
| } | ||||
|  | ||||
| // Functional, chainable-style helpers (consume and return the builder) | ||||
| fn builder_memory_mb(mut b: CloudHvBuilder, mb: i64) -> CloudHvBuilder { | ||||
|     if mb > 0 { | ||||
|         b.memory_mb(mb as u32); | ||||
|     } | ||||
|     b | ||||
| } | ||||
|  | ||||
| fn builder_vcpus(mut b: CloudHvBuilder, v: i64) -> CloudHvBuilder { | ||||
|     if v > 0 { | ||||
|         b.vcpus(v as u32); | ||||
|     } | ||||
|     b | ||||
| } | ||||
|  | ||||
| fn builder_disk(mut b: CloudHvBuilder, path: &str) -> CloudHvBuilder { | ||||
|     b.disk(path); | ||||
|     b | ||||
| } | ||||
|  | ||||
| fn builder_disk_from_flavor(mut b: CloudHvBuilder, flavor: &str) -> CloudHvBuilder { | ||||
|     b.disk_from_flavor(flavor); | ||||
|     b | ||||
| } | ||||
|  | ||||
| fn builder_cmdline(mut b: CloudHvBuilder, c: &str) -> CloudHvBuilder { | ||||
|     b.cmdline(c); | ||||
|     b | ||||
| } | ||||
|  | ||||
| fn builder_extra_arg(mut b: CloudHvBuilder, a: &str) -> CloudHvBuilder { | ||||
|     b.extra_arg(a); | ||||
|     b | ||||
| } | ||||
|  | ||||
| fn builder_no_default_net(mut b: CloudHvBuilder) -> CloudHvBuilder { | ||||
|     b.no_default_net(); | ||||
|     b | ||||
| } | ||||
|  | ||||
| fn builder_launch(mut b: CloudHvBuilder) -> Result<String, Box<EvalAltResult>> { | ||||
|     b.launch().map_err(|e| { | ||||
|         Box::new(EvalAltResult::ErrorRuntime( | ||||
|             format!("cloudhv builder launch failed: {}", e).into(), | ||||
|             rhai::Position::NONE, | ||||
|         )) | ||||
|     }) | ||||
| } | ||||
|  | ||||
| // Noob-friendly one-shot wrapper | ||||
| fn vm_easy_launch(flavor: &str, id: &str, memory_mb: i64, vcpus: i64) -> Result<String, Box<EvalAltResult>> { | ||||
|     // Preflight | ||||
|     let report = host_check_deps().map_err(|e| { | ||||
|         Box::new(EvalAltResult::ErrorRuntime( | ||||
|             format!("host_check failed: {}", e).into(), | ||||
|             rhai::Position::NONE, | ||||
|         )) | ||||
|     })?; | ||||
|     if !report.ok { | ||||
|         return Err(Box::new(EvalAltResult::ErrorRuntime( | ||||
|             format!("missing dependencies: {:?}", report.critical).into(), | ||||
|             rhai::Position::NONE, | ||||
|         ))); | ||||
|     } | ||||
|  | ||||
|     // Prepare image to raw using defaults (DHCPv4 + placeholder v6 + disable cloud-init net) | ||||
|     let img_flavor = match flavor { | ||||
|         "ubuntu" | "Ubuntu" | "UBUNTU" => ImgFlavor::Ubuntu, | ||||
|         "alpine" | "Alpine" | "ALPINE" => ImgFlavor::Alpine, | ||||
|         _ => ImgFlavor::Ubuntu, | ||||
|     }; | ||||
|     let prep_opts = ImagePrepOptions { | ||||
|         flavor: img_flavor, | ||||
|         id: id.to_string(), | ||||
|         source: None, | ||||
|         target_dir: None, | ||||
|         net: NetPlanOpts::default(), | ||||
|         disable_cloud_init_net: true, | ||||
|     }; | ||||
|     let prep = image_prepare(&prep_opts).map_err(|e| { | ||||
|         Box::new(EvalAltResult::ErrorRuntime( | ||||
|             format!("image_prepare failed: {}", e).into(), | ||||
|             rhai::Position::NONE, | ||||
|         )) | ||||
|     })?; | ||||
|  | ||||
|     // Build and launch | ||||
|     let mut b = CloudHvBuilder::new(id); | ||||
|     b.disk(&prep.raw_disk); | ||||
|     if memory_mb > 0 { | ||||
|         b.memory_mb(memory_mb as u32); | ||||
|     } | ||||
|     if vcpus > 0 { | ||||
|         b.vcpus(vcpus as u32); | ||||
|     } | ||||
|     b.launch().map_err(|e| { | ||||
|         Box::new(EvalAltResult::ErrorRuntime( | ||||
|             format!("vm_easy_launch failed at launch: {}", e).into(), | ||||
|             rhai::Position::NONE, | ||||
|         )) | ||||
|     }) | ||||
| } | ||||
|  | ||||
| pub fn register_cloudhv_builder_module(engine: &mut Engine) -> Result<(), Box<EvalAltResult>> { | ||||
|     // Register type | ||||
|     engine.register_type_with_name::<CloudHvBuilder>("CloudHvBuilder"); | ||||
|  | ||||
|     // Factory | ||||
|     engine.register_fn("cloudhv_builder", builder_new); | ||||
|  | ||||
|     // Chainable methods (functional style) | ||||
|     engine.register_fn("memory_mb", builder_memory_mb); | ||||
|     engine.register_fn("vcpus", builder_vcpus); | ||||
|     engine.register_fn("disk", builder_disk); | ||||
|     engine.register_fn("disk_from_flavor", builder_disk_from_flavor); | ||||
|     engine.register_fn("cmdline", builder_cmdline); | ||||
|     engine.register_fn("extra_arg", builder_extra_arg); | ||||
|     engine.register_fn("no_default_net", builder_no_default_net); | ||||
|  | ||||
|     // Action | ||||
|     engine.register_fn("launch", builder_launch); | ||||
|  | ||||
|     // One-shot wrapper | ||||
|     engine.register_fn("vm_easy_launch", vm_easy_launch); | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
							
								
								
									
										48
									
								
								packages/system/virt/src/rhai/hostcheck.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								packages/system/virt/src/rhai/hostcheck.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| use crate::hostcheck::{host_check_deps, HostCheckReport}; | ||||
| use rhai::{Array, Dynamic, Engine, EvalAltResult, Map}; | ||||
|  | ||||
| fn report_to_map(r: &HostCheckReport) -> Map { | ||||
|     let mut m = Map::new(); | ||||
|     m.insert("ok".into(), (r.ok as bool).into()); | ||||
|  | ||||
|     let mut crit = Array::new(); | ||||
|     for s in &r.critical { | ||||
|         crit.push(s.clone().into()); | ||||
|     } | ||||
|     m.insert("critical".into(), crit.into()); | ||||
|  | ||||
|     let mut opt = Array::new(); | ||||
|     for s in &r.optional { | ||||
|         opt.push(s.clone().into()); | ||||
|     } | ||||
|     m.insert("optional".into(), opt.into()); | ||||
|  | ||||
|     let mut notes = Array::new(); | ||||
|     for s in &r.notes { | ||||
|         notes.push(s.clone().into()); | ||||
|     } | ||||
|     m.insert("notes".into(), notes.into()); | ||||
|  | ||||
|     m | ||||
| } | ||||
|  | ||||
| fn host_check() -> Result<Map, Box<EvalAltResult>> { | ||||
|     match host_check_deps() { | ||||
|         Ok(rep) => Ok(report_to_map(&rep)), | ||||
|         Err(e) => { | ||||
|             let mut m = Map::new(); | ||||
|             m.insert("ok".into(), Dynamic::FALSE); | ||||
|             let mut crit = Array::new(); | ||||
|             crit.push(format!("host_check failed: {}", e).into()); | ||||
|             m.insert("critical".into(), crit.into()); | ||||
|             m.insert("optional".into(), Array::new().into()); | ||||
|             m.insert("notes".into(), Array::new().into()); | ||||
|             Ok(m) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| pub fn register_hostcheck_module(engine: &mut Engine) -> Result<(), Box<EvalAltResult>> { | ||||
|     engine.register_fn("host_check", host_check); | ||||
|     Ok(()) | ||||
| } | ||||
							
								
								
									
										98
									
								
								packages/system/virt/src/rhai/image_prep.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								packages/system/virt/src/rhai/image_prep.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | ||||
| use crate::image_prep::{image_prepare, Flavor, ImagePrepOptions, NetPlanOpts}; | ||||
| use rhai::{Array, Dynamic, Engine, EvalAltResult, Map}; | ||||
|  | ||||
| fn parse_flavor(s: &str) -> Result<Flavor, Box<EvalAltResult>> { | ||||
|     match s { | ||||
|         "ubuntu" | "Ubuntu" | "UBUNTU" => Ok(Flavor::Ubuntu), | ||||
|         "alpine" | "Alpine" | "ALPINE" => Ok(Flavor::Alpine), | ||||
|         other => Err(Box::new(EvalAltResult::ErrorRuntime( | ||||
|             format!("image_prepare: invalid flavor '{}', allowed: ubuntu|alpine", other).into(), | ||||
|             rhai::Position::NONE, | ||||
|         ))), | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn map_get_string(m: &Map, k: &str) -> Option<String> { | ||||
|     m.get(k).and_then(|v| if v.is_string() { Some(v.clone().cast::<String>()) } else { None }) | ||||
| } | ||||
| fn map_get_bool(m: &Map, k: &str) -> Option<bool> { | ||||
|     m.get(k).and_then(|v| v.as_bool().ok()) | ||||
| } | ||||
|  | ||||
| fn net_from_map(m: Option<&Map>) -> NetPlanOpts { | ||||
|     let mut n = NetPlanOpts::default(); | ||||
|     if let Some(mm) = m { | ||||
|         if let Some(b) = map_get_bool(mm, "dhcp4") { | ||||
|             n.dhcp4 = b; | ||||
|         } | ||||
|         if let Some(b) = map_get_bool(mm, "dhcp6") { | ||||
|             n.dhcp6 = b; | ||||
|         } | ||||
|         if let Some(s) = map_get_string(mm, "ipv6_addr") { | ||||
|             if !s.trim().is_empty() { | ||||
|                 n.ipv6_addr = Some(s); | ||||
|             } | ||||
|         } | ||||
|         if let Some(s) = map_get_string(mm, "gw6") { | ||||
|             if !s.trim().is_empty() { | ||||
|                 n.gw6 = Some(s); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     n | ||||
| } | ||||
|  | ||||
| fn image_prepare_rhai(opts: Map) -> Result<Map, Box<EvalAltResult>> { | ||||
|     // Required fields | ||||
|     let id = map_get_string(&opts, "id").ok_or_else(|| { | ||||
|         Box::new(EvalAltResult::ErrorRuntime( | ||||
|             "image_prepare: missing required field 'id'".into(), | ||||
|             rhai::Position::NONE, | ||||
|         )) | ||||
|     })?; | ||||
|     if id.trim().is_empty() { | ||||
|         return Err(Box::new(EvalAltResult::ErrorRuntime( | ||||
|             "image_prepare: 'id' must not be empty".into(), | ||||
|             rhai::Position::NONE, | ||||
|         ))); | ||||
|     } | ||||
|  | ||||
|     let flavor_s = map_get_string(&opts, "flavor").unwrap_or_else(|| "ubuntu".into()); | ||||
|     let flavor = parse_flavor(&flavor_s)?; | ||||
|  | ||||
|     // Optional fields | ||||
|     let source = map_get_string(&opts, "source"); | ||||
|     let target_dir = map_get_string(&opts, "target_dir"); | ||||
|     let net = opts.get("net").and_then(|v| if v.is_map() { Some(v.clone().cast::<Map>()) } else { None }); | ||||
|     let net_opts = net_from_map(net.as_ref()); | ||||
|  | ||||
|     let disable_cloud_init_net = map_get_bool(&opts, "disable_cloud_init_net").unwrap_or(true); | ||||
|  | ||||
|     let o = ImagePrepOptions { | ||||
|         flavor, | ||||
|         id, | ||||
|         source, | ||||
|         target_dir, | ||||
|         net: net_opts, | ||||
|         disable_cloud_init_net, | ||||
|     }; | ||||
|  | ||||
|     let res = image_prepare(&o).map_err(|e| { | ||||
|         Box::new(EvalAltResult::ErrorRuntime( | ||||
|             format!("image_prepare failed: {}", e).into(), | ||||
|             rhai::Position::NONE, | ||||
|         )) | ||||
|     })?; | ||||
|  | ||||
|     let mut out = Map::new(); | ||||
|     out.insert("raw_disk".into(), res.raw_disk.into()); | ||||
|     out.insert("root_uuid".into(), res.root_uuid.into()); | ||||
|     out.insert("boot_uuid".into(), res.boot_uuid.into()); | ||||
|     out.insert("work_qcow2".into(), res.work_qcow2.into()); | ||||
|     Ok(out) | ||||
| } | ||||
|  | ||||
| pub fn register_image_prep_module(engine: &mut Engine) -> Result<(), Box<EvalAltResult>> { | ||||
|     engine.register_fn("image_prepare", image_prepare_rhai); | ||||
|     Ok(()) | ||||
| } | ||||
							
								
								
									
										234
									
								
								packages/system/virt/tests/rhai/10_vm_end_to_end.rhai
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										234
									
								
								packages/system/virt/tests/rhai/10_vm_end_to_end.rhai
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,234 @@ | ||||
| // End-to-end smoke test for the new qcow2 + cloud-hypervisor refactor | ||||
| // This script executes in logical phases so we can see clearly what works. | ||||
| // | ||||
| // Phases: | ||||
| //  1) Host preflight check | ||||
| //  2) Image preparation (Ubuntu) -> raw disk | ||||
| //  3) Launch VM via builder using prepared raw disk | ||||
| //  4) Inspect VM info, list VMs | ||||
| //  5) Stop & delete VM | ||||
| //  6) Launch VM via one-shot wrapper vm_easy_launch | ||||
| //  7) Inspect VM info, list VMs | ||||
| //  8) Stop & delete VM | ||||
| // | ||||
| // Notes: | ||||
| //  - Run as root on the host (required for NBD/mount/networking). | ||||
| //  - Base images expected at: | ||||
| //      /images/noble-server-cloudimg-amd64.img | ||||
| //      /images/alpine-virt-cloudimg-amd64.qcow2 (Alpine prepare not implemented yet) | ||||
| //      /images/hypervisor-fw (firmware binary used via --kernel) | ||||
| //  - Network defaults: IPv4 NAT + dnsmasq DHCP; placeholder IPv6 on bridge + guest netplan. | ||||
| // | ||||
| // Conventions: | ||||
| //  - Functional builder chaining: b = memory_mb(b, 4096), etc. | ||||
| //  - Each phase prints a banner and either "OK" or "FAILED" with detailed error message. | ||||
|  | ||||
| fn banner(s) { | ||||
|     print("=================================================="); | ||||
|     print(s); | ||||
|     print("=================================================="); | ||||
| } | ||||
|  | ||||
| fn ok(s) { | ||||
|     print("[OK] " + s); | ||||
| } | ||||
|  | ||||
| fn fail(msg) { | ||||
|     print("[FAILED] " + msg); | ||||
| } | ||||
|  | ||||
| fn dump_map(m) { | ||||
|     // simple pretty printer for small maps | ||||
|     for k in m.keys() { | ||||
|         print("  " + k + ": " + m[k].to_string()); | ||||
|     } | ||||
| } | ||||
|  | ||||
| fn dump_array(a) { | ||||
|     let i = 0; | ||||
|     for x in a { | ||||
|         print("  - " + x.to_string()); | ||||
|     } | ||||
| } | ||||
|  | ||||
| // ------------------------------------------------------------------------------------ | ||||
| // Phase 1: Host preflight check | ||||
| // ------------------------------------------------------------------------------------ | ||||
| banner("PHASE 1: host_check()"); | ||||
| let hc = host_check(); | ||||
| if !(hc.ok == true) { | ||||
|     fail("host_check indicates missing dependencies; details:"); | ||||
|     print("critical:"); | ||||
|     dump_array(hc.critical); | ||||
|     print("optional:"); | ||||
|     dump_array(hc.optional); | ||||
|     print("notes:"); | ||||
|     dump_array(hc.notes); | ||||
|     // Short-circuit: nothing else will work without deps | ||||
|     throw "Missing critical host dependencies"; | ||||
| } else { | ||||
|     ok("host_check passed"); | ||||
| } | ||||
|  | ||||
| // ------------------------------------------------------------------------------------ | ||||
| // Phase 2: Image preparation for Ubuntu | ||||
| // - produces a per-VM raw disk in $HOME/hero/virt/vms/<id>/disk.raw | ||||
| // ------------------------------------------------------------------------------------ | ||||
| banner("PHASE 2: image_prepare (Ubuntu) -> raw disk"); | ||||
| let vmA = "vm-e2e-a"; | ||||
| let prep_opts = #{ | ||||
|     id: vmA, | ||||
|     flavor: "ubuntu", | ||||
|     // source: optional override, default uses /images/noble-server-cloudimg-amd64.img | ||||
|     // target_dir: optional override, default $HOME/hero/virt/vms/<id> | ||||
|     net: #{ | ||||
|         dhcp4: true, | ||||
|         dhcp6: false, | ||||
|         ipv6_addr: "400::10/64", | ||||
|         gw6: "400::1", | ||||
|     }, | ||||
|     disable_cloud_init_net: true, | ||||
| }; | ||||
|  | ||||
| let prep_res = (); | ||||
| let prep_ok = false; | ||||
| try { | ||||
|     prep_res = image_prepare(prep_opts); | ||||
|     ok("image_prepare returned:"); | ||||
|     dump_map(prep_res); | ||||
|     if prep_res.raw_disk == () { | ||||
|         fail("prep_res.raw_disk is UNIT; expected string path"); | ||||
|     } else { | ||||
|         ok("raw_disk: " + prep_res.raw_disk); | ||||
|         prep_ok = true; | ||||
|     } | ||||
| } catch (e) { | ||||
|     fail("image_prepare failed: " + e.to_string()); | ||||
| } | ||||
|  | ||||
| if !(prep_ok) { | ||||
|     throw "Stopping due to image_prepare failure"; | ||||
| } | ||||
|  | ||||
| // ------------------------------------------------------------------------------------ | ||||
| // Phase 3: Launch VM via builder using the prepared raw disk | ||||
| // ------------------------------------------------------------------------------------ | ||||
| banner("PHASE 3: Launch via cloudhv_builder (disk from Phase 2)"); | ||||
| let b = cloudhv_builder(vmA); | ||||
| let b = disk(b, prep_res.raw_disk); | ||||
| let b = memory_mb(b, 4096); | ||||
| let b = vcpus(b, 2); | ||||
| // Optional extras: | ||||
| // let b = extra_arg(b, "--serial"); let b = extra_arg(b, "tty"); | ||||
| // let b = no_default_net(b); | ||||
|  | ||||
| let vm_id_a = ""; | ||||
| try { | ||||
|     vm_id_a = launch(b); | ||||
|     ok("builder.launch started VM id: " + vm_id_a); | ||||
| } catch (e) { | ||||
|     fail("builder.launch failed: " + e.to_string()); | ||||
|     throw "Stopping due to launch failure for vm-e2e-a"; | ||||
| } | ||||
|  | ||||
| // ------------------------------------------------------------------------------------ | ||||
| // Phase 4: Inspect VM info, list VMs | ||||
| // ------------------------------------------------------------------------------------ | ||||
| banner("PHASE 4: cloudhv_vm_info / cloudhv_vm_list"); | ||||
| try { | ||||
|     let info_a = cloudhv_vm_info(vm_id_a); | ||||
|     ok("cloudhv_vm_info:"); | ||||
|     dump_map(info_a); | ||||
| } catch (e) { | ||||
|     fail("cloudhv_vm_info failed: " + e.to_string()); | ||||
| } | ||||
|  | ||||
| try { | ||||
|     let vms = cloudhv_vm_list(); | ||||
|     ok("cloudhv_vm_list count = " + vms.len.to_string()); | ||||
| } catch (e) { | ||||
|     fail("cloudhv_vm_list failed: " + e.to_string()); | ||||
| } | ||||
|  | ||||
| // ------------------------------------------------------------------------------------ | ||||
| // Phase 5: Stop & delete VM A | ||||
| // ------------------------------------------------------------------------------------ | ||||
| banner("PHASE 5: Stop & delete VM A"); | ||||
| try { | ||||
|     cloudhv_vm_stop(vm_id_a, false); | ||||
|     ok("cloudhv_vm_stop graceful OK"); | ||||
| } catch (e) { | ||||
|     fail("cloudhv_vm_stop (graceful) failed: " + e.to_string() + " -> trying force"); | ||||
|     try { | ||||
|         cloudhv_vm_stop(vm_id_a, true); | ||||
|         ok("cloudhv_vm_stop force OK"); | ||||
|     } catch (e2) { | ||||
|         fail("cloudhv_vm_stop force failed: " + e2.to_string()); | ||||
|     } | ||||
| } | ||||
|  | ||||
| try { | ||||
|     cloudhv_vm_delete(vm_id_a, true); | ||||
|     ok("cloudhv_vm_delete OK (deleted disks)"); | ||||
| } catch (e) { | ||||
|     fail("cloudhv_vm_delete failed: " + e.to_string()); | ||||
| } | ||||
|  | ||||
| // ------------------------------------------------------------------------------------ | ||||
| // Phase 6: Launch VM via one-shot wrapper vm_easy_launch() | ||||
| // ------------------------------------------------------------------------------------ | ||||
| banner("PHASE 6: vm_easy_launch for VM B"); | ||||
| let vmB = "vm-e2e-b"; | ||||
| let vm_id_b = ""; | ||||
| try { | ||||
|     vm_id_b = vm_easy_launch("ubuntu", vmB, 4096, 2); | ||||
|     ok("vm_easy_launch started VM id: " + vm_id_b); | ||||
| } catch (e) { | ||||
|     fail("vm_easy_launch failed: " + e.to_string()); | ||||
|     throw "Stopping due to vm_easy_launch failure"; | ||||
| } | ||||
|  | ||||
| // ------------------------------------------------------------------------------------ | ||||
| // Phase 7: Inspect VM B info, list VMs | ||||
| // ------------------------------------------------------------------------------------ | ||||
| banner("PHASE 7: Inspect VM B"); | ||||
| try { | ||||
|     let info_b = cloudhv_vm_info(vm_id_b); | ||||
|     ok("cloudhv_vm_info (B):"); | ||||
|     dump_map(info_b); | ||||
| } catch (e) { | ||||
|     fail("cloudhv_vm_info (B) failed: " + e.to_string()); | ||||
| } | ||||
|  | ||||
| try { | ||||
|     let vms2 = cloudhv_vm_list(); | ||||
|     ok("cloudhv_vm_list count = " + vms2.len.to_string()); | ||||
| } catch (e) { | ||||
|     fail("cloudhv_vm_list failed: " + e.to_string()); | ||||
| } | ||||
|  | ||||
| // ------------------------------------------------------------------------------------ | ||||
| // Phase 8: Stop & delete VM B | ||||
| // ------------------------------------------------------------------------------------ | ||||
| banner("PHASE 8: Stop & delete VM B"); | ||||
| try { | ||||
|     cloudhv_vm_stop(vm_id_b, false); | ||||
|     ok("cloudhv_vm_stop (B) graceful OK"); | ||||
| } catch (e) { | ||||
|     fail("cloudhv_vm_stop (B) graceful failed: " + e.to_string() + " -> trying force"); | ||||
|     try { | ||||
|         cloudhv_vm_stop(vm_id_b, true); | ||||
|         ok("cloudhv_vm_stop (B) force OK"); | ||||
|     } catch (e2) { | ||||
|         fail("cloudhv_vm_stop (B) force failed: " + e2.to_string()); | ||||
|     } | ||||
| } | ||||
|  | ||||
| try { | ||||
|     cloudhv_vm_delete(vm_id_b, true); | ||||
|     ok("cloudhv_vm_delete (B) OK (deleted disks)"); | ||||
| } catch (e) { | ||||
|     fail("cloudhv_vm_delete (B) failed: " + e.to_string()); | ||||
| } | ||||
|  | ||||
| banner("DONE: All phases executed"); | ||||
		Reference in New Issue
	
	Block a user