cleanup 2

This commit is contained in:
Maxime Van Hees
2025-09-09 14:25:09 +02:00
parent 182b0edeb7
commit f8436a726e
5 changed files with 273 additions and 31 deletions

View File

@@ -74,6 +74,15 @@ pub struct VmRuntime {
pub status: String,
/// Console log file path
pub log_file: String,
/// Bridge name used for networking discovery (if applicable)
#[serde(default)]
pub bridge_name: Option<String>,
/// dnsmasq lease file used (if applicable)
#[serde(default)]
pub lease_file: Option<String>,
/// Stable MAC used for NIC injection (derived from VM id)
#[serde(default)]
pub mac: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -108,11 +117,88 @@ fn vm_dir(id: &str) -> PathBuf {
fn vm_json_path(id: &str) -> PathBuf {
vm_dir(id).join("vm.json")
}
// Attempt to resolve a VM record across both the current user's HOME and root HOME.
// This handles cases where the VM was created/launched under sudo (HOME=/root).
fn resolve_vm_json_path(id: &str) -> Option<PathBuf> {
let candidates = vec![
hero_vm_root(), // $HOME/hero/virt/vms
Path::new("/root/hero/virt/vms").to_path_buf(),
];
for base in candidates {
let p = base.join(id).join("vm.json");
if p.exists() {
return Some(p);
}
}
None
}
fn vm_log_path(id: &str) -> PathBuf {
vm_dir(id).join("logs/console.log")
}
/// Attempt to resolve an API socket across both the current user's HOME and root HOME.
/// This handles cases where the VM was launched under sudo (HOME=/root).
fn resolve_vm_api_socket_path(id: &str) -> Option<PathBuf> {
let candidates = vec![
hero_vm_root(), // $HOME/hero/virt/vms
Path::new("/root/hero/virt/vms").to_path_buf(),
];
for base in candidates {
let p = base.join(id).join("api.sock");
if p.exists() {
return Some(p);
}
}
None
}
/// Query cloud-hypervisor for the first NIC's tap and mac via ch-remote-static.
/// Returns (tap_name, mac_lower) if successful.
fn ch_query_tap_mac(api_sock: &Path) -> Option<(String, String)> {
let cmd = format!(
"ch-remote-static --api-socket {} info",
shell_escape(&api_sock.to_string_lossy())
);
if let Ok(res) = sal_process::run(&cmd).silent(true).die(false).execute() {
if res.success {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&res.stdout) {
if let Some(net0) = v
.get("config")
.and_then(|c| c.get("net"))
.and_then(|n| n.get(0))
{
let tap = net0.get("tap").and_then(|t| t.as_str()).unwrap_or("").to_string();
let mac = net0.get("mac").and_then(|m| m.as_str()).unwrap_or("").to_string();
if !tap.is_empty() && !mac.is_empty() {
return Some((tap, mac.to_lowercase()));
}
}
}
}
}
None
}
/// Infer the bridge name a tap device is attached to by parsing `ip -o link show <tap>` output.
fn bridge_name_for_tap(tap: &str) -> Option<String> {
let cmd = format!("ip -o link show {}", shell_escape(tap));
if let Ok(res) = sal_process::run(&cmd).silent(true).die(false).execute() {
if res.success {
for line in res.stdout.lines() {
if let Some(idx) = line.find(" master ") {
let rest = &line[idx + " master ".len()..];
let name = rest.split_whitespace().next().unwrap_or("");
if !name.is_empty() {
return Some(name.to_string());
}
}
}
}
}
None
}
fn vm_pid_path(id: &str) -> PathBuf {
vm_dir(id).join("pid")
}
@@ -180,6 +266,23 @@ pub fn vm_create(spec: &VmSpec) -> Result<String, CloudHvError> {
return Err(CloudHvError::InvalidSpec("memory_mb must be >= 128".into()));
}
// If a VM with this id already exists, ensure it's not running to avoid clobber + resource conflicts
let json_path = vm_json_path(&spec.id);
if json_path.exists() {
if let Ok(value) = read_json(&json_path) {
if let Ok(existing) = serde_json::from_value::<VmRecord>(value.clone()) {
if let Some(pid) = existing.runtime.pid {
if proc_exists(pid) {
return Err(CloudHvError::CommandFailed(format!(
"VM '{}' already exists and is running with pid {}. Stop or delete it first, or choose a different id.",
spec.id, pid
)));
}
}
}
}
}
// Prepare directory layout
let dir = vm_dir(&spec.id);
sal_os::mkdir(
@@ -190,17 +293,35 @@ pub fn vm_create(spec: &VmSpec) -> Result<String, CloudHvError> {
let log_dir = dir.join("logs");
sal_os::mkdir(log_dir.to_str().unwrap()).map_err(|e| CloudHvError::IoError(e.to_string()))?;
// Persist initial record
// Build runtime (preserve prior metadata if present; will be refreshed on start)
let mut runtime = VmRuntime {
pid: None,
status: "stopped".into(),
log_file: vm_log_path(&spec.id).to_string_lossy().into_owned(),
bridge_name: None,
lease_file: None,
mac: None,
};
if json_path.exists() {
if let Ok(value) = read_json(&json_path) {
if let Ok(existing) = serde_json::from_value::<VmRecord>(value) {
if !existing.runtime.log_file.is_empty() {
runtime.log_file = existing.runtime.log_file;
}
runtime.bridge_name = existing.runtime.bridge_name;
runtime.lease_file = existing.runtime.lease_file;
runtime.mac = existing.runtime.mac;
}
}
}
// Persist record (spec updated, runtime preserved/reset to stopped)
let rec = VmRecord {
spec: spec.clone(),
runtime: VmRuntime {
pid: None,
status: "stopped".into(),
log_file: vm_log_path(&spec.id).to_string_lossy().into_owned(),
},
runtime,
};
let value = serde_json::to_value(&rec).map_err(|e| CloudHvError::JsonError(e.to_string()))?;
write_json(&vm_json_path(&spec.id), &value)?;
write_json(&json_path, &value)?;
Ok(spec.id.clone())
}
@@ -563,6 +684,9 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> {
rec.runtime.pid = pid;
rec.runtime.status = if pid.is_some() { "running".into() } else { "stopped".into() };
rec.runtime.log_file = log_file;
rec.runtime.bridge_name = bridge_for_disc.clone();
rec.runtime.lease_file = lease_for_disc.clone();
rec.runtime.mac = Some(net::stable_mac_from_id(id));
rec.spec.api_socket = api_socket.clone();
let value = serde_json::to_value(&rec).map_err(|e| CloudHvError::JsonError(e.to_string()))?;
@@ -584,17 +708,89 @@ pub fn vm_start(id: &str) -> Result<(), CloudHvError> {
Ok(())
}
/// Return VM record info (spec + runtime) by id
//// Return VM record info (spec + runtime) by id
pub fn vm_info(id: &str) -> Result<VmRecord, CloudHvError> {
let p = vm_json_path(id);
if !p.exists() {
// Try current user's VM root first, then fall back to /root (common when VM was launched under sudo)
let p_user = vm_json_path(id);
let p = if p_user.exists() {
p_user
} else if let Some(p2) = resolve_vm_json_path(id) {
p2
} else {
return Err(CloudHvError::NotFound(format!("VM '{}' not found", id)));
}
};
let value = read_json(&p)?;
let rec: VmRecord = serde_json::from_value(value).map_err(|e| CloudHvError::JsonError(e.to_string()))?;
Ok(rec)
}
//// Discover VM network info using persisted metadata (bridge/lease/mac) with sensible fallbacks.
/// Returns (IPv4, IPv6, MAC, BridgeName, LeaseFile), each optional.
pub fn vm_network_info(
id: &str,
timeout_secs: u64,
) -> Result<(Option<String>, Option<String>, Option<String>, Option<String>, Option<String>), CloudHvError> {
let rec = vm_info(id)?;
// Start with persisted/env/default values
let mut bridge_name = rec
.runtime
.bridge_name
.clone()
.or_else(|| std::env::var("HERO_VIRT_BRIDGE_NAME").ok())
.unwrap_or_else(|| "br-hero".into());
// MAC: persisted or deterministically derived (lowercased for matching)
let mut mac_lower = rec
.runtime
.mac
.clone()
.unwrap_or_else(|| net::stable_mac_from_id(id))
.to_lowercase();
// Attempt to query CH for ground-truth (tap, mac) if API socket is available
if let Some(api_sock) = resolve_vm_api_socket_path(id) {
if let Some((tap, mac_from_ch)) = ch_query_tap_mac(&api_sock) {
mac_lower = mac_from_ch;
if let Some(br) = bridge_name_for_tap(&tap) {
bridge_name = br;
}
}
}
// Lease file: persisted -> env -> derived from (possibly overridden) bridge
let lease_path = rec
.runtime
.lease_file
.clone()
.or_else(|| std::env::var("HERO_VIRT_DHCP_LEASE_FILE").ok())
.unwrap_or_else(|| format!("/var/lib/misc/dnsmasq-hero-{}.leases", bridge_name));
// Discover addresses
let ipv4 = net::discover_ipv4_from_leases(&lease_path, &mac_lower, timeout_secs);
let ipv6 = {
use std::time::{Duration, Instant};
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
let mut v6: Option<String> = None;
while Instant::now() < deadline {
if let Some(ip) = net::discover_ipv6_on_bridge(&bridge_name, &mac_lower) {
v6 = Some(ip);
break;
}
std::thread::sleep(Duration::from_millis(800));
}
v6
};
Ok((
ipv4,
ipv6,
Some(mac_lower),
Some(bridge_name),
Some(lease_path),
))
}
/// Stop a VM via ch-remote (graceful), optionally force kill
pub fn vm_stop(id: &str, force: bool) -> Result<(), CloudHvError> {
ensure_deps().ok(); // best-effort; we might still force-kill

View File

@@ -189,8 +189,7 @@ if [ -n \"$IPV6_CIDR\" ]; then
printf '%s\\n' \
\"enable-ra\" \
\"dhcp-range=$BRIDGE_PREFIX,ra-names,12h\" \
\"dhcp-option=option6:dns-server,[2001:4860:4860::8888]\" \
\"dhcp-option=option6:route,3600,400::/7,[$BRIDGE_ADDR]\" >>\"$TMP\"
\"dhcp-option=option6:dns-server,[2001:4860:4860::8888]\" >>\"$TMP\"
fi
if [ ! -f \"$CFG\" ] || ! cmp -s \"$CFG\" \"$TMP\"; then
@@ -246,7 +245,15 @@ TAP={tap}
UIDX=$(id -u)
GIDX=$(id -g)
ip link show \"$TAP\" >/dev/null 2>&1 || ip tuntap add dev \"$TAP\" mode tap user \"$UIDX\" group \"$GIDX\"
# Ensure a clean TAP state to avoid Resource busy if a previous VM run left it lingering
if ip link show \"$TAP\" >/dev/null 2>&1; then
ip link set \"$TAP\" down || true
ip link set \"$TAP\" nomaster 2>/dev/null || true
ip tuntap del dev \"$TAP\" mode tap 2>/dev/null || true
fi
# Recreate with correct ownership and attach to bridge
ip tuntap add dev \"$TAP\" mode tap user \"$UIDX\" group \"$GIDX\"
ip link set \"$TAP\" master \"$BR\" 2>/dev/null || true
ip link set \"$TAP\" up
",
@@ -258,13 +265,18 @@ ip link set \"$TAP\" up
}
/// Stable locally-administered unicast MAC derived from VM id.
/// IMPORTANT: Use a deterministic hash (FNV-1a) rather than DefaultHasher (which is randomized).
pub fn stable_mac_from_id(id: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
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
// 64-bit FNV-1a
const FNV_OFFSET: u64 = 0xcbf29ce484222325;
const FNV_PRIME: u64 = 0x00000100000001B3;
let mut v: u64 = FNV_OFFSET;
for b in id.as_bytes() {
v ^= *b as u64;
v = v.wrapping_mul(FNV_PRIME);
}
// Locally administered, unicast
let b0 = (((v >> 40) & 0xff) as u8 & 0xfe) | 0x02;
let b1 = ((v >> 32) & 0xff) as u8;
let b2 = ((v >> 24) & 0xff) as u8;
let b3 = ((v >> 16) & 0xff) as u8;

View File

@@ -83,19 +83,21 @@ fn default_disable_cloud_init_net() -> bool {
}
fn stable_mac_from_id(id: &str) -> String {
let mut h = DefaultHasher::new();
id.hash(&mut h);
let v = h.finish();
// Use deterministic FNV-1a (matches host-side MAC derivation used by CH builder)
const FNV_OFFSET: u64 = 0xcbf29ce484222325;
const FNV_PRIME: u64 = 0x00000100000001B3;
let mut v: u64 = FNV_OFFSET;
for b in id.as_bytes() {
v ^= *b as u64;
v = v.wrapping_mul(FNV_PRIME);
}
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
)
format!("{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", b0, b1, b2, b3, b4, b5)
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -230,6 +230,21 @@ pub fn cloudhv_display_network_info(vm_id: &str, ipv4: Dynamic, ipv6: Dynamic) {
println!(" cloudhv_vm_delete(\"{}\", true);", vm_id);
}
/// High-level network discovery that avoids hardcoded MAC/paths.
/// Returns a Rhai map with fields: ipv4, ipv6, mac, bridge, lease.
pub fn cloudhv_vm_network_info(id: &str, timeout_secs: i64) -> Result<Map, Box<EvalAltResult>> {
let (ipv4, ipv6, mac, bridge, lease) =
hv_to_rhai(cloudhv::vm_network_info(id, timeout_secs as u64))?;
let mut m = Map::new();
m.insert("vm_id".into(), id.to_string().into());
m.insert("ipv4".into(), ipv4.map(Into::into).unwrap_or(Dynamic::UNIT));
m.insert("ipv6".into(), ipv6.map(Into::into).unwrap_or(Dynamic::UNIT));
m.insert("mac".into(), mac.map(Into::into).unwrap_or(Dynamic::UNIT));
m.insert("bridge".into(), bridge.map(Into::into).unwrap_or(Dynamic::UNIT));
m.insert("lease".into(), lease.map(Into::into).unwrap_or(Dynamic::UNIT));
Ok(m)
}
// Module registration
pub fn register_cloudhv_module(engine: &mut Engine) -> Result<(), Box<EvalAltResult>> {
@@ -239,6 +254,7 @@ pub fn register_cloudhv_module(engine: &mut Engine) -> Result<(), Box<EvalAltRes
engine.register_fn("cloudhv_vm_delete", cloudhv_vm_delete);
engine.register_fn("cloudhv_vm_list", cloudhv_vm_list);
engine.register_fn("cloudhv_vm_info", cloudhv_vm_info);
engine.register_fn("cloudhv_vm_network_info", cloudhv_vm_network_info);
engine.register_fn("cloudhv_discover_ipv4_from_leases", cloudhv_discover_ipv4_from_leases);
engine.register_fn("cloudhv_discover_ipv6_on_bridge", cloudhv_discover_ipv6_on_bridge);
engine.register_fn("cloudhv_display_network_info", cloudhv_display_network_info);

View File

@@ -3,6 +3,21 @@
let vm_id = "vm-clean-test";
// Phase 0: Pre-clean any existing VM with the same id (best-effort)
// This avoids TAP "Resource busy" when a previous run is still active.
try {
cloudhv_vm_stop(vm_id, true);
} catch (e) {
// ignore
}
// brief wait to let processes exit and TAP release
wait_for_vm_boot(1);
try {
cloudhv_vm_delete(vm_id, true);
} catch (e) {
// ignore
}
// Phase 1: Host check
let hc = host_check();
if !(hc.ok == true) {
@@ -25,10 +40,11 @@ try {
// Phase 3: Wait for VM to boot and get network configuration
wait_for_vm_boot(10);
// Phase 4: Discover VM IP addresses
let mac_addr = "a2:26:1e:ac:96:3a"; // This should be derived from vm_id_actual
let ipv4 = cloudhv_discover_ipv4_from_leases("/var/lib/misc/dnsmasq-hero-br-hero.leases", mac_addr, 30);
let ipv6 = cloudhv_discover_ipv6_on_bridge("br-hero", mac_addr);
// Phase 4: Discover VM IP addresses (robust, no hardcoded MAC/paths)
let net = cloudhv_vm_network_info(vm_id_actual, 30);
let ipv4 = net["ipv4"]; // Dynamic UNIT if not found yet
let ipv6 = net["ipv6"]; // Dynamic UNIT if not found
// Optional: you could also inspect net["mac"], net["bridge"], net["lease"]
// Phase 5: Display connection info
cloudhv_display_network_info(vm_id_actual, ipv4, ipv6);