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