From 182b0edeb79d99aa5d85d678ea8a71869f818ed4 Mon Sep 17 00:00:00 2001 From: Maxime Van Hees Date: Tue, 9 Sep 2025 10:31:07 +0200 Subject: [PATCH] cleanup --- packages/system/virt/src/cloudhv/builder.rs | 1 - packages/system/virt/src/cloudhv/mod.rs | 247 +----------------- packages/system/virt/src/cloudhv/net/mod.rs | 2 - packages/system/virt/src/hostcheck/mod.rs | 2 +- packages/system/virt/src/image_prep/mod.rs | 5 +- packages/system/virt/src/rhai/cloudhv.rs | 47 ++++ .../system/virt/src/rhai/cloudhv_builder.rs | 26 +- packages/system/virt/src/rhai/hostcheck.rs | 35 ++- packages/system/virt/src/rhai/image_prep.rs | 2 +- .../virt/tests/rhai/vm_clean_launch.rhai | 42 +-- 10 files changed, 115 insertions(+), 294 deletions(-) diff --git a/packages/system/virt/src/cloudhv/builder.rs b/packages/system/virt/src/cloudhv/builder.rs index 00aff87..3995c25 100644 --- a/packages/system/virt/src/cloudhv/builder.rs +++ b/packages/system/virt/src/cloudhv/builder.rs @@ -1,6 +1,5 @@ use crate::cloudhv::{vm_create, vm_start, CloudHvError, VmSpec}; use crate::image_prep::{image_prepare, Flavor as ImgFlavor, ImagePrepOptions, NetPlanOpts}; -use sal_process; use crate::cloudhv::net::{NetworkingProfileSpec, DefaultNatOptions, BridgeOptions}; /// Cloud Hypervisor VM Builder focused on Rhai ergonomics. diff --git a/packages/system/virt/src/cloudhv/mod.rs b/packages/system/virt/src/cloudhv/mod.rs index af0a9c6..ab9ba9f 100644 --- a/packages/system/virt/src/cloudhv/mod.rs +++ b/packages/system/virt/src/cloudhv/mod.rs @@ -5,8 +5,6 @@ use std::fs; use std::path::{Path, PathBuf}; use std::thread; use std::time::Duration; -use std::collections::hash_map::DefaultHasher; -use std::hash::{Hash, Hasher}; use sal_os; use sal_process; @@ -416,7 +414,7 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> { if let Some(profile) = profile_effective { match profile { - NetworkingProfileSpec::DefaultNat(mut nat) => { + NetworkingProfileSpec::DefaultNat(nat) => { // IPv6 handling (auto via Mycelium unless disabled) let mut ipv6_bridge_cidr: Option = None; if nat.ipv6_enable { @@ -579,8 +577,8 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> { std::env::var("HERO_VIRT_DHCP_LEASE_FILE") .unwrap_or_else(|_| format!("/var/lib/misc/dnsmasq-hero-{}.leases", bridge_name)) }); - let ipv4 = net::discover_ipv4_from_leases(&lease_path, &mac_lower, 12); - let ipv6 = net::discover_ipv6_on_bridge(&bridge_name, &mac_lower); + let _ipv4 = net::discover_ipv4_from_leases(&lease_path, &mac_lower, 12); + let _ipv6 = net::discover_ipv6_on_bridge(&bridge_name, &mac_lower); } Ok(()) @@ -713,253 +711,14 @@ pub fn vm_list() -> Result, CloudHvError> { Ok(out) } -fn tap_name_for_id(id: &str) -> String { - // Linux IFNAMSIZ is typically 15; keep "tap-" + 10 hex = 14 chars - let mut h = DefaultHasher::new(); - id.hash(&mut h); - let v = h.finish(); - let hex = format!("{:016x}", v); - format!("tap-{}", &hex[..10]) -} -fn ensure_tap_for_vm(bridge_name: &str, id: &str) -> Result { - let tap = tap_name_for_id(id); - let body = format!( - "BR={br} -TAP={tap} -UIDX=$(id -u) -GIDX=$(id -g) - -# Create TAP if missing and assign to current user/group -ip link show \"$TAP\" >/dev/null 2>&1 || ip tuntap add dev \"$TAP\" mode tap user \"$UIDX\" group \"$GIDX\" - -# Enslave to bridge and bring up (idempotent) -ip link set \"$TAP\" master \"$BR\" 2>/dev/null || true -ip link set \"$TAP\" up -", - br = shell_escape(bridge_name), - tap = shell_escape(&tap), - ); - let heredoc_tap = format!("bash -e -s <<'EOF'\n{}\nEOF\n", body); - - match sal_process::run(&heredoc_tap).silent(true).execute() { - Ok(res) if res.success => Ok(tap), - Ok(res) => Err(CloudHvError::CommandFailed(format!( - "Failed to ensure TAP '{}': {}", - tap, res.stderr - ))), - Err(e) => Err(CloudHvError::CommandFailed(format!( - "Failed to ensure TAP '{}': {}", - tap, e - ))), - } -} - -fn stable_mac_from_id(id: &str) -> String { - let mut h = DefaultHasher::new(); - id.hash(&mut h); - let v = h.finish(); - let b0 = (((v >> 40) & 0xff) as u8 & 0xfe) | 0x02; // locally administered, unicast - let b1 = ((v >> 32) & 0xff) as u8; - let b2 = ((v >> 24) & 0xff) as u8; - let b3 = ((v >> 16) & 0xff) as u8; - let b4 = ((v >> 8) & 0xff) as u8; - let b5 = (v & 0xff) as u8; - format!("{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", b0, b1, b2, b3, b4, b5) -} /// Discover the mycelium IPv6 address by inspecting the interface itself (no CLI dependency). /// Returns (interface_name, first global IPv6 address found on the interface). -fn get_mycelium_ipv6_addr(iface_hint: &str) -> Result<(String, String), CloudHvError> { - let iface = std::env::var("HERO_VIRT_MYCELIUM_IF").unwrap_or_else(|_| iface_hint.to_string()); - - // Query IPv6 addresses on the interface - let cmd = format!("ip -6 addr show dev {}", shell_escape(&iface)); - let res = sal_process::run(&cmd).silent(true).die(false).execute(); - let out = match res { - Ok(r) if r.success => r.stdout, - _ => { - return Err(CloudHvError::DependencyMissing(format!( - "mycelium interface '{}' not found or no IPv6 configured", - iface - ))) - } - }; - - // Extract the first global IPv6 address present on the interface. - for line in out.lines() { - let lt = line.trim(); - // Example line: "inet6 578:9fcf:.../7 scope global" - if lt.starts_with("inet6 ") && lt.contains("scope global") { - let parts: Vec<&str> = lt.split_whitespace().collect(); - if let Some(addr_cidr) = parts.get(1) { - let addr_only = addr_cidr.split('/').next().unwrap_or("").trim(); - if !addr_only.is_empty() && addr_only.parse::().is_ok() { - return Ok((iface, addr_only.to_string())); - } - } - } - } - - Err(CloudHvError::DependencyMissing(format!( - "no global IPv6 found on interface '{}'", - iface - ))) -} /// Derive a /64 prefix P from the mycelium IPv6 and return (P/64, P::2/64). -fn derive_ipv6_prefix_from_mycelium(m: &str) -> Result<(String, String), CloudHvError> { - let ip = m - .parse::() - .map_err(|e| CloudHvError::InvalidSpec(format!("invalid mycelium IPv6 address '{}': {}", m, e)))?; - let seg = ip.segments(); // [u16; 8] - // Take the top /64 from the mycelium address; zero the host half - let pfx = std::net::Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, 0); - // Router address for the bridge = P::2 - let router = std::net::Ipv6Addr::new(seg[0], seg[1], seg[2], seg[3], 0, 0, 0, 2); - let pfx_str = format!("{}/64", pfx); - let router_cidr = format!("{}/64", router); - Ok((pfx_str, router_cidr)) -} -fn ensure_host_net_prereq_dnsmasq_nftables( - bridge_name: &str, - bridge_addr_cidr: &str, - subnet_cidr: &str, - dhcp_start: &str, - dhcp_end: &str, - ipv6_bridge_cidr: Option<&str>, - mycelium_if: Option<&str>, -) -> Result<(), CloudHvError> { - // Dependencies - for bin in ["ip", "nft", "dnsmasq", "systemctl"] { - if sal_process::which(bin).is_none() { - return Err(CloudHvError::DependencyMissing(format!( - "{} not found on PATH; required for VM networking", - bin - ))); - } - } - - // Prepare optional IPv6 value (empty string when disabled) - let ipv6_cidr = ipv6_bridge_cidr.unwrap_or(""); - - // Build idempotent setup script - let body = format!( - "set -e -BR={br} -BR_ADDR={br_addr} -SUBNET={subnet} -DHCP_START={dstart} -DHCP_END={dend} -IPV6_CIDR={v6cidr} -LEASE_FILE=/var/lib/misc/dnsmasq-hero-$BR.leases - -# Determine default WAN interface -WAN_IF=$(ip -o route show default | awk '{{print $5}}' | head -n1) -if [ -z \"$WAN_IF\" ]; then - echo \"No default WAN interface detected (required for IPv4 NAT)\" >&2 - exit 2 -fi - -# Bridge creation (idempotent) -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 bridge address and forwarding (optional) -if [ -n \"$IPV6_CIDR\" ]; then - ip -6 addr replace \"$IPV6_CIDR\" dev \"$BR\" - sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null || true -fi - -# IPv4 forwarding -sysctl -w net.ipv4.ip_forward=1 >/dev/null - -# nftables NAT (idempotent) for IPv4 -nft list table ip hero >/dev/null 2>&1 || nft add table ip hero -nft list chain ip hero postrouting >/dev/null 2>&1 || nft add chain ip hero postrouting {{ type nat hook postrouting priority 100 \\; }} -nft list chain ip hero postrouting | grep -q \"ip saddr $SUBNET oifname \\\"$WAN_IF\\\" masquerade\" \ - || nft add rule ip hero postrouting ip saddr $SUBNET oifname \"$WAN_IF\" masquerade - -# dnsmasq DHCPv4 + RA/DHCPv6 config (idempotent) -mkdir -p /etc/dnsmasq.d -mkdir -p /var/lib/misc -CFG=/etc/dnsmasq.d/hero-$BR.conf -TMP=/etc/dnsmasq.d/.hero-$BR.conf.new - -RELOAD=0 -CONF=/etc/dnsmasq.conf -# Ensure conf-dir includes /etc/dnsmasq.d (simple fixed-string check to avoid regex escapes in Rust) -if ! grep -qF \"conf-dir=/etc/dnsmasq.d\" \"$CONF\"; then - printf '%s\n' 'conf-dir=/etc/dnsmasq.d,*.conf' >> \"$CONF\" - RELOAD=1 -fi - - -# Ensure lease file exists and is writable by dnsmasq user -touch \"$LEASE_FILE\" || true -chown dnsmasq:dnsmasq \"$LEASE_FILE\" 2>/dev/null || true - -# Always include IPv4 section -printf '%s\n' \ - \"interface=$BR\" \ - \"bind-interfaces\" \ - \"dhcp-authoritative\" \ - \"dhcp-range=$DHCP_START,$DHCP_END,12h\" \ - \"dhcp-option=option:dns-server,1.1.1.1,8.8.8.8\" \ - \"dhcp-leasefile=$LEASE_FILE\" >\"$TMP\" - -# Optionally append IPv6 RA/DHCPv6 -if [ -n \"$IPV6_CIDR\" ]; then - printf '%s\n' \ - \"enable-ra\" \ - \"dhcp-range=::,constructor:BR_PLACEHOLDER,ra-names,64,12h\" \ - \"dhcp-option=option6:dns-server,[2001:4860:4860::8888],[2606:4700:4700::1111]\" >>\"$TMP\" - sed -i \"s/BR_PLACEHOLDER/$BR/g\" \"$TMP\" -fi - -if [ ! -f \"$CFG\" ] || ! cmp -s \"$CFG\" \"$TMP\"; then - mv \"$TMP\" \"$CFG\" - if systemctl is-active --quiet dnsmasq; then - systemctl reload dnsmasq || systemctl restart dnsmasq || true - else - systemctl enable --now dnsmasq || true - fi -else - rm -f \"$TMP\" - systemctl enable --now dnsmasq || true -fi - -# Reload if main conf was updated to include conf-dir -if [ \"$RELOAD\" = \"1\" ]; then - systemctl reload dnsmasq || systemctl restart dnsmasq || true -fi -", - br = shell_escape(bridge_name), - br_addr = shell_escape(bridge_addr_cidr), - subnet = shell_escape(subnet_cidr), - dstart = shell_escape(dhcp_start), - dend = shell_escape(dhcp_end), - v6cidr = shell_escape(ipv6_cidr), - ); - - // Use a unique heredoc delimiter to avoid clashing with inner < Ok(()), - Ok(res) => Err(CloudHvError::CommandFailed(format!( - "Host networking setup failed: {}", - res.stderr - ))), - Err(e) => Err(CloudHvError::CommandFailed(format!( - "Host networking setup failed: {}", - e - ))), - } -} /// Render a shell-safe command string from vector of tokens fn shell_join(parts: &Vec) -> String { diff --git a/packages/system/virt/src/cloudhv/net/mod.rs b/packages/system/virt/src/cloudhv/net/mod.rs index 9374230..a0705b1 100644 --- a/packages/system/virt/src/cloudhv/net/mod.rs +++ b/packages/system/virt/src/cloudhv/net/mod.rs @@ -1,5 +1,3 @@ -use serde::{Deserialize, Serialize}; - use sal_process; use crate::cloudhv::CloudHvError; diff --git a/packages/system/virt/src/hostcheck/mod.rs b/packages/system/virt/src/hostcheck/mod.rs index 60108b1..3c62a4a 100644 --- a/packages/system/virt/src/hostcheck/mod.rs +++ b/packages/system/virt/src/hostcheck/mod.rs @@ -42,7 +42,7 @@ fn bin_missing(name: &str) -> bool { /// 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 optional: Vec = Vec::new(); let mut notes: Vec = Vec::new(); // Must run as root diff --git a/packages/system/virt/src/image_prep/mod.rs b/packages/system/virt/src/image_prep/mod.rs index 22cc02d..3801c27 100644 --- a/packages/system/virt/src/image_prep/mod.rs +++ b/packages/system/virt/src/image_prep/mod.rs @@ -1,5 +1,4 @@ use serde::{Deserialize, Serialize}; -use std::fs; use std::path::Path; use sal_os; @@ -443,7 +442,7 @@ if [ -f "$MNT_ROOT/etc/ssh/sshd_config" ]; then sed -i -E 's/^[[:space:]]*AuthenticationMethods[[:space:]].*$/# hero: removed AuthenticationMethods/' "$MNT_ROOT/etc/ssh/sshd_config" 2>/dev/null || true fi if [ -d "$MNT_ROOT/etc/ssh/sshd_config.d" ]; then - find "$MNT_ROOT/etc/ssh/sshd_config.d" -type f -name '*.conf' -exec sed -i -E 's/^[[:space:]]*AuthenticationMethods[[:space:]].*$/# hero: removed AuthenticationMethods/' {} + 2>/dev/null \; || true + find "$MNT_ROOT/etc/ssh/sshd_config.d" -type f -name '*.conf' -exec sed -i -E 's/^[[:space:]]*AuthenticationMethods[[:space:]].*$/# hero: removed AuthenticationMethods/' {{}} + 2>/dev/null \; || true fi # Set password for default user 'ubuntu' @@ -653,7 +652,7 @@ if [ -f /etc/ssh/sshd_config ]; then sed -i -E 's/^[[:space:]]*AuthenticationMethods[[:space:]].*$/# hero: removed AuthenticationMethods/' /etc/ssh/sshd_config 2>/dev/null || true fi if [ -d /etc/ssh/sshd_config.d ]; then - find /etc/ssh/sshd_config.d -type f -name '*.conf' -exec sed -i -E 's/^[[:space:]]*AuthenticationMethods[[:space:]].*$/# hero: removed AuthenticationMethods/' {} + 2>/dev/null \; || true + find /etc/ssh/sshd_config.d -type f -name '*.conf' -exec sed -i -E 's/^[[:space:]]*AuthenticationMethods[[:space:]].*$/# hero: removed AuthenticationMethods/' {{}} + 2>/dev/null \; || true fi # Ensure Include covers drop-ins diff --git a/packages/system/virt/src/rhai/cloudhv.rs b/packages/system/virt/src/rhai/cloudhv.rs index d123c1d..c1a614a 100644 --- a/packages/system/virt/src/rhai/cloudhv.rs +++ b/packages/system/virt/src/rhai/cloudhv.rs @@ -171,6 +171,13 @@ pub fn cloudhv_vm_info(id: &str) -> Result> { } pub fn cloudhv_discover_ipv4_from_leases(lease_path: &str, mac_lower: &str, timeout_secs: i64) -> Dynamic { + // Check verbosity from environment variable, default to verbose + let verbose = std::env::var("VIRT_VERBOSE").unwrap_or_else(|_| "1".to_string()) == "1"; + + if verbose { + println!("🔍 Discovering VM network addresses..."); + } + match crate::cloudhv::net::discover_ipv4_from_leases(lease_path, mac_lower, timeout_secs as u64) { Some(ip) => ip.into(), None => Dynamic::UNIT, @@ -184,6 +191,45 @@ pub fn cloudhv_discover_ipv6_on_bridge(bridge_name: &str, mac_lower: &str) -> Dy } } +pub fn cloudhv_display_network_info(vm_id: &str, ipv4: Dynamic, ipv6: Dynamic) { + // Check verbosity from environment variable, default to verbose + let verbose = std::env::var("VIRT_VERBOSE").unwrap_or_else(|_| "1".to_string()) == "1"; + + if !verbose { + return; + } + + println!("✅ VM {} is ready!", vm_id); + println!(""); + println!("🌐 Network Information:"); + + if ipv4.is_string() && !ipv4.clone().cast::().is_empty() { + println!(" IPv4: {}", ipv4.clone().cast::()); + } else { + println!(" IPv4: Not assigned yet (VM may still be configuring)"); + } + + if ipv6.is_string() && !ipv6.clone().cast::().is_empty() { + println!(" IPv6: {}", ipv6.clone().cast::()); + } else { + println!(" IPv6: Not available"); + } + + println!(""); + println!("💡 VM is running in the background. To connect:"); + + let ssh_addr = if ipv4.is_string() && !ipv4.clone().cast::().is_empty() { + ipv4.cast::() + } else { + "".to_string() + }; + println!(" SSH: ssh ubuntu@{}", ssh_addr); + println!(""); + println!("🛑 To stop the VM later:"); + println!(" cloudhv_vm_stop(\"{}\", false);", vm_id); + println!(" cloudhv_vm_delete(\"{}\", true);", vm_id); +} + // Module registration pub fn register_cloudhv_module(engine: &mut Engine) -> Result<(), Box> { @@ -195,5 +241,6 @@ pub fn register_cloudhv_module(engine: &mut Engine) -> Result<(), Box CloudHvBuilder { @@ -85,14 +85,37 @@ fn network_custom(b: CloudHvBuilder, args: Array) -> CloudHvBuilder { } fn launch(mut b: CloudHvBuilder) -> Result> { + // Check verbosity from environment variable, default to verbose + let verbose = std::env::var("VIRT_VERBOSE").unwrap_or_else(|_| "1".to_string()) == "1"; + + if verbose { + println!("Preparing Ubuntu image and configuring VM..."); + } + b.launch().map_err(|e| { Box::new(EvalAltResult::ErrorRuntime( format!("cloudhv builder launch failed: {}", e).into(), rhai::Position::NONE, )) + }).map(|vm_id| { + if verbose { + println!("✅ VM launched successfully"); + } + vm_id }) } +fn wait_for_vm_boot(seconds: i64) { + // Check verbosity from environment variable, default to verbose + let verbose = std::env::var("VIRT_VERBOSE").unwrap_or_else(|_| "1".to_string()) == "1"; + + if verbose { + println!("⏳ Waiting {} seconds for VM to boot and configure network...", seconds); + } + + std::thread::sleep(std::time::Duration::from_secs(seconds as u64)); +} + // Noob-friendly one-shot wrapper fn vm_easy_launch(flavor: &str, id: &str, memory_mb: i64, vcpus: i64) -> Result> { // Preflight @@ -172,6 +195,7 @@ pub fn register_cloudhv_builder_module(engine: &mut Engine) -> Result<(), Box Map { } fn host_check() -> Result> { + // Check verbosity from environment variable, default to verbose + let verbose = std::env::var("VIRT_VERBOSE").unwrap_or_else(|_| "1".to_string()) == "1"; + + if verbose { + println!("Checking system requirements..."); + } + match host_check_deps() { - Ok(rep) => Ok(report_to_map(&rep)), + Ok(rep) => { + if verbose { + if rep.ok { + println!("✅ System requirements met"); + } else { + println!("❌ System check failed - missing dependencies:"); + if !rep.critical.is_empty() { + println!("Critical:"); + for dep in &rep.critical { + println!(" - {}", dep); + } + } + if !rep.optional.is_empty() { + println!("Optional:"); + for dep in &rep.optional { + println!(" - {}", dep); + } + } + } + } + Ok(report_to_map(&rep)) + }, Err(e) => { + if verbose { + println!("❌ System check failed - missing dependencies:"); + println!("Critical:"); + println!(" - host_check failed: {}", e); + } let mut m = Map::new(); m.insert("ok".into(), Dynamic::FALSE); let mut crit = Array::new(); diff --git a/packages/system/virt/src/rhai/image_prep.rs b/packages/system/virt/src/rhai/image_prep.rs index 3901735..3297571 100644 --- a/packages/system/virt/src/rhai/image_prep.rs +++ b/packages/system/virt/src/rhai/image_prep.rs @@ -1,5 +1,5 @@ use crate::image_prep::{image_prepare, Flavor, ImagePrepOptions, NetPlanOpts}; -use rhai::{Array, Dynamic, Engine, EvalAltResult, Map}; +use rhai::{Engine, EvalAltResult, Map}; fn parse_flavor(s: &str) -> Result> { match s { diff --git a/packages/system/virt/tests/rhai/vm_clean_launch.rhai b/packages/system/virt/tests/rhai/vm_clean_launch.rhai index 87cdc89..e831006 100644 --- a/packages/system/virt/tests/rhai/vm_clean_launch.rhai +++ b/packages/system/virt/tests/rhai/vm_clean_launch.rhai @@ -4,28 +4,12 @@ let vm_id = "vm-clean-test"; // Phase 1: Host check -print("Checking system requirements..."); let hc = host_check(); if !(hc.ok == true) { - print("❌ System check failed - missing dependencies:"); - if hc.critical != () && hc.critical.len() > 0 { - print("Critical:"); - for dep in hc.critical { - print(" - " + dep); - } - } - if hc.optional != () && hc.optional.len() > 0 { - print("Optional:"); - for dep in hc.optional { - print(" - " + dep); - } - } throw "Host check failed: missing dependencies"; } -print("✅ System requirements met"); // Phase 2: Create VM using fluent builder pattern -print("Preparing Ubuntu image and configuring VM..."); let vm_id_actual = ""; try { vm_id_actual = cloudhv_builder(vm_id) @@ -39,37 +23,15 @@ try { } // Phase 3: Wait for VM to boot and get network configuration -print("✅ VM launched successfully"); -print("⏳ Waiting 10 seconds for VM to boot and configure network..."); -sleep(10); +wait_for_vm_boot(10); // Phase 4: Discover VM IP addresses -print("🔍 Discovering VM network addresses..."); let mac_addr = "a2:26:1e:ac:96:3a"; // This should be derived from vm_id_actual let ipv4 = cloudhv_discover_ipv4_from_leases("/var/lib/misc/dnsmasq-hero-br-hero.leases", mac_addr, 30); let ipv6 = cloudhv_discover_ipv6_on_bridge("br-hero", mac_addr); // Phase 5: Display connection info -print("✅ VM " + vm_id_actual + " is ready!"); -print(""); -print("🌐 Network Information:"); -if ipv4 != () && ipv4 != "" { - print(" IPv4: " + ipv4); -} else { - print(" IPv4: Not assigned yet (VM may still be configuring)"); -} -if ipv6 != () && ipv6 != "" { - print(" IPv6: " + ipv6); -} else { - print(" IPv6: Not available"); -} -print(""); -print("💡 VM is running in the background. To connect:"); -print(" SSH: ssh ubuntu@" + (if ipv4 != () && ipv4 != "" { ipv4 } else { "" })); -print(""); -print("🛑 To stop the VM later:"); -print(" cloudhv_vm_stop(\"" + vm_id_actual + "\", false);"); -print(" cloudhv_vm_delete(\"" + vm_id_actual + "\", true);"); +cloudhv_display_network_info(vm_id_actual, ipv4, ipv6); /* try {