networking VMs (WIP)
This commit is contained in:
		| @@ -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<Vec<VmRecord>, 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<String, CloudHvError> { | ||||
|     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\" <<EOF | ||||
| interface=$BR | ||||
| bind-interfaces | ||||
| dhcp-range=$DHCP_START,$DHCP_END,12h | ||||
| dhcp-option=option:dns-server,1.1.1.1,8.8.8.8 | ||||
| EOF | ||||
|  | ||||
| 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 | ||||
| ", | ||||
|         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), | ||||
|     ); | ||||
|  | ||||
|     match sal_process::run(&script).silent(true).execute() { | ||||
|         Ok(res) if res.success => 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>) -> String { | ||||
|     let mut s = String::new(); | ||||
|   | ||||
							
								
								
									
										148
									
								
								packages/system/virt/tests/rhai/05_cloudhv_diag.rhai
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								packages/system/virt/tests/rhai/05_cloudhv_diag.rhai
									
									
									
									
									
										Normal file
									
								
							| @@ -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 ==="); | ||||
							
								
								
									
										309
									
								
								packages/system/virt/tests/rhai/06_cloudhv_cloudinit_dhcpd.rhai
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										309
									
								
								packages/system/virt/tests/rhai/06_cloudhv_cloudinit_dhcpd.rhai
									
									
									
									
									
										Normal file
									
								
							| @@ -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 <IP> after you see a lease):"); | ||||
|     print(`ssh -o StrictHostKeyChecking=no ubuntu@<IP>`); | ||||
| } | ||||
|  | ||||
| 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 ==="); | ||||
							
								
								
									
										311
									
								
								packages/system/virt/tests/rhai/07_cloudhv_ubuntu_ssh.rhai
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										311
									
								
								packages/system/virt/tests/rhai/07_cloudhv_ubuntu_ssh.rhai
									
									
									
									
									
										Normal file
									
								
							| @@ -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@<IP>"); | ||||
| } | ||||
|  | ||||
| print("\n=== Completed: Ubuntu VM launched with SSH key via cloud-init ==="); | ||||
		Reference in New Issue
	
	Block a user