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