networking VMs (WIP)

This commit is contained in:
Maxime Van Hees
2025-08-21 18:57:20 +02:00
parent 768e3e176d
commit af89ef0149
4 changed files with 940 additions and 0 deletions

View File

@@ -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();

View 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 ===");

View 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 ===");

View 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 ===");