From 4b4f3371b07090d540f51accf849a5b489ac1f1c Mon Sep 17 00:00:00 2001 From: Maxime Van Hees Date: Tue, 26 Aug 2025 16:50:59 +0200 Subject: [PATCH] WIP: automating VM deployment --- packages/system/virt/src/cloudhv/builder.rs | 170 +++++++++ packages/system/virt/src/cloudhv/mod.rs | 12 +- packages/system/virt/src/hostcheck/mod.rs | 153 ++++++++ packages/system/virt/src/image_prep/mod.rs | 349 ++++++++++++++++++ packages/system/virt/src/lib.rs | 2 + packages/system/virt/src/rhai.rs | 12 + .../system/virt/src/rhai/cloudhv_builder.rs | 136 +++++++ packages/system/virt/src/rhai/hostcheck.rs | 48 +++ packages/system/virt/src/rhai/image_prep.rs | 98 +++++ .../virt/tests/rhai/10_vm_end_to_end.rhai | 234 ++++++++++++ 10 files changed, 1213 insertions(+), 1 deletion(-) create mode 100644 packages/system/virt/src/cloudhv/builder.rs create mode 100644 packages/system/virt/src/hostcheck/mod.rs create mode 100644 packages/system/virt/src/image_prep/mod.rs create mode 100644 packages/system/virt/src/rhai/cloudhv_builder.rs create mode 100644 packages/system/virt/src/rhai/hostcheck.rs create mode 100644 packages/system/virt/src/rhai/image_prep.rs create mode 100644 packages/system/virt/tests/rhai/10_vm_end_to_end.rhai diff --git a/packages/system/virt/src/cloudhv/builder.rs b/packages/system/virt/src/cloudhv/builder.rs new file mode 100644 index 0000000..4f08fca --- /dev/null +++ b/packages/system/virt/src/cloudhv/builder.rs @@ -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, + flavor: Option, + memory_mb: u32, + vcpus: u32, + cmdline: Option, + extra_args: Vec, + 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 { + 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 { + 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 { + // 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) + } +} \ No newline at end of file diff --git a/packages/system/virt/src/cloudhv/mod.rs b/packages/system/virt/src/cloudhv/mod.rs index 4f5ee51..71d12d4 100644 --- a/packages/system/virt/src/cloudhv/mod.rs +++ b/packages/system/virt/src/cloudhv/mod.rs @@ -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 diff --git a/packages/system/virt/src/hostcheck/mod.rs b/packages/system/virt/src/hostcheck/mod.rs new file mode 100644 index 0000000..330eaf0 --- /dev/null +++ b/packages/system/virt/src/hostcheck/mod.rs @@ -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, + pub optional: Vec, + pub notes: Vec, +} + +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 { + let mut critical: Vec = Vec::new(); + let mut optional: Vec = Vec::new(); + let mut notes: Vec = 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, + }) +} \ No newline at end of file diff --git a/packages/system/virt/src/image_prep/mod.rs b/packages/system/virt/src/image_prep/mod.rs new file mode 100644 index 0000000..c35aef3 --- /dev/null +++ b/packages/system/virt/src/image_prep/mod.rs @@ -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, // e.g., "400::10/64" + pub gw6: Option, // 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/ + pub source: Option, + /// Optional VM target directory, defaults to $HOME/hero/virt/vms/ + pub target_dir: Option, + /// 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 { + 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 { + // 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 +} \ No newline at end of file diff --git a/packages/system/virt/src/lib.rs b/packages/system/virt/src/lib.rs index 103edbf..506edae 100644 --- a/packages/system/virt/src/lib.rs +++ b/packages/system/virt/src/lib.rs @@ -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; diff --git a/packages/system/virt/src/rhai.rs b/packages/system/virt/src/rhai.rs index e6642d0..d444123 100644 --- a/packages/system/virt/src/rhai.rs +++ b/packages/system/virt/src/rhai.rs @@ -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 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> { + 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> { + // 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> { + // Register type + engine.register_type_with_name::("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(()) +} \ No newline at end of file diff --git a/packages/system/virt/src/rhai/hostcheck.rs b/packages/system/virt/src/rhai/hostcheck.rs new file mode 100644 index 0000000..5440685 --- /dev/null +++ b/packages/system/virt/src/rhai/hostcheck.rs @@ -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> { + 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> { + engine.register_fn("host_check", host_check); + Ok(()) +} \ No newline at end of file diff --git a/packages/system/virt/src/rhai/image_prep.rs b/packages/system/virt/src/rhai/image_prep.rs new file mode 100644 index 0000000..3901735 --- /dev/null +++ b/packages/system/virt/src/rhai/image_prep.rs @@ -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> { + 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 { + m.get(k).and_then(|v| if v.is_string() { Some(v.clone().cast::()) } else { None }) +} +fn map_get_bool(m: &Map, k: &str) -> Option { + 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> { + // 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::()) } 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> { + engine.register_fn("image_prepare", image_prepare_rhai); + Ok(()) +} \ No newline at end of file diff --git a/packages/system/virt/tests/rhai/10_vm_end_to_end.rhai b/packages/system/virt/tests/rhai/10_vm_end_to_end.rhai new file mode 100644 index 0000000..5e350ae --- /dev/null +++ b/packages/system/virt/tests/rhai/10_vm_end_to_end.rhai @@ -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//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/ + 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"); \ No newline at end of file