diff --git a/crates/vm-manager/src/backends/mod.rs b/crates/vm-manager/src/backends/mod.rs index ce8b5d2..0a6f186 100644 --- a/crates/vm-manager/src/backends/mod.rs +++ b/crates/vm-manager/src/backends/mod.rs @@ -93,7 +93,7 @@ impl Hypervisor for RouterHypervisor { self.noop.prepare(spec).await } - async fn start(&self, vm: &VmHandle) -> Result<()> { + async fn start(&self, vm: &VmHandle) -> Result { match vm.backend { #[cfg(target_os = "linux")] BackendTag::Qemu => match self.qemu { @@ -117,7 +117,7 @@ impl Hypervisor for RouterHypervisor { } } - async fn stop(&self, vm: &VmHandle, timeout: Duration) -> Result<()> { + async fn stop(&self, vm: &VmHandle, timeout: Duration) -> Result { match vm.backend { #[cfg(target_os = "linux")] BackendTag::Qemu => match self.qemu { @@ -141,7 +141,7 @@ impl Hypervisor for RouterHypervisor { } } - async fn suspend(&self, vm: &VmHandle) -> Result<()> { + async fn suspend(&self, vm: &VmHandle) -> Result { match vm.backend { #[cfg(target_os = "linux")] BackendTag::Qemu => match self.qemu { @@ -165,7 +165,7 @@ impl Hypervisor for RouterHypervisor { } } - async fn resume(&self, vm: &VmHandle) -> Result<()> { + async fn resume(&self, vm: &VmHandle) -> Result { match vm.backend { #[cfg(target_os = "linux")] BackendTag::Qemu => match self.qemu { diff --git a/crates/vm-manager/src/backends/noop.rs b/crates/vm-manager/src/backends/noop.rs index 8129cd3..07b4792 100644 --- a/crates/vm-manager/src/backends/noop.rs +++ b/crates/vm-manager/src/backends/noop.rs @@ -27,27 +27,33 @@ impl Hypervisor for NoopBackend { qmp_socket: None, console_socket: None, vnc_addr: None, + vcpus: spec.vcpus, + memory_mb: spec.memory_mb, + disk_gb: spec.disk_gb, + network: spec.network.clone(), + ssh_host_port: None, + mac_addr: None, }) } - async fn start(&self, vm: &VmHandle) -> Result<()> { + async fn start(&self, vm: &VmHandle) -> Result { info!(id = %vm.id, name = %vm.name, "noop: start"); - Ok(()) + Ok(vm.clone()) } - async fn stop(&self, vm: &VmHandle, _timeout: Duration) -> Result<()> { + async fn stop(&self, vm: &VmHandle, _timeout: Duration) -> Result { info!(id = %vm.id, name = %vm.name, "noop: stop"); - Ok(()) + Ok(vm.clone()) } - async fn suspend(&self, vm: &VmHandle) -> Result<()> { + async fn suspend(&self, vm: &VmHandle) -> Result { info!(id = %vm.id, name = %vm.name, "noop: suspend"); - Ok(()) + Ok(vm.clone()) } - async fn resume(&self, vm: &VmHandle) -> Result<()> { + async fn resume(&self, vm: &VmHandle) -> Result { info!(id = %vm.id, name = %vm.name, "noop: resume"); - Ok(()) + Ok(vm.clone()) } async fn destroy(&self, vm: VmHandle) -> Result<()> { @@ -98,11 +104,11 @@ mod tests { assert_eq!(handle.backend, BackendTag::Noop); assert!(handle.id.starts_with("noop-")); - backend.start(&handle).await.unwrap(); + let handle = backend.start(&handle).await.unwrap(); assert_eq!(backend.state(&handle).await.unwrap(), VmState::Prepared); - backend.suspend(&handle).await.unwrap(); - backend.resume(&handle).await.unwrap(); + let handle = backend.suspend(&handle).await.unwrap(); + let handle = backend.resume(&handle).await.unwrap(); let ip = backend.guest_ip(&handle).await.unwrap(); assert_eq!(ip, "127.0.0.1"); @@ -110,7 +116,78 @@ mod tests { let endpoint = backend.console_endpoint(&handle).unwrap(); assert!(matches!(endpoint, ConsoleEndpoint::None)); - backend.stop(&handle, Duration::from_secs(5)).await.unwrap(); + let handle = backend.stop(&handle, Duration::from_secs(5)).await.unwrap(); backend.destroy(handle).await.unwrap(); } + + #[test] + fn network_config_roundtrip() { + let configs = vec![ + NetworkConfig::User, + NetworkConfig::Tap { + bridge: "br0".into(), + }, + NetworkConfig::Vnic { + name: "vnic0".into(), + }, + NetworkConfig::None, + ]; + for cfg in configs { + let json = serde_json::to_string(&cfg).unwrap(); + let parsed: NetworkConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(format!("{:?}", cfg), format!("{:?}", parsed)); + } + } + + #[test] + fn vmhandle_roundtrip() { + let handle = VmHandle { + id: "test-123".into(), + name: "my-vm".into(), + backend: BackendTag::Noop, + work_dir: "/tmp/test".into(), + overlay_path: None, + seed_iso_path: None, + pid: Some(1234), + qmp_socket: None, + console_socket: None, + vnc_addr: Some("127.0.0.1:5900".into()), + vcpus: 4, + memory_mb: 2048, + disk_gb: Some(20), + network: NetworkConfig::User, + ssh_host_port: Some(10022), + mac_addr: Some("52:54:00:ab:cd:ef".into()), + }; + let json = serde_json::to_string_pretty(&handle).unwrap(); + let parsed: VmHandle = serde_json::from_str(&json).unwrap(); + assert_eq!(handle.id, parsed.id); + assert_eq!(handle.vcpus, parsed.vcpus); + assert_eq!(handle.memory_mb, parsed.memory_mb); + assert_eq!(handle.ssh_host_port, parsed.ssh_host_port); + assert_eq!(handle.mac_addr, parsed.mac_addr); + } + + #[test] + fn vmhandle_backward_compat() { + // Simulate a JSON from before the new fields were added + let old_json = r#"{ + "id": "old-123", + "name": "old-vm", + "backend": "noop", + "work_dir": "/tmp/old", + "overlay_path": null, + "seed_iso_path": null, + "pid": null, + "qmp_socket": null, + "console_socket": null, + "vnc_addr": null + }"#; + let handle: VmHandle = serde_json::from_str(old_json).unwrap(); + assert_eq!(handle.vcpus, 1); + assert_eq!(handle.memory_mb, 1024); + assert_eq!(handle.disk_gb, None); + assert!(handle.ssh_host_port.is_none()); + assert!(handle.mac_addr.is_none()); + } } diff --git a/crates/vm-manager/src/backends/propolis.rs b/crates/vm-manager/src/backends/propolis.rs index 24f4792..aa85300 100644 --- a/crates/vm-manager/src/backends/propolis.rs +++ b/crates/vm-manager/src/backends/propolis.rs @@ -133,13 +133,19 @@ impl Hypervisor for PropolisBackend { qmp_socket: None, console_socket: None, vnc_addr: None, + vcpus: spec.vcpus, + memory_mb: spec.memory_mb, + disk_gb: spec.disk_gb, + network: spec.network.clone(), + ssh_host_port: None, + mac_addr: None, }; info!(name = %spec.name, id = %handle.id, "Propolis: prepared"); Ok(handle) } - async fn start(&self, vm: &VmHandle) -> Result<()> { + async fn start(&self, vm: &VmHandle) -> Result { // Boot zone let (ok, _, stderr) = Self::run_cmd("zoneadm", &["-z", &vm.name, "boot"]).await?; if !ok { @@ -190,10 +196,10 @@ impl Hypervisor for PropolisBackend { })?; info!(name = %vm.name, "Propolis: started"); - Ok(()) + Ok(vm.clone()) } - async fn stop(&self, vm: &VmHandle, _timeout: Duration) -> Result<()> { + async fn stop(&self, vm: &VmHandle, _timeout: Duration) -> Result { let propolis_addr = "127.0.0.1:12400"; let client = reqwest::Client::new(); @@ -208,17 +214,17 @@ impl Hypervisor for PropolisBackend { let _ = Self::run_cmd("zoneadm", &["-z", &vm.name, "halt"]).await; info!(name = %vm.name, "Propolis: stopped"); - Ok(()) + Ok(vm.clone()) } - async fn suspend(&self, vm: &VmHandle) -> Result<()> { + async fn suspend(&self, vm: &VmHandle) -> Result { info!(name = %vm.name, "Propolis: suspend (not yet implemented)"); - Ok(()) + Ok(vm.clone()) } - async fn resume(&self, vm: &VmHandle) -> Result<()> { + async fn resume(&self, vm: &VmHandle) -> Result { info!(name = %vm.name, "Propolis: resume (not yet implemented)"); - Ok(()) + Ok(vm.clone()) } async fn destroy(&self, vm: VmHandle) -> Result<()> { diff --git a/crates/vm-manager/src/backends/qemu.rs b/crates/vm-manager/src/backends/qemu.rs index 3f7767e..8f3b288 100644 --- a/crates/vm-manager/src/backends/qemu.rs +++ b/crates/vm-manager/src/backends/qemu.rs @@ -7,7 +7,7 @@ use crate::cloudinit; use crate::error::{Result, VmError}; use crate::image; use crate::traits::{ConsoleEndpoint, Hypervisor}; -use crate::types::{BackendTag, VmHandle, VmSpec, VmState}; +use crate::types::{BackendTag, NetworkConfig, VmHandle, VmSpec, VmState}; use super::qmp::QmpClient; @@ -66,6 +66,16 @@ impl QemuBackend { // Signal 0 checks if process exists without sending a signal unsafe { libc::kill(pid as i32, 0) == 0 } } + + /// Derive a deterministic SSH host port from the VM name (range 10022..10122). + fn ssh_port_for_name(name: &str) -> u16 { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + name.hash(&mut hasher); + let h = hasher.finish(); + 10022 + (h % 100) as u16 + } } /// Generate a locally-administered unicast MAC address using random bytes. @@ -117,6 +127,14 @@ impl Hypervisor for QemuBackend { let qmp_socket = work_dir.join("qmp.sock"); let console_socket = work_dir.join("console.sock"); + let mac_addr = Self::generate_mac(); + + // For user-mode networking, allocate an SSH host port based on the VM name + let ssh_host_port = match &spec.network { + NetworkConfig::User => Some(Self::ssh_port_for_name(&spec.name)), + _ => None, + }; + let handle = VmHandle { id: format!("qemu-{}", uuid::Uuid::new_v4()), name: spec.name.clone(), @@ -128,11 +146,19 @@ impl Hypervisor for QemuBackend { qmp_socket: Some(qmp_socket), console_socket: Some(console_socket), vnc_addr: None, + vcpus: spec.vcpus, + memory_mb: spec.memory_mb, + disk_gb: spec.disk_gb, + network: spec.network.clone(), + ssh_host_port, + mac_addr: Some(mac_addr), }; info!( name = %spec.name, id = %handle.id, + vcpus = handle.vcpus, + memory_mb = handle.memory_mb, overlay = ?handle.overlay_path, seed = ?handle.seed_iso_path, "QEMU: prepared" @@ -141,7 +167,7 @@ impl Hypervisor for QemuBackend { Ok(handle) } - async fn start(&self, vm: &VmHandle) -> Result<()> { + async fn start(&self, vm: &VmHandle) -> Result { let overlay = vm .overlay_path .as_ref() @@ -150,12 +176,29 @@ impl Hypervisor for QemuBackend { state: "no overlay path".into(), })?; - // Read the VmSpec vcpus/memory from the overlay's qemu-img info? No — we need - // to reconstruct from VmHandle. For now, use defaults if not stored. - // The CLI will re-read spec and pass to prepare+start in sequence. + let qmp_sock = vm + .qmp_socket + .as_ref() + .ok_or_else(|| VmError::InvalidState { + name: vm.name.clone(), + state: "no QMP socket path".into(), + })?; + let console_sock = vm + .console_socket + .as_ref() + .ok_or_else(|| VmError::InvalidState { + name: vm.name.clone(), + state: "no console socket path".into(), + })?; - let qmp_sock = vm.qmp_socket.as_ref().unwrap(); - let console_sock = vm.console_socket.as_ref().unwrap(); + // Clean up stale socket files from a previous run + for sock in [qmp_sock, console_sock] { + if sock.exists() { + let _ = tokio::fs::remove_file(sock).await; + } + } + + let mac = vm.mac_addr.as_deref().unwrap_or("52:54:00:00:00:01"); let mut args: Vec = vec![ "-enable-kvm".into(), @@ -164,6 +207,12 @@ impl Hypervisor for QemuBackend { "-cpu".into(), "host".into(), "-nodefaults".into(), + // vCPUs + "-smp".into(), + vm.vcpus.to_string(), + // Memory + "-m".into(), + format!("{}M", vm.memory_mb), // QMP socket "-qmp".into(), format!("unix:{},server,nowait", qmp_sock.display()), @@ -186,6 +235,30 @@ impl Hypervisor for QemuBackend { "virtio-blk-pci,drive=drive0".into(), ]; + // Networking + match &vm.network { + NetworkConfig::Tap { bridge } => { + args.extend([ + "-netdev".into(), + format!("tap,id=net0,br={bridge},script=no,downscript=no"), + "-device".into(), + format!("virtio-net-pci,netdev=net0,mac={mac}"), + ]); + } + NetworkConfig::User => { + let port = vm.ssh_host_port.unwrap_or(10022); + args.extend([ + "-netdev".into(), + format!("user,id=net0,hostfwd=tcp::{port}-:22"), + "-device".into(), + format!("virtio-net-pci,netdev=net0,mac={mac}"), + ]); + } + NetworkConfig::Vnic { .. } | NetworkConfig::None => { + // No network args for Vnic (illumos only) or None + } + } + // Seed ISO (cloud-init) if let Some(ref iso) = vm.seed_iso_path { args.extend([ @@ -208,6 +281,8 @@ impl Hypervisor for QemuBackend { info!( name = %vm.name, + vcpus = vm.vcpus, + memory_mb = vm.memory_mb, binary = %self.qemu_binary.display(), "QEMU: starting" ); @@ -225,15 +300,30 @@ impl Hypervisor for QemuBackend { }); } - // Wait for QMP socket and verify connection - let mut qmp = QmpClient::connect(qmp_sock, Duration::from_secs(10)).await?; - let status = qmp.query_status().await?; - info!(name = %vm.name, status = %status, "QEMU: started"); + // Read PID from pidfile + let pid = Self::read_pid(&vm.work_dir).await; - Ok(()) + // Wait for QMP socket and verify + query VNC + let mut qmp = QmpClient::connect(qmp_sock, Duration::from_secs(10)).await?; + let qmp_status = qmp.query_status().await?; + let vnc_addr = qmp.query_vnc().await.unwrap_or(None); + + info!( + name = %vm.name, + status = %qmp_status, + pid = ?pid, + vnc = ?vnc_addr, + "QEMU: started" + ); + + let mut updated = vm.clone(); + updated.pid = pid; + updated.vnc_addr = vnc_addr; + + Ok(updated) } - async fn stop(&self, vm: &VmHandle, timeout: Duration) -> Result<()> { + async fn stop(&self, vm: &VmHandle, timeout: Duration) -> Result { // Try ACPI shutdown via QMP first if let Some(ref qmp_sock) = vm.qmp_socket { if qmp_sock.exists() { @@ -249,11 +339,17 @@ impl Hypervisor for QemuBackend { if let Some(pid) = Self::read_pid(&vm.work_dir).await { if !Self::pid_alive(pid) { info!(name = %vm.name, "QEMU: process exited after ACPI shutdown"); - return Ok(()); + let mut updated = vm.clone(); + updated.pid = None; + updated.vnc_addr = None; + return Ok(updated); } } else { // No PID file, process likely already gone - return Ok(()); + let mut updated = vm.clone(); + updated.pid = None; + updated.vnc_addr = None; + return Ok(updated); } if start.elapsed() >= timeout { @@ -281,23 +377,26 @@ impl Hypervisor for QemuBackend { } } - Ok(()) + let mut updated = vm.clone(); + updated.pid = None; + updated.vnc_addr = None; + Ok(updated) } - async fn suspend(&self, vm: &VmHandle) -> Result<()> { + async fn suspend(&self, vm: &VmHandle) -> Result { if let Some(ref qmp_sock) = vm.qmp_socket { let mut qmp = QmpClient::connect(qmp_sock, Duration::from_secs(5)).await?; qmp.stop().await?; } - Ok(()) + Ok(vm.clone()) } - async fn resume(&self, vm: &VmHandle) -> Result<()> { + async fn resume(&self, vm: &VmHandle) -> Result { if let Some(ref qmp_sock) = vm.qmp_socket { let mut qmp = QmpClient::connect(qmp_sock, Duration::from_secs(5)).await?; qmp.cont().await?; } - Ok(()) + Ok(vm.clone()) } async fn destroy(&self, vm: VmHandle) -> Result<()> { @@ -349,7 +448,18 @@ impl Hypervisor for QemuBackend { } async fn guest_ip(&self, vm: &VmHandle) -> Result { - // Parse ARP table (`ip neigh`) looking for IPs on the bridge + // For user-mode networking, the guest is reachable via localhost + // (SSH uses the forwarded host port) + if matches!(vm.network, NetworkConfig::User) { + return Ok("127.0.0.1".to_string()); + } + + // For TAP networking: parse ARP table (`ip neigh`) looking for IPs on the bridge + let bridge_filter = match &vm.network { + NetworkConfig::Tap { bridge } => Some(bridge.as_str()), + _ => self.default_bridge.as_deref(), + }; + let output = tokio::process::Command::new("ip") .args(["neigh", "show"]) .output() @@ -360,10 +470,14 @@ impl Hypervisor for QemuBackend { let text = String::from_utf8_lossy(&output.stdout); - // Try to find an IP from the ARP table. This is a best-effort heuristic: - // look for REACHABLE or STALE entries on common bridge interfaces. for line in text.lines() { if line.contains("REACHABLE") || line.contains("STALE") { + // If we have a bridge filter, only match entries on that interface + if let Some(br) = bridge_filter { + if !line.contains(br) { + continue; + } + } if let Some(ip) = line.split_whitespace().next() { // Basic IPv4 check if ip.contains('.') && !ip.starts_with("127.") { @@ -374,10 +488,22 @@ impl Hypervisor for QemuBackend { } // Fallback: check dnsmasq leases if available - if self.default_bridge.is_some() { + if bridge_filter.is_some() { let leases_path = "/var/lib/misc/dnsmasq.leases"; if let Ok(content) = tokio::fs::read_to_string(leases_path).await { // Lease format: epoch MAC IP hostname clientid + // Try to match by MAC address if we know it + if let Some(ref mac) = vm.mac_addr { + for line in content.lines() { + if line.contains(mac) { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 3 { + return Ok(parts[2].to_string()); + } + } + } + } + // Fallback to last lease if let Some(line) = content.lines().last() { let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() >= 3 { diff --git a/crates/vm-manager/src/backends/qmp.rs b/crates/vm-manager/src/backends/qmp.rs index 990253f..8d7e69b 100644 --- a/crates/vm-manager/src/backends/qmp.rs +++ b/crates/vm-manager/src/backends/qmp.rs @@ -75,11 +75,13 @@ impl QmpClient { async fn send_command(&mut self, execute: &str, arguments: Option) -> Result<()> { let mut cmd = serde_json::json!({ "execute": execute }); if let Some(args) = arguments { - cmd.as_object_mut() - .unwrap() - .insert("arguments".into(), args); + if let Some(obj) = cmd.as_object_mut() { + obj.insert("arguments".into(), args); + } } - let mut line = serde_json::to_string(&cmd).unwrap(); + let mut line = serde_json::to_string(&cmd).map_err(|e| VmError::QmpCommandFailed { + message: format!("JSON serialize failed: {e}"), + })?; line.push('\n'); trace!(cmd = %line.trim(), "QMP send"); self.writer @@ -197,4 +199,29 @@ impl QmpClient { .to_string(); Ok(status) } + + /// Query the VNC server address. Returns `"host:port"` if VNC is active. + pub async fn query_vnc(&mut self) -> Result> { + let resp = self.execute("query-vnc", None).await?; + if resp.get("error").is_some() { + return Ok(None); + } + let ret = match resp.get("return") { + Some(r) => r, + None => return Ok(None), + }; + let enabled = ret + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !enabled { + return Ok(None); + } + let host = ret + .get("host") + .and_then(|v| v.as_str()) + .unwrap_or("127.0.0.1"); + let service = ret.get("service").and_then(|v| v.as_str()).unwrap_or("0"); + Ok(Some(format!("{host}:{service}"))) + } } diff --git a/crates/vm-manager/src/ssh.rs b/crates/vm-manager/src/ssh.rs index 3d0f801..03be690 100644 --- a/crates/vm-manager/src/ssh.rs +++ b/crates/vm-manager/src/ssh.rs @@ -9,11 +9,11 @@ use tracing::warn; use crate::error::{Result, VmError}; use crate::types::SshConfig; -/// Establish an SSH session to the given IP using the provided config. +/// Establish an SSH session to the given IP and port using the provided config. /// /// Tries in-memory key first, then key file path. -pub fn connect(ip: &str, config: &SshConfig) -> Result { - let addr = format!("{ip}:22"); +pub fn connect(ip: &str, port: u16, config: &SshConfig) -> Result { + let addr = format!("{ip}:{port}"); let tcp = TcpStream::connect(&addr).map_err(|e| VmError::SshFailed { detail: format!("TCP connect to {addr}: {e}"), })?; @@ -124,6 +124,7 @@ pub fn upload(sess: &Session, local: &Path, remote: &Path) -> Result<()> { /// Retries the connection until `timeout` elapses, with exponential backoff capped at 5 seconds. pub async fn connect_with_retry( ip: &str, + port: u16, config: &SshConfig, timeout: Duration, ) -> Result { @@ -137,7 +138,8 @@ pub async fn connect_with_retry( let config_clone = config.clone(); // Run the blocking SSH connect on a blocking thread - let result = tokio::task::spawn_blocking(move || connect(&ip_owned, &config_clone)).await; + let result = + tokio::task::spawn_blocking(move || connect(&ip_owned, port, &config_clone)).await; match result { Ok(Ok(sess)) => return Ok(sess), diff --git a/crates/vm-manager/src/traits.rs b/crates/vm-manager/src/traits.rs index 03ad7aa..39247a0 100644 --- a/crates/vm-manager/src/traits.rs +++ b/crates/vm-manager/src/traits.rs @@ -10,17 +10,22 @@ pub trait Hypervisor: Send + Sync { /// Allocate resources (overlay disk, cloud-init ISO, zone config, etc.) and return a handle. fn prepare(&self, spec: &VmSpec) -> impl Future> + Send; - /// Boot the VM. - fn start(&self, vm: &VmHandle) -> impl Future> + Send; + /// Boot the VM. Returns the updated handle with PID, VNC addr, etc. + fn start(&self, vm: &VmHandle) -> impl Future> + Send; /// Gracefully stop the VM. Falls back to forceful termination after `timeout`. - fn stop(&self, vm: &VmHandle, timeout: Duration) -> impl Future> + Send; + /// Returns the updated handle with cleared runtime fields. + fn stop( + &self, + vm: &VmHandle, + timeout: Duration, + ) -> impl Future> + Send; - /// Pause VM execution (freeze vCPUs). - fn suspend(&self, vm: &VmHandle) -> impl Future> + Send; + /// Pause VM execution (freeze vCPUs). Returns the updated handle. + fn suspend(&self, vm: &VmHandle) -> impl Future> + Send; - /// Resume a suspended VM. - fn resume(&self, vm: &VmHandle) -> impl Future> + Send; + /// Resume a suspended VM. Returns the updated handle. + fn resume(&self, vm: &VmHandle) -> impl Future> + Send; /// Stop the VM (if running) and clean up all resources. fn destroy(&self, vm: VmHandle) -> impl Future> + Send; diff --git a/crates/vm-manager/src/types.rs b/crates/vm-manager/src/types.rs index 6ccf482..7cac8b5 100644 --- a/crates/vm-manager/src/types.rs +++ b/crates/vm-manager/src/types.rs @@ -34,7 +34,8 @@ pub struct VmSpec { } /// Network configuration for a VM. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] pub enum NetworkConfig { /// TAP device bridged to a host bridge (default on Linux). Tap { bridge: String }, @@ -94,6 +95,32 @@ pub struct VmHandle { pub console_socket: Option, /// VNC listen address (e.g. "127.0.0.1:5900"). pub vnc_addr: Option, + /// Number of virtual CPUs allocated to this VM. + #[serde(default = "default_vcpus")] + pub vcpus: u16, + /// Memory in megabytes allocated to this VM. + #[serde(default = "default_memory_mb")] + pub memory_mb: u64, + /// Disk size in GB (overlay resize), if specified. + #[serde(default)] + pub disk_gb: Option, + /// Network configuration for this VM. + #[serde(default)] + pub network: NetworkConfig, + /// SSH host port for user-mode networking (forwarded to guest port 22). + #[serde(default)] + pub ssh_host_port: Option, + /// MAC address assigned to this VM. + #[serde(default)] + pub mac_addr: Option, +} + +fn default_vcpus() -> u16 { + 1 +} + +fn default_memory_mb() -> u64 { + 1024 } /// Observed VM lifecycle state. diff --git a/crates/vmctl/src/commands/create.rs b/crates/vmctl/src/commands/create.rs index 8f9139c..a801dcc 100644 --- a/crates/vmctl/src/commands/create.rs +++ b/crates/vmctl/src/commands/create.rs @@ -51,14 +51,58 @@ pub struct CreateArgs { } pub async fn run(args: CreateArgs) -> Result<()> { + // --- Input validation --- + if args.vcpus == 0 { + miette::bail!( + severity = miette::Severity::Error, + code = "vmctl::create::invalid_vcpus", + help = "specify at least 1 vCPU with --vcpus", + "vCPUs must be greater than 0" + ); + } + if args.memory == 0 { + miette::bail!( + severity = miette::Severity::Error, + code = "vmctl::create::invalid_memory", + help = "specify a positive amount of memory in MB with --memory", + "memory must be greater than 0" + ); + } + + // Check for name collision + let mut store = state::load_store().await?; + if store.contains_key(&args.name) { + miette::bail!( + severity = miette::Severity::Error, + code = "vmctl::create::name_exists", + help = "choose a different name or destroy the existing VM with `vmctl destroy {name}`", + "VM '{name}' already exists", + name = args.name + ); + } + // Resolve image let image_path = if let Some(ref path) = args.image { + if !path.exists() { + miette::bail!( + severity = miette::Severity::Error, + code = "vmctl::create::image_not_found", + help = "check the path is correct and the file exists", + "image file not found: {}", + path.display() + ); + } path.clone() } else if let Some(ref url) = args.image_url { let mgr = vm_manager::image::ImageManager::new(); mgr.pull(url, Some(&args.name)).await.into_diagnostic()? } else { - miette::bail!("either --image or --image-url must be specified"); + miette::bail!( + severity = miette::Severity::Error, + code = "vmctl::create::no_image", + help = "provide --image for a local file or --image-url to download one", + "either --image or --image-url must be specified" + ); }; // Build cloud-init config if user-data or ssh key provided @@ -121,14 +165,15 @@ pub async fn run(args: CreateArgs) -> Result<()> { info!(name = %args.name, id = %handle.id, "VM created"); // Persist handle - let mut store = state::load_store().await?; store.insert(args.name.clone(), handle.clone()); state::save_store(&store).await?; println!("VM '{}' created (id: {})", args.name, handle.id); if args.start { - hv.start(&handle).await.into_diagnostic()?; + let updated = hv.start(&handle).await.into_diagnostic()?; + store.insert(args.name.clone(), updated); + state::save_store(&store).await?; println!("VM '{}' started", args.name); } diff --git a/crates/vmctl/src/commands/list.rs b/crates/vmctl/src/commands/list.rs index ddd1713..69bd186 100644 --- a/crates/vmctl/src/commands/list.rs +++ b/crates/vmctl/src/commands/list.rs @@ -1,5 +1,6 @@ use clap::Args; use miette::Result; +use vm_manager::NetworkConfig; use super::state; @@ -14,19 +15,34 @@ pub async fn run(_args: ListArgs) -> Result<()> { return Ok(()); } - println!("{:<20} {:<12} {:<40} WORK DIR", "NAME", "BACKEND", "ID"); - println!("{}", "-".repeat(90)); + println!( + "{:<16} {:<8} {:>5} {:>6} {:<10} {:<8} SSH", + "NAME", "BACKEND", "VCPUS", "MEM", "NETWORK", "PID" + ); + println!("{}", "-".repeat(72)); let mut entries: Vec<_> = store.iter().collect(); entries.sort_by_key(|(name, _)| (*name).clone()); for (name, handle) in entries { + let net = match &handle.network { + NetworkConfig::Tap { .. } => "tap", + NetworkConfig::User => "user", + NetworkConfig::Vnic { .. } => "vnic", + NetworkConfig::None => "none", + }; + let pid = handle + .pid + .map(|p| p.to_string()) + .unwrap_or_else(|| "-".into()); + let ssh = handle + .ssh_host_port + .map(|p| format!(":{p}")) + .unwrap_or_else(|| "-".into()); + println!( - "{:<20} {:<12} {:<40} {}", - name, - handle.backend, - handle.id, - handle.work_dir.display() + "{:<16} {:<8} {:>5} {:>4}MB {:<10} {:<8} {}", + name, handle.backend, handle.vcpus, handle.memory_mb, net, pid, ssh ); } diff --git a/crates/vmctl/src/commands/ssh.rs b/crates/vmctl/src/commands/ssh.rs index ae420db..3777e1b 100644 --- a/crates/vmctl/src/commands/ssh.rs +++ b/crates/vmctl/src/commands/ssh.rs @@ -3,10 +3,13 @@ use std::time::Duration; use clap::Args; use miette::{IntoDiagnostic, Result}; -use vm_manager::{Hypervisor, RouterHypervisor, SshConfig}; +use vm_manager::{Hypervisor, NetworkConfig, RouterHypervisor, SshConfig}; use super::state; +/// SSH key filenames to try, in order of preference. +const SSH_KEY_NAMES: &[&str] = &["id_ed25519", "id_ecdsa", "id_rsa"]; + #[derive(Args)] pub struct SshArgs { /// VM name @@ -21,6 +24,20 @@ pub struct SshArgs { key: Option, } +/// Find the first existing SSH key in the user's .ssh directory. +fn find_ssh_key() -> Option { + let ssh_dir = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("/root")) + .join(".ssh"); + for name in SSH_KEY_NAMES { + let path = ssh_dir.join(name); + if path.exists() { + return Some(path); + } + } + None +} + pub async fn run(args: SshArgs) -> Result<()> { let store = state::load_store().await?; let handle = store @@ -30,12 +47,18 @@ pub async fn run(args: SshArgs) -> Result<()> { let hv = RouterHypervisor::new(None, None); let ip = hv.guest_ip(handle).await.into_diagnostic()?; - let key_path = args.key.unwrap_or_else(|| { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from("/root")) - .join(".ssh") - .join("id_ed25519") - }); + // Determine SSH port: use the forwarded host port for user-mode networking + let port = match handle.network { + NetworkConfig::User => handle.ssh_host_port.unwrap_or(22), + _ => 22, + }; + + let key_path = args.key.or_else(find_ssh_key).ok_or_else(|| { + miette::miette!( + "no SSH key found — provide one with --key or ensure ~/.ssh/id_ed25519, \ + ~/.ssh/id_ecdsa, or ~/.ssh/id_rsa exists" + ) + })?; let config = SshConfig { user: args.user.clone(), @@ -44,9 +67,9 @@ pub async fn run(args: SshArgs) -> Result<()> { private_key_pem: None, }; - println!("Connecting to {}@{}...", args.user, ip); + println!("Connecting to {}@{}:{}...", args.user, ip, port); - let sess = vm_manager::ssh::connect_with_retry(&ip, &config, Duration::from_secs(30)) + let sess = vm_manager::ssh::connect_with_retry(&ip, port, &config, Duration::from_secs(30)) .await .into_diagnostic()?; @@ -54,21 +77,25 @@ pub async fn run(args: SshArgs) -> Result<()> { // We use the system ssh binary for interactive terminal support. drop(sess); - let status = tokio::process::Command::new("ssh") - .arg("-o") + let mut cmd = tokio::process::Command::new("ssh"); + cmd.arg("-o") .arg("StrictHostKeyChecking=no") .arg("-o") - .arg("UserKnownHostsFile=/dev/null") - .args( - config - .private_key_path - .iter() - .flat_map(|p| ["-i".to_string(), p.display().to_string()]), - ) - .arg(format!("{}@{}", args.user, ip)) - .status() - .await - .into_diagnostic()?; + .arg("UserKnownHostsFile=/dev/null"); + + // Add port if non-standard + if port != 22 { + cmd.arg("-p").arg(port.to_string()); + } + + // Add key + if let Some(ref key) = config.private_key_path { + cmd.arg("-i").arg(key); + } + + cmd.arg(format!("{}@{}", args.user, ip)); + + let status = cmd.status().await.into_diagnostic()?; if !status.success() { miette::bail!("SSH exited with status {}", status); diff --git a/crates/vmctl/src/commands/start.rs b/crates/vmctl/src/commands/start.rs index 6176257..ca0adc1 100644 --- a/crates/vmctl/src/commands/start.rs +++ b/crates/vmctl/src/commands/start.rs @@ -11,7 +11,7 @@ pub struct StartArgs { } pub async fn run_start(args: StartArgs) -> Result<()> { - let store = state::load_store().await?; + let mut store = state::load_store().await?; let handle = store.get(&args.name).ok_or_else(|| { miette::miette!( "VM '{}' not found — run `vmctl list` to see available VMs", @@ -20,7 +20,11 @@ pub async fn run_start(args: StartArgs) -> Result<()> { })?; let hv = RouterHypervisor::new(None, None); - hv.start(handle).await.into_diagnostic()?; + let updated = hv.start(handle).await.into_diagnostic()?; + + store.insert(args.name.clone(), updated); + state::save_store(&store).await?; + println!("VM '{}' started", args.name); Ok(()) } @@ -32,13 +36,17 @@ pub struct SuspendArgs { } pub async fn run_suspend(args: SuspendArgs) -> Result<()> { - let store = state::load_store().await?; + let mut store = state::load_store().await?; let handle = store .get(&args.name) .ok_or_else(|| miette::miette!("VM '{}' not found", args.name))?; let hv = RouterHypervisor::new(None, None); - hv.suspend(handle).await.into_diagnostic()?; + let updated = hv.suspend(handle).await.into_diagnostic()?; + + store.insert(args.name.clone(), updated); + state::save_store(&store).await?; + println!("VM '{}' suspended", args.name); Ok(()) } @@ -50,13 +58,17 @@ pub struct ResumeArgs { } pub async fn run_resume(args: ResumeArgs) -> Result<()> { - let store = state::load_store().await?; + let mut store = state::load_store().await?; let handle = store .get(&args.name) .ok_or_else(|| miette::miette!("VM '{}' not found", args.name))?; let hv = RouterHypervisor::new(None, None); - hv.resume(handle).await.into_diagnostic()?; + let updated = hv.resume(handle).await.into_diagnostic()?; + + store.insert(args.name.clone(), updated); + state::save_store(&store).await?; + println!("VM '{}' resumed", args.name); Ok(()) } diff --git a/crates/vmctl/src/commands/state.rs b/crates/vmctl/src/commands/state.rs index 439af09..fbf41a6 100644 --- a/crates/vmctl/src/commands/state.rs +++ b/crates/vmctl/src/commands/state.rs @@ -1,4 +1,4 @@ -//! Persistent state for vmctl: maps VM name → VmHandle in a JSON file. +//! Persistent state for vmctl: maps VM name -> VmHandle in a JSON file. use std::collections::HashMap; use std::path::PathBuf; @@ -27,13 +27,17 @@ pub async fn load_store() -> Result { Ok(store) } -/// Save the VM store to disk. +/// Save the VM store to disk atomically (write to .tmp then rename). pub async fn save_store(store: &Store) -> Result<()> { let path = state_path(); if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await.into_diagnostic()?; } let data = serde_json::to_string_pretty(store).into_diagnostic()?; - tokio::fs::write(&path, data).await.into_diagnostic()?; + let tmp_path = path.with_extension("json.tmp"); + tokio::fs::write(&tmp_path, data).await.into_diagnostic()?; + tokio::fs::rename(&tmp_path, &path) + .await + .into_diagnostic()?; Ok(()) } diff --git a/crates/vmctl/src/commands/status.rs b/crates/vmctl/src/commands/status.rs index 9f3ee12..8d83105 100644 --- a/crates/vmctl/src/commands/status.rs +++ b/crates/vmctl/src/commands/status.rs @@ -1,6 +1,6 @@ use clap::Args; use miette::{IntoDiagnostic, Result}; -use vm_manager::{Hypervisor, RouterHypervisor}; +use vm_manager::{Hypervisor, NetworkConfig, RouterHypervisor}; use super::state; @@ -23,6 +23,12 @@ pub async fn run(args: StatusArgs) -> Result<()> { println!("ID: {}", handle.id); println!("Backend: {}", handle.backend); println!("State: {}", state); + println!("vCPUs: {}", handle.vcpus); + println!("Memory: {} MB", handle.memory_mb); + if let Some(disk) = handle.disk_gb { + println!("Disk: {} GB", disk); + } + println!("Network: {}", format_network(&handle.network)); println!("WorkDir: {}", handle.work_dir.display()); if let Some(ref overlay) = handle.overlay_path { @@ -37,6 +43,21 @@ pub async fn run(args: StatusArgs) -> Result<()> { if let Some(ref vnc) = handle.vnc_addr { println!("VNC: {}", vnc); } + if let Some(port) = handle.ssh_host_port { + println!("SSH: 127.0.0.1:{}", port); + } + if let Some(ref mac) = handle.mac_addr { + println!("MAC: {}", mac); + } Ok(()) } + +fn format_network(net: &NetworkConfig) -> String { + match net { + NetworkConfig::Tap { bridge } => format!("tap (bridge: {bridge})"), + NetworkConfig::User => "user (SLIRP)".into(), + NetworkConfig::Vnic { name } => format!("vnic ({name})"), + NetworkConfig::None => "none".into(), + } +} diff --git a/crates/vmctl/src/commands/stop.rs b/crates/vmctl/src/commands/stop.rs index 94576ed..3f8c509 100644 --- a/crates/vmctl/src/commands/stop.rs +++ b/crates/vmctl/src/commands/stop.rs @@ -17,15 +17,20 @@ pub struct StopArgs { } pub async fn run(args: StopArgs) -> Result<()> { - let store = state::load_store().await?; + let mut store = state::load_store().await?; let handle = store .get(&args.name) .ok_or_else(|| miette::miette!("VM '{}' not found", args.name))?; let hv = RouterHypervisor::new(None, None); - hv.stop(handle, Duration::from_secs(args.timeout)) + let updated = hv + .stop(handle, Duration::from_secs(args.timeout)) .await .into_diagnostic()?; + + store.insert(args.name.clone(), updated); + state::save_store(&store).await?; + println!("VM '{}' stopped", args.name); Ok(()) }