From af89ef014949c3244a93c50925abcbcea3ef79fc Mon Sep 17 00:00:00 2001 From: Maxime Van Hees Date: Thu, 21 Aug 2025 18:57:20 +0200 Subject: [PATCH] networking VMs (WIP) --- packages/system/virt/src/cloudhv/mod.rs | 172 ++++++++++ .../virt/tests/rhai/05_cloudhv_diag.rhai | 148 +++++++++ .../rhai/06_cloudhv_cloudinit_dhcpd.rhai | 309 +++++++++++++++++ .../tests/rhai/07_cloudhv_ubuntu_ssh.rhai | 311 ++++++++++++++++++ 4 files changed, 940 insertions(+) create mode 100644 packages/system/virt/tests/rhai/05_cloudhv_diag.rhai create mode 100644 packages/system/virt/tests/rhai/06_cloudhv_cloudinit_dhcpd.rhai create mode 100644 packages/system/virt/tests/rhai/07_cloudhv_ubuntu_ssh.rhai diff --git a/packages/system/virt/src/cloudhv/mod.rs b/packages/system/virt/src/cloudhv/mod.rs index e35c78d..248c6e6 100644 --- a/packages/system/virt/src/cloudhv/mod.rs +++ b/packages/system/virt/src/cloudhv/mod.rs @@ -5,6 +5,8 @@ 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; @@ -299,6 +301,32 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> { parts.push("--console".into()); parts.push("off".into()); + // Networking prerequisites (bridge + NAT via nftables + dnsmasq DHCP) + // Defaults can be overridden via env: + // HERO_VIRT_BRIDGE_NAME, HERO_VIRT_BRIDGE_ADDR_CIDR, HERO_VIRT_SUBNET_CIDR, HERO_VIRT_DHCP_START, HERO_VIRT_DHCP_END + let bridge_name = std::env::var("HERO_VIRT_BRIDGE_NAME").unwrap_or_else(|_| "br-hero".into()); + let bridge_addr_cidr = std::env::var("HERO_VIRT_BRIDGE_ADDR_CIDR").unwrap_or_else(|_| "172.30.0.1/24".into()); + let subnet_cidr = std::env::var("HERO_VIRT_SUBNET_CIDR").unwrap_or_else(|_| "172.30.0.0/24".into()); + let dhcp_start = std::env::var("HERO_VIRT_DHCP_START").unwrap_or_else(|_| "172.30.0.50".into()); + let dhcp_end = std::env::var("HERO_VIRT_DHCP_END").unwrap_or_else(|_| "172.30.0.250".into()); + + // Ensure host-side networking (requires root privileges / CAP_NET_ADMIN) + ensure_host_net_prereq_dnsmasq_nftables( + &bridge_name, + &bridge_addr_cidr, + &subnet_cidr, + &dhcp_start, + &dhcp_end, + )?; + + // Ensure a TAP device for this VM and attach to the bridge + let tap_name = ensure_tap_for_vm(&bridge_name, id)?; + // Stable locally-administered MAC derived from VM id + let mac = stable_mac_from_id(id); + + parts.push("--net".into()); + parts.push(format!("tap={},mac={}", tap_name, mac)); + if let Some(extra) = rec.spec.extra_args.clone() { for e in extra { parts.push(e); @@ -480,6 +508,150 @@ 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 script = format!( + "#!/bin/bash -e +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), + ); + + match sal_process::run(&script).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) +} + +fn ensure_host_net_prereq_dnsmasq_nftables( + bridge_name: &str, + bridge_addr_cidr: &str, + subnet_cidr: &str, + dhcp_start: &str, + dhcp_end: &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 + ))); + } + } + + // Build idempotent setup script + let script = format!( + "#!/bin/bash -e +set -e + +BR={br} +BR_ADDR={br_addr} +SUBNET={subnet} +DHCP_START={dstart} +DHCP_END={dend} + +# Determine default WAN interface +WAN_IF=$(ip -o route show default | awk '{{print $5}}' | head -n1) + +# 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 + +# IPv4 forwarding +sysctl -w net.ipv4.ip_forward=1 >/dev/null + +# nftables NAT (idempotent) +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 DHCP config (idempotent) +mkdir -p /etc/dnsmasq.d +CFG=/etc/dnsmasq.d/hero-$BR.conf +TMP=/etc/dnsmasq.d/.hero-$BR.conf.new +cat >\"$TMP\" < 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 { let mut s = String::new(); diff --git a/packages/system/virt/tests/rhai/05_cloudhv_diag.rhai b/packages/system/virt/tests/rhai/05_cloudhv_diag.rhai new file mode 100644 index 0000000..02cecc4 --- /dev/null +++ b/packages/system/virt/tests/rhai/05_cloudhv_diag.rhai @@ -0,0 +1,148 @@ +// Cloud Hypervisor diagnostic script +// Creates a VM, starts CH, verifies PID, API socket, ch-remote info, and tails logs. + +print("=== CloudHV Diagnostic ==="); + +// Dependency check +let chs = which("cloud-hypervisor-static"); +let chrs = which("ch-remote-static"); +let ch_missing = (chs == () || chs == ""); +let chr_missing = (chrs == () || chrs == ""); +if ch_missing || chr_missing { + print("cloud-hypervisor-static and/or ch-remote-static not available - aborting."); + exit(); +} + +// Inputs +let firmware_path = "/tmp/virt_images/hypervisor-fw"; +let disk_path = "/tmp/virt_images/noble-server-cloudimg-amd64.img"; + +if !exist(firmware_path) { + print(`Firmware not found: ${firmware_path}`); + exit(); +} +if !exist(disk_path) { + print(`Disk image not found: ${disk_path}`); + exit(); +} + +// Unique ID +let rid = run_silent("date +%s%N"); +let suffix = if rid.success && rid.stdout != "" { rid.stdout.trim() } else { "100000" }; +let vm_id = `diagvm_${suffix}`; + +// Socket path will be obtained from VM info (SAL populates spec.api_socket after start) + +// Build minimal spec; let SAL decide the api_socket under the VM dir +let spec = #{ + "id": vm_id, + "disk_path": disk_path, + "vcpus": 1, + "memory_mb": 512 +}; +spec.firmware_path = firmware_path; + +fn pid_alive(p) { + if p == () { return false; } + // Use /proc to avoid noisy "kill: No such process" messages from kill -0 + return exist(`/proc/${p}`); +} + +fn tail_log(p, n) { + if exist(p) { + let r = run_silent(`tail -n ${n} ${p}`); + if r.success { print(r.stdout); } else { print(r.stderr); } + } else { + print(`Log file not found: ${p}`); + } +} + +try { + print("--- Create VM spec ---"); + let created = cloudhv_vm_create(spec); + print(`created: ${created}`); +} catch (err) { + print(`create failed: ${err}`); + exit(); +} + +// Read back info to get SAL-resolved log_file path +let info0 = cloudhv_vm_info(vm_id); +let log_file = info0.runtime.log_file; + +// Rely on SAL to handle socket directory creation and stale-socket cleanup + +print("--- Start VM ---"); +try { + cloudhv_vm_start(vm_id); + print("start invoked"); +} catch (err) { + print(`start failed: ${err}`); + tail_log(log_file, 200); + exit(); +} + +// Fetch PID and discover API socket path from updated spec +let info1 = cloudhv_vm_info(vm_id); +let pid = info1.runtime.pid; +let api_sock = info1.spec.api_socket; +print(`pid=${pid}`); +print(`api_sock_from_sal=${api_sock}`); + +// Wait for socket file +let sock_ok = false; +for x in 0..50 { + if exist(api_sock) { sock_ok = true; break; } + sleep(1); +} +print(`api_sock_exists=${sock_ok} path=${api_sock}`); + +// Probe ch-remote info +let info_ok = false; +let last_err = ""; +if sock_ok { + for x in 0..20 { + let r = run_silent(`ch-remote-static --api-socket ${api_sock} info`); + if r.success { + info_ok = true; + print("ch-remote info OK"); + break; + } else { + last_err = if r.stderr != "" { r.stderr } else { r.stdout }; + sleep(1); + } + } +} +if !info_ok { + print("ch-remote info FAILED"); + if last_err != "" { print(last_err); } + let alive = pid_alive(pid); + print(`pid_alive=${alive}`); + print("--- Last 200 lines of CH log ---"); + tail_log(log_file, 200); + print("--- End of log ---"); +} else { + print("--- Stop via SAL (force) ---"); + try { + cloudhv_vm_stop(vm_id, true); + print("SAL stop invoked (force)"); + } catch (err) { + print(`stop failed: ${err}`); + } + // wait for exit (check original PID) + for x in 0..30 { + if !pid_alive(pid) { break; } + sleep(1); + } + print(`pid_alive_after_stop=${pid_alive(pid)}`); +} + +print("--- Cleanup ---"); +try { + cloudhv_vm_delete(vm_id, false); + print("vm deleted"); +} catch (err) { + print(`delete failed: ${err}`); +} + +print("=== Diagnostic done ==="); \ No newline at end of file diff --git a/packages/system/virt/tests/rhai/06_cloudhv_cloudinit_dhcpd.rhai b/packages/system/virt/tests/rhai/06_cloudhv_cloudinit_dhcpd.rhai new file mode 100644 index 0000000..77608d7 --- /dev/null +++ b/packages/system/virt/tests/rhai/06_cloudhv_cloudinit_dhcpd.rhai @@ -0,0 +1,309 @@ +// Cloud-init NoCloud + host DHCP (dnsmasq) provisioning for Cloud Hypervisor +// - Accepts a user-supplied SSH public key +// - Ensures Ubuntu cloud image via SAL qcow2 builder +// - Sets up host bridge br0 and tap0, and runs an ephemeral dnsmasq bound to br0 +// - Builds NoCloud seed ISO (cloud-localds preferred; genisoimage fallback) +// - Creates/starts a VM and prints SSH connection instructions +// +// Requirements (run this script with privileges that allow sudo commands): +// - cloud-hypervisor-static, ch-remote-static +// - cloud-image-utils (for cloud-localds) or genisoimage/xorriso +// - dnsmasq, iproute2 +// - qemu tools already used by qcow2 builder +// +// Note: This script uses sudo for network and dnsmasq operations. + +print("=== CloudHV + cloud-init + host DHCP (dnsmasq) ==="); + +// ----------- User input ----------- +let user_pubkey = "ssh-ed25519 REPLACE_WITH_YOUR_PUBLIC_KEY user@host"; + +// Optional: choose boot method. If firmware is present in common locations, it will be used. +// Otherwise, if kernel_path exists, direct kernel boot will be used. +// If neither is found, the script will abort before starting the VM. +let firmware_path_override = ""; // e.g., "/usr/share/cloud-hypervisor/hypervisor-fw" +let kernel_path_override = ""; // e.g., "/path/to/vmlinux" +let kernel_cmdline_override = "console=ttyS0 reboot=k panic=1"; + +// Network parameters (local-only setup) +let bridge = "br0"; +let br_cidr = "192.168.127.1/24"; +let br_ip = "192.168.127.1"; +let tap = "tap0"; +let mac = "02:00:00:00:00:10"; // locally administered MAC + +// Paths +let base_dir = "/tmp/virt_images"; +let seed_iso = `${base_dir}/seed.iso`; +let user_data = `${base_dir}/user-data`; +let meta_data = `${base_dir}/meta-data`; +let dnsmasq_pid = `${base_dir}/dnsmasq.pid`; +let dnsmasq_lease= `${base_dir}/dnsmasq.leases`; +let dnsmasq_log = `${base_dir}/dnsmasq.log`; + +// ----------- Dependency checks ----------- +print("\n--- Checking dependencies ---"); +let chs = which("cloud-hypervisor-static"); +let chrs = which("ch-remote-static"); +let clds = which("cloud-localds"); +let geniso = which("genisoimage"); +let dns = which("dnsmasq"); +let ipt = which("ip"); + +let missing = false; +if chs == () || chs == "" { + print("❌ cloud-hypervisor-static not found on PATH"); + missing = true; +} +if chrs == () || chrs == "" { + print("❌ ch-remote-static not found on PATH"); + missing = true; +} +if (clds == () || clds == "") && (geniso == () || geniso == "") { + print("❌ Neither cloud-localds nor genisoimage is available. Install cloud-image-utils or genisoimage."); + missing = true; +} +if dns == () || dns == "" { + print("❌ dnsmasq not found on PATH"); + missing = true; +} +if ipt == () || ipt == "" { + print("❌ ip (iproute2) not found on PATH"); + missing = true; +} +if missing { + print("=== Aborting due to missing dependencies ==="); + exit(); +} +print("✓ Dependencies look OK"); + +// ----------- Ensure base image ----------- +print("\n--- Ensuring Ubuntu 24.04 cloud image ---"); +let base; +try { + // Adjust the size_gb as desired; this resizes the cloud image sparsely. + base = qcow2_build_ubuntu_24_04_base(base_dir, 10); +} catch (err) { + print(`❌ Failed to build/ensure base image: ${err}`); + exit(); +} +let disk_path = base.base_image_path; +print(`✓ Using base image: ${disk_path}`); + +// ----------- Host networking (bridge + tap) ----------- +print("\n--- Configuring host networking (bridge + tap) ---"); +// Idempotent: create br0 if missing; assign IP if not present; set up +run_silent(`sudo sh -lc 'ip link show ${bridge} >/dev/null 2>&1 || ip link add ${bridge} type bridge'`); +run_silent(`sudo sh -lc 'ip addr show dev ${bridge} | grep -q "${br_cidr}" || ip addr add ${br_cidr} dev ${bridge}'`); +run_silent(`sudo sh -lc 'ip link set ${bridge} up'`); + +// Idempotent: create tap and attach to bridge +run_silent(`sudo sh -lc 'ip link show ${tap} >/dev/null 2>&1 || ip tuntap add dev ${tap} mode tap'`); +run_silent(`sudo sh -lc 'bridge link | grep -q "${tap}" || ip link set ${tap} master ${bridge}'`); +run_silent(`sudo sh -lc 'ip link set ${tap} up'`); +print(`✓ Bridge ${bridge} and tap ${tap} configured`); + +// ----------- Start/ensure dnsmasq on br0 ----------- +print("\n--- Ensuring dnsmasq serving DHCP on the bridge ---"); +// If an instance with our pid-file is running, keep it; otherwise start a new one bound to br0. +// Use --port=0 to avoid DNS port conflicts; we only need DHCP here. +let dns_state = run_silent(`bash -lc 'if [ -f ${dnsmasq_pid} ] && ps -p $(cat ${dnsmasq_pid}) >/dev/null 2>&1; then echo RUNNING; else echo STOPPED; fi'`); +let need_start = true; +if dns_state.success && dns_state.stdout.trim() == "RUNNING" { + print("✓ dnsmasq already running (pid file present and alive)"); + need_start = false; +} else { + // Clean stale files + run_silent(`bash -lc 'rm -f ${dnsmasq_pid} ${dnsmasq_lease}'`); +} + +if need_start { + let cmd = ` + nohup sudo dnsmasq \ + --port=0 \ + --bind-interfaces \ + --except-interface=lo \ + --interface=${bridge} \ + --dhcp-range=192.168.127.100,192.168.127.200,12h \ + --dhcp-option=option:router,${br_ip} \ + --dhcp-option=option:dns-server,1.1.1.1 \ + --pid-file=${dnsmasq_pid} \ + --dhcp-leasefile=${dnsmasq_lease} \ + > ${dnsmasq_log} 2>&1 & + echo $! >/dev/null + `; + let r = run_silent(`bash -lc ${cmd.stringify()}`); + if !r.success { + print(`❌ Failed to start dnsmasq. Check log: ${dnsmasq_log}`); + exit(); + } + // Wait briefly for pid file + sleep(1); + let chk = run_silent(`bash -lc 'test -f ${dnsmasq_pid} && ps -p $(cat ${dnsmasq_pid}) >/dev/null 2>&1 && echo OK || echo FAIL'`); + if !(chk.success && chk.stdout.trim() == "OK") { + print(`❌ dnsmasq did not come up. See ${dnsmasq_log}`); + exit(); + } + print("✓ dnsmasq started (DHCP on br0)"); +} + +// ----------- Build cloud-init NoCloud seed (user-data/meta-data) ----------- +print("\n--- Building NoCloud seed (user-data, meta-data) ---"); +run_silent(`bash -lc 'mkdir -p ${base_dir}'`); + +// Compose user-data and meta-data content +let ud = `#cloud-config +users: + - name: ubuntu + groups: [adm, cdrom, dialout, lxd, plugdev, sudo] + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/bash + lock_passwd: true + ssh_authorized_keys: + - ${user_pubkey} +ssh_pwauth: false +package_update: true +`; +let md = `instance-id: iid-ubuntu-noble-001 +local-hostname: noblevm +`; + +// Write files via heredoc +let wr1 = run_silent(`bash -lc "cat > ${user_data} <<'EOF'\n${ud}\nEOF"`); +if !wr1.success { print(`❌ Failed to write ${user_data}`); exit(); } +let wr2 = run_silent(`bash -lc "cat > ${meta_data} <<'EOF'\n${md}\nEOF"`); +if !wr2.success { print(`❌ Failed to write ${meta_data}`); exit(); } + +// Build seed ISO (prefer cloud-localds) +let built = false; +if !(clds == () || clds == "") { + let r = run_silent(`bash -lc "sudo cloud-localds ${seed_iso} ${user_data} ${meta_data}"`); + if r.success { + built = true; + } +} +if !built { + if geniso == () || geniso == "" { + print("❌ Neither cloud-localds nor genisoimage succeeded/available to build seed.iso"); + exit(); + } + let r2 = run_silent(`bash -lc "sudo genisoimage -output ${seed_iso} -volid cidata -joliet -rock ${user_data} ${meta_data}"`); + if !r2.success { + print("❌ genisoimage failed to create seed.iso"); + exit(); + } +} +print(`✓ Seed ISO: ${seed_iso}`); + +// ----------- Determine boot method (firmware or kernel) ----------- +print("\n--- Determining boot method ---"); +let firmware_path = ""; +if firmware_path_override != "" && exist(firmware_path_override) { + firmware_path = firmware_path_override; +} else { + let candidates = [ + "/usr/local/share/cloud-hypervisor/hypervisor-fw", + "/usr/share/cloud-hypervisor/hypervisor-fw", + "/usr/lib/cloud-hypervisor/hypervisor-fw", + "/tmp/virt_images/hypervisor-fw" + ]; + for p in candidates { + if exist(p) { firmware_path = p; break; } + } +} +let kernel_path = ""; +if kernel_path_override != "" && exist(kernel_path_override) { + kernel_path = kernel_path_override; +} +if firmware_path == "" && kernel_path == "" { + print("❌ No firmware_path or kernel_path found. Set firmware_path_override or kernel_path_override at top and re-run."); + exit(); +} +if firmware_path != "" { + print(`✓ Using firmware boot: ${firmware_path}`); +} else { + print(`✓ Using direct kernel boot: ${kernel_path}`); +} + +// ----------- Create and start VM ----------- +print("\n--- Creating and starting VM ---"); +let rid = run_silent("date +%s%N"); +let suffix = if rid.success && rid.stdout != "" { rid.stdout.trim() } else { "100000" }; +let vm_id = `noble_vm_${suffix}`; + +let spec = #{ + "id": vm_id, + "disk_path": disk_path, + "api_socket": "", + "vcpus": 2, + "memory_mb": 2048 +}; +if firmware_path != "" { + spec.firmware_path = firmware_path; +} else { + spec.kernel_path = kernel_path; + spec.cmdline = kernel_cmdline_override; +} +spec.extra_args = [ + "--disk", `path=${seed_iso},readonly=true`, + "--net", `tap=${tap},mac=${mac}` +]; + +try { + let created = cloudhv_vm_create(spec); + print(`✓ VM created: ${created}`); +} catch (err) { + print(`❌ VM create failed: ${err}`); + exit(); +} + +try { + cloudhv_vm_start(vm_id); + print("✓ VM start invoked"); +} catch (err) { + print(`❌ VM start failed: ${err}`); + exit(); +} + +// ----------- Wait for DHCP lease and print access info ----------- +print("\n--- Waiting for DHCP lease from dnsmasq ---"); +let vm_ip = ""; +for i in 0..60 { + sleep(1); + let lr = run_silent(`bash -lc "if [ -f ${dnsmasq_lease} ]; then awk '\\$2 ~ /${mac}/ {print \\$3}' ${dnsmasq_lease} | tail -n1; fi"`); + if lr.success { + let ip = lr.stdout.trim(); + if ip != "" { + vm_ip = ip; + break; + } + } +} +if vm_ip == "" { + print("⚠️ Could not discover VM IP from leases yet. You can check leases and retry:"); + print(` cat ${dnsmasq_lease}`); +} else { + print(`✓ Lease acquired: ${vm_ip}`); +} + +print("\n--- VM access details ---"); +print(`VM ID: ${vm_id}`); +let info = cloudhv_vm_info(vm_id); +print(`API socket: ${info.spec.api_socket}`); +print(`Console log: ${info.runtime.log_file}`); +print(`Bridge: ${bridge} at ${br_ip}, TAP: ${tap}, MAC: ${mac}`); +print(`Seed: ${seed_iso}`); +if vm_ip != "" { + print("\nSSH command (key-only; default user 'ubuntu'):"); + print(`ssh -o StrictHostKeyChecking=no ubuntu@${vm_ip}`); +} else { + print("\nSSH command (replace after you see a lease):"); + print(`ssh -o StrictHostKeyChecking=no ubuntu@`); +} + +print("\nCleanup hints (manual):"); +print(`- Stop dnsmasq: sudo kill \$(cat ${dnsmasq_pid})`); +print(`- Remove TAP: sudo ip link set ${tap} down; sudo ip link del ${tap}`); +print(" (Keep the bridge if you will reuse it.)"); + +print("\n=== Completed ==="); \ No newline at end of file diff --git a/packages/system/virt/tests/rhai/07_cloudhv_ubuntu_ssh.rhai b/packages/system/virt/tests/rhai/07_cloudhv_ubuntu_ssh.rhai new file mode 100644 index 0000000..e411f73 --- /dev/null +++ b/packages/system/virt/tests/rhai/07_cloudhv_ubuntu_ssh.rhai @@ -0,0 +1,311 @@ +// Create and boot an Ubuntu 24.04 VM with cloud-init SSH key injection on Cloud Hypervisor +// - Uses qcow2 base image builder from SAL +// - Builds a NoCloud seed ISO embedding your SSH public key +// - Starts the VM; host networking prerequisites (bridge/dnsmasq/nftables) are ensured by CloudHV SAL +// - Attempts to discover the VM IP from dnsmasq leases and prints SSH instructions +// +// Requirements on host: +// - cloud-hypervisor-static, ch-remote-static +// - cloud-localds (preferred) OR genisoimage +// - qemu-img (already used by qcow2 SAL) +// - dnsmasq + nftables (will be handled by SAL during vm_start) +// +// Note: +// - SAL CloudHV networking will create a bridge br-hero, enable dnsmasq, and add a NAT rule via nftables +// - This script does NOT manage host networking; it relies on SAL to do so during vm_start() + +print("=== CloudHV Ubuntu 24.04 with SSH key (cloud-init) ==="); + +// ---------- Inputs ---------- +let user_pubkey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFyZJCEsvRc0eitsOoq+ywC5Lmqejvk3hXMVbO0AxPrd maxime@maxime-arch"; + +// Optional overrides for boot method (if firmware is present, it will be preferred) +let firmware_path_override = ""; // e.g., "/usr/share/cloud-hypervisor/hypervisor-fw" +let kernel_path_override = ""; // e.g., "/path/to/vmlinux" +let kernel_cmdline = "console=ttyS0 reboot=k panic=1"; + +// Cloud-init hostname and instance id (used to identify leases reliably) +let cloudinit_hostname = "noblevm"; +let cloudinit_instance_id = "iid-ubuntu-noble-ssh"; + +// Paths +let base_dir = "/tmp/virt_images"; +let seed_iso = `${base_dir}/seed-ssh.iso`; +let user_data = `${base_dir}/user-data`; +let meta_data = `${base_dir}/meta-data`; + +// ---------- Dependency checks ---------- +print("\n--- Checking dependencies ---"); +let chs = which("cloud-hypervisor-static"); +let chrs = which("ch-remote-static"); +let clds = which("cloud-localds"); +let geniso = which("genisoimage"); +let qemu = which("qemu-img"); + +let missing = false; +if chs == () || chs == "" { + print("❌ cloud-hypervisor-static not found on PATH"); + missing = true; +} +if chrs == () || chrs == "" { + print("❌ ch-remote-static not found on PATH"); + missing = true; +} +if (clds == () || clds == "") && (geniso == () || geniso == "") { + print("❌ Neither cloud-localds nor genisoimage is available. Install cloud-image-utils or genisoimage."); + missing = true; +} +if qemu == () || qemu == "" { + print("❌ qemu-img not found (required by base image builder)"); + missing = true; +} +if missing { + print("=== Aborting due to missing dependencies ==="); + exit(); +} +print("✓ Dependencies look OK"); + +// ---------- Ensure base image ---------- +print("\n--- Ensuring Ubuntu 24.04 cloud image ---"); +let base; +try { + // Resize to e.g. 10 GiB sparse (adjust as needed) + base = qcow2_build_ubuntu_24_04_base(base_dir, 10); +} catch (err) { + print(`❌ Failed to build/ensure base image: ${err}`); + exit(); +} +let disk_path = base.base_image_path; +print(`✓ Using base image: ${disk_path}`); + +// ---------- Build cloud-init NoCloud seed (user-data/meta-data) ---------- +print("\n--- Building NoCloud seed (SSH key) ---"); +run_silent(`mkdir -p ${base_dir}`); + +// Compose user-data and meta-data +let ud = `#cloud-config +users: + - name: ubuntu + groups: [adm, cdrom, dialout, lxd, plugdev, sudo] + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/bash + lock_passwd: true + ssh_authorized_keys: + - ${user_pubkey} +ssh_pwauth: false +package_update: true +`; +let md = `instance-id: ${cloudinit_instance_id} +local-hostname: ${cloudinit_hostname} +`; + +// Write files +let wr1 = run_silent(`/bin/bash -lc "cat > ${user_data} <<'EOF' +${ud} +EOF"`); +if !wr1.success { print(`❌ Failed to write ${user_data}`); exit(); } +let wr2 = run_silent(`/bin/bash -lc "cat > ${meta_data} <<'EOF' +${md} +EOF"`); +if !wr2.success { print(`❌ Failed to write ${meta_data}`); exit(); } + +// Build seed ISO (prefer cloud-localds) +let built = false; +if !(clds == () || clds == "") { + let r = run_silent(`cloud-localds ${seed_iso} ${user_data} ${meta_data}`); + if r.success { built = true; } +} +if !built { + if geniso == () || geniso == "" { + print("❌ Neither cloud-localds nor genisoimage available to build seed.iso"); + exit(); + } + let r2 = run_silent(`genisoimage -output ${seed_iso} -volid cidata -joliet -rock ${user_data} ${meta_data}`); + if !r2.success { + print("❌ genisoimage failed to create seed.iso"); + exit(); + } +} +print(`✓ Seed ISO: ${seed_iso}`); + +// ---------- Determine boot method (firmware or kernel) ---------- +print("\n--- Determining boot method ---"); +let firmware_path = ""; +if firmware_path_override != "" && exist(firmware_path_override) { + firmware_path = firmware_path_override; +} else { + let candidates = [ + "/usr/local/share/cloud-hypervisor/hypervisor-fw", + "/usr/share/cloud-hypervisor/hypervisor-fw", + "/usr/lib/cloud-hypervisor/hypervisor-fw", + "/tmp/virt_images/hypervisor-fw" + ]; + for p in candidates { + if exist(p) { firmware_path = p; break; } + } +} +let kernel_path = ""; +if kernel_path_override != "" && exist(kernel_path_override) { + kernel_path = kernel_path_override; +} +if firmware_path == "" && kernel_path == "" { + print("❌ No firmware_path or kernel_path found. Set firmware_path_override or kernel_path_override and re-run."); + exit(); +} +if firmware_path != "" { + print(`✓ Using firmware boot: ${firmware_path}`); +} else { + print(`✓ Using direct kernel boot: ${kernel_path}`); +} + +// ---------- Create and start VM ---------- +print("\n--- Creating and starting VM ---"); +let rid = run_silent("date +%s%N"); +// Make suffix robust even if date outputs nothing +let suffix = "100000"; +if rid.success { + let t = rid.stdout.trim(); + if t != "" { suffix = t; } +} +let vm_id = `noble_ssh_${suffix}`; + +let spec = #{ + "id": vm_id, + "disk_path": disk_path, + "api_socket": "", + "vcpus": 2, + "memory_mb": 2048 +}; +if firmware_path != "" { + spec.firmware_path = firmware_path; +} else { + spec.kernel_path = kernel_path; + spec.cmdline = kernel_cmdline; +} + +// Attach the NoCloud seed ISO as a read-only disk +spec.extra_args = [ + "--disk", `path=${seed_iso},readonly=true` +]; + +try { + let created = cloudhv_vm_create(spec); + print(`✓ VM created: ${created}`); +} catch (err) { + print(`❌ VM create failed: ${err}`); + exit(); +} + +try { + cloudhv_vm_start(vm_id); + print("✓ VM start invoked"); +} catch (err) { + print(`❌ VM start failed: ${err}`); + exit(); +} + +// ---------- Wait for VM API socket and probe readiness ---------- +print("\n--- Waiting for VM API socket ---"); +let api_sock = ""; +// Discover socket path (from SAL or common defaults) +let fallback_candidates = [ + `/root/hero/virt/vms/${vm_id}/api.sock`, + `/home/maxime/hero/virt/vms/${vm_id}/api.sock` +]; + +// First, try to detect the socket on disk with a longer timeout +let sock_exists = false; +for i in 0..180 { + sleep(1); + let info = cloudhv_vm_info(vm_id); + api_sock = info.spec.api_socket; + if api_sock == () || api_sock == "" { + for cand in fallback_candidates { + if exist(cand) { api_sock = cand; break; } + } + } + if api_sock != () && api_sock != "" && exist(api_sock) { + sock_exists = true; + break; + } +} + +// Regardless of filesystem existence, also try probing the API directly +let api_ok = false; +if api_sock != () && api_sock != "" { + for i in 0..60 { + let r = run_silent(`ch-remote-static --api-socket ${api_sock} info`); + if r.success { api_ok = true; break; } + sleep(1); + } +} + +if api_ok { + print("✓ VM API reachable"); +} else if sock_exists { + print("⚠️ VM API socket exists but API not reachable yet"); +} else { + print("⚠️ VM API socket not found yet; proceeding"); + let info_dbg = cloudhv_vm_info(vm_id); + let log_path = info_dbg.runtime.log_file; + if exist(log_path) { + let t = run_silent(`tail -n 120 ${log_path}`); + if t.success && t.stdout.trim() != "" { + print("\n--- Last 120 lines of console log (diagnostics) ---"); + print(t.stdout); + print("--- End of console log ---"); + } + } else { + print(`(console log not found at ${log_path})`); + } +} + +// ---------- Discover VM IP from dnsmasq leases ---------- +print("\n--- Discovering VM IP (dnsmasq leases) ---"); +// SAL enables system dnsmasq for br-hero by default; leases usually at /var/lib/misc/dnsmasq.leases +let leases_paths = [ + "/var/lib/misc/dnsmasq.leases", + "/var/lib/dnsmasq/dnsmasq.leases" +]; +let vm_ip = ""; +for path in leases_paths { + if !exist(path) { continue; } + for i in 0..120 { + sleep(1); + // Pure awk (no nested shells/pipes). Keep last IP matching hostname. + let lr = run_silent(`awk -v host="${cloudinit_hostname}" '($4 ~ host){ip=$3} END{if(ip!=\"\") print ip}' ${path}`); + if lr.success { + let ip = lr.stdout.trim(); + if ip != "" { + vm_ip = ip; + break; + } + } + } + if vm_ip != "" { break; } +} + +// ---------- Output connection details ---------- +print("\n--- VM access details ---"); +let info = cloudhv_vm_info(vm_id); +print(`VM ID: ${vm_id}`); +if info.runtime.pid != () { + print(`PID: ${info.runtime.pid}`); +} +print(`Status: ${info.runtime.status}`); +print(`API socket: ${info.spec.api_socket}`); +print(`Console log: ${info.runtime.log_file}`); +print(`Seed ISO: ${seed_iso}`); +print(`Hostname: ${cloudinit_hostname}`); + +if vm_ip != "" { + print("\nSSH command (default user 'ubuntu'):"); + print(`ssh -o StrictHostKeyChecking=no ubuntu@${vm_ip}`); +} else { + print("\n⚠️ Could not resolve VM IP yet from leases. Try later:"); + print(" - Check leases: sudo cat /var/lib/misc/dnsmasq.leases | grep noblevm"); + print(" - Or find on bridge (example): ip -4 neigh show dev br-hero"); + print(" - Then SSH: ssh -o StrictHostKeyChecking=no ubuntu@"); +} + +print("\n=== Completed: Ubuntu VM launched with SSH key via cloud-init ==="); \ No newline at end of file