Make QEMU backend fully functional end-to-end

The scaffolding compiled but couldn't actually run VMs because QEMU
was missing vCPU/memory/networking args, VmHandle didn't persist enough
state for restart, and CLI commands discarded updated handles after
operations.

Key changes:
- Add vcpus, memory_mb, disk_gb, network, ssh_host_port, mac_addr to
  VmHandle with serde(default) for backward compat
- Make NetworkConfig serializable with serde(tag = "type")
- Change Hypervisor trait: start/stop/suspend/resume return Result<VmHandle>
  so backends can return updated PID, VNC addr, etc.
- Complete QEMU start() with -smp, -m, tap/user-mode networking, stale
  socket cleanup, PID reading, and VNC querying via QMP
- Fix guest_ip() to return 127.0.0.1 for user-mode networking and
  filter ARP entries by bridge for tap mode
- Add QMP query_vnc() and fix unwrap() panics in send_command()
- All CLI commands now persist the updated VmHandle after operations
- Add input validation in create with miette diagnostics
- Atomic state persistence (write-to-tmp + rename)
- SSH: port-aware connections, try ed25519/ecdsa/rsa key types
- Enhanced status/list output with vCPUs, memory, network, SSH port
- New tests: NetworkConfig roundtrip, VmHandle roundtrip, backward compat

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Till Wegmueller 2026-02-14 18:58:02 +01:00
parent 9dc492f90f
commit 407baab42f
No known key found for this signature in database
15 changed files with 508 additions and 108 deletions

View file

@ -93,7 +93,7 @@ impl Hypervisor for RouterHypervisor {
self.noop.prepare(spec).await self.noop.prepare(spec).await
} }
async fn start(&self, vm: &VmHandle) -> Result<()> { async fn start(&self, vm: &VmHandle) -> Result<VmHandle> {
match vm.backend { match vm.backend {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
BackendTag::Qemu => match self.qemu { 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<VmHandle> {
match vm.backend { match vm.backend {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
BackendTag::Qemu => match self.qemu { 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<VmHandle> {
match vm.backend { match vm.backend {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
BackendTag::Qemu => match self.qemu { 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<VmHandle> {
match vm.backend { match vm.backend {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
BackendTag::Qemu => match self.qemu { BackendTag::Qemu => match self.qemu {

View file

@ -27,27 +27,33 @@ impl Hypervisor for NoopBackend {
qmp_socket: None, qmp_socket: None,
console_socket: None, console_socket: None,
vnc_addr: 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<VmHandle> {
info!(id = %vm.id, name = %vm.name, "noop: start"); 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<VmHandle> {
info!(id = %vm.id, name = %vm.name, "noop: stop"); 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<VmHandle> {
info!(id = %vm.id, name = %vm.name, "noop: suspend"); 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<VmHandle> {
info!(id = %vm.id, name = %vm.name, "noop: resume"); info!(id = %vm.id, name = %vm.name, "noop: resume");
Ok(()) Ok(vm.clone())
} }
async fn destroy(&self, vm: VmHandle) -> Result<()> { async fn destroy(&self, vm: VmHandle) -> Result<()> {
@ -98,11 +104,11 @@ mod tests {
assert_eq!(handle.backend, BackendTag::Noop); assert_eq!(handle.backend, BackendTag::Noop);
assert!(handle.id.starts_with("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); assert_eq!(backend.state(&handle).await.unwrap(), VmState::Prepared);
backend.suspend(&handle).await.unwrap(); let handle = backend.suspend(&handle).await.unwrap();
backend.resume(&handle).await.unwrap(); let handle = backend.resume(&handle).await.unwrap();
let ip = backend.guest_ip(&handle).await.unwrap(); let ip = backend.guest_ip(&handle).await.unwrap();
assert_eq!(ip, "127.0.0.1"); assert_eq!(ip, "127.0.0.1");
@ -110,7 +116,78 @@ mod tests {
let endpoint = backend.console_endpoint(&handle).unwrap(); let endpoint = backend.console_endpoint(&handle).unwrap();
assert!(matches!(endpoint, ConsoleEndpoint::None)); 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(); 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());
}
} }

View file

@ -133,13 +133,19 @@ impl Hypervisor for PropolisBackend {
qmp_socket: None, qmp_socket: None,
console_socket: None, console_socket: None,
vnc_addr: 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"); info!(name = %spec.name, id = %handle.id, "Propolis: prepared");
Ok(handle) Ok(handle)
} }
async fn start(&self, vm: &VmHandle) -> Result<()> { async fn start(&self, vm: &VmHandle) -> Result<VmHandle> {
// Boot zone // Boot zone
let (ok, _, stderr) = Self::run_cmd("zoneadm", &["-z", &vm.name, "boot"]).await?; let (ok, _, stderr) = Self::run_cmd("zoneadm", &["-z", &vm.name, "boot"]).await?;
if !ok { if !ok {
@ -190,10 +196,10 @@ impl Hypervisor for PropolisBackend {
})?; })?;
info!(name = %vm.name, "Propolis: started"); 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<VmHandle> {
let propolis_addr = "127.0.0.1:12400"; let propolis_addr = "127.0.0.1:12400";
let client = reqwest::Client::new(); let client = reqwest::Client::new();
@ -208,17 +214,17 @@ impl Hypervisor for PropolisBackend {
let _ = Self::run_cmd("zoneadm", &["-z", &vm.name, "halt"]).await; let _ = Self::run_cmd("zoneadm", &["-z", &vm.name, "halt"]).await;
info!(name = %vm.name, "Propolis: stopped"); info!(name = %vm.name, "Propolis: stopped");
Ok(()) Ok(vm.clone())
} }
async fn suspend(&self, vm: &VmHandle) -> Result<()> { async fn suspend(&self, vm: &VmHandle) -> Result<VmHandle> {
info!(name = %vm.name, "Propolis: suspend (not yet implemented)"); 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<VmHandle> {
info!(name = %vm.name, "Propolis: resume (not yet implemented)"); info!(name = %vm.name, "Propolis: resume (not yet implemented)");
Ok(()) Ok(vm.clone())
} }
async fn destroy(&self, vm: VmHandle) -> Result<()> { async fn destroy(&self, vm: VmHandle) -> Result<()> {

View file

@ -7,7 +7,7 @@ use crate::cloudinit;
use crate::error::{Result, VmError}; use crate::error::{Result, VmError};
use crate::image; use crate::image;
use crate::traits::{ConsoleEndpoint, Hypervisor}; use crate::traits::{ConsoleEndpoint, Hypervisor};
use crate::types::{BackendTag, VmHandle, VmSpec, VmState}; use crate::types::{BackendTag, NetworkConfig, VmHandle, VmSpec, VmState};
use super::qmp::QmpClient; use super::qmp::QmpClient;
@ -66,6 +66,16 @@ impl QemuBackend {
// Signal 0 checks if process exists without sending a signal // Signal 0 checks if process exists without sending a signal
unsafe { libc::kill(pid as i32, 0) == 0 } 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. /// 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 qmp_socket = work_dir.join("qmp.sock");
let console_socket = work_dir.join("console.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 { let handle = VmHandle {
id: format!("qemu-{}", uuid::Uuid::new_v4()), id: format!("qemu-{}", uuid::Uuid::new_v4()),
name: spec.name.clone(), name: spec.name.clone(),
@ -128,11 +146,19 @@ impl Hypervisor for QemuBackend {
qmp_socket: Some(qmp_socket), qmp_socket: Some(qmp_socket),
console_socket: Some(console_socket), console_socket: Some(console_socket),
vnc_addr: None, 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!( info!(
name = %spec.name, name = %spec.name,
id = %handle.id, id = %handle.id,
vcpus = handle.vcpus,
memory_mb = handle.memory_mb,
overlay = ?handle.overlay_path, overlay = ?handle.overlay_path,
seed = ?handle.seed_iso_path, seed = ?handle.seed_iso_path,
"QEMU: prepared" "QEMU: prepared"
@ -141,7 +167,7 @@ impl Hypervisor for QemuBackend {
Ok(handle) Ok(handle)
} }
async fn start(&self, vm: &VmHandle) -> Result<()> { async fn start(&self, vm: &VmHandle) -> Result<VmHandle> {
let overlay = vm let overlay = vm
.overlay_path .overlay_path
.as_ref() .as_ref()
@ -150,12 +176,29 @@ impl Hypervisor for QemuBackend {
state: "no overlay path".into(), state: "no overlay path".into(),
})?; })?;
// Read the VmSpec vcpus/memory from the overlay's qemu-img info? No — we need let qmp_sock = vm
// to reconstruct from VmHandle. For now, use defaults if not stored. .qmp_socket
// The CLI will re-read spec and pass to prepare+start in sequence. .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(); // Clean up stale socket files from a previous run
let console_sock = vm.console_socket.as_ref().unwrap(); 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<String> = vec![ let mut args: Vec<String> = vec![
"-enable-kvm".into(), "-enable-kvm".into(),
@ -164,6 +207,12 @@ impl Hypervisor for QemuBackend {
"-cpu".into(), "-cpu".into(),
"host".into(), "host".into(),
"-nodefaults".into(), "-nodefaults".into(),
// vCPUs
"-smp".into(),
vm.vcpus.to_string(),
// Memory
"-m".into(),
format!("{}M", vm.memory_mb),
// QMP socket // QMP socket
"-qmp".into(), "-qmp".into(),
format!("unix:{},server,nowait", qmp_sock.display()), format!("unix:{},server,nowait", qmp_sock.display()),
@ -186,6 +235,30 @@ impl Hypervisor for QemuBackend {
"virtio-blk-pci,drive=drive0".into(), "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) // Seed ISO (cloud-init)
if let Some(ref iso) = vm.seed_iso_path { if let Some(ref iso) = vm.seed_iso_path {
args.extend([ args.extend([
@ -208,6 +281,8 @@ impl Hypervisor for QemuBackend {
info!( info!(
name = %vm.name, name = %vm.name,
vcpus = vm.vcpus,
memory_mb = vm.memory_mb,
binary = %self.qemu_binary.display(), binary = %self.qemu_binary.display(),
"QEMU: starting" "QEMU: starting"
); );
@ -225,15 +300,30 @@ impl Hypervisor for QemuBackend {
}); });
} }
// Wait for QMP socket and verify connection // Read PID from pidfile
let mut qmp = QmpClient::connect(qmp_sock, Duration::from_secs(10)).await?; let pid = Self::read_pid(&vm.work_dir).await;
let status = qmp.query_status().await?;
info!(name = %vm.name, status = %status, "QEMU: started");
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<VmHandle> {
// Try ACPI shutdown via QMP first // Try ACPI shutdown via QMP first
if let Some(ref qmp_sock) = vm.qmp_socket { if let Some(ref qmp_sock) = vm.qmp_socket {
if qmp_sock.exists() { if qmp_sock.exists() {
@ -249,11 +339,17 @@ impl Hypervisor for QemuBackend {
if let Some(pid) = Self::read_pid(&vm.work_dir).await { if let Some(pid) = Self::read_pid(&vm.work_dir).await {
if !Self::pid_alive(pid) { if !Self::pid_alive(pid) {
info!(name = %vm.name, "QEMU: process exited after ACPI shutdown"); 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 { } else {
// No PID file, process likely already gone // 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 { 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<VmHandle> {
if let Some(ref qmp_sock) = vm.qmp_socket { if let Some(ref qmp_sock) = vm.qmp_socket {
let mut qmp = QmpClient::connect(qmp_sock, Duration::from_secs(5)).await?; let mut qmp = QmpClient::connect(qmp_sock, Duration::from_secs(5)).await?;
qmp.stop().await?; qmp.stop().await?;
} }
Ok(()) Ok(vm.clone())
} }
async fn resume(&self, vm: &VmHandle) -> Result<()> { async fn resume(&self, vm: &VmHandle) -> Result<VmHandle> {
if let Some(ref qmp_sock) = vm.qmp_socket { if let Some(ref qmp_sock) = vm.qmp_socket {
let mut qmp = QmpClient::connect(qmp_sock, Duration::from_secs(5)).await?; let mut qmp = QmpClient::connect(qmp_sock, Duration::from_secs(5)).await?;
qmp.cont().await?; qmp.cont().await?;
} }
Ok(()) Ok(vm.clone())
} }
async fn destroy(&self, vm: VmHandle) -> Result<()> { async fn destroy(&self, vm: VmHandle) -> Result<()> {
@ -349,7 +448,18 @@ impl Hypervisor for QemuBackend {
} }
async fn guest_ip(&self, vm: &VmHandle) -> Result<String> { async fn guest_ip(&self, vm: &VmHandle) -> Result<String> {
// 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") let output = tokio::process::Command::new("ip")
.args(["neigh", "show"]) .args(["neigh", "show"])
.output() .output()
@ -360,10 +470,14 @@ impl Hypervisor for QemuBackend {
let text = String::from_utf8_lossy(&output.stdout); 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() { for line in text.lines() {
if line.contains("REACHABLE") || line.contains("STALE") { 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() { if let Some(ip) = line.split_whitespace().next() {
// Basic IPv4 check // Basic IPv4 check
if ip.contains('.') && !ip.starts_with("127.") { if ip.contains('.') && !ip.starts_with("127.") {
@ -374,10 +488,22 @@ impl Hypervisor for QemuBackend {
} }
// Fallback: check dnsmasq leases if available // 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"; let leases_path = "/var/lib/misc/dnsmasq.leases";
if let Ok(content) = tokio::fs::read_to_string(leases_path).await { if let Ok(content) = tokio::fs::read_to_string(leases_path).await {
// Lease format: epoch MAC IP hostname clientid // 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() { if let Some(line) = content.lines().last() {
let parts: Vec<&str> = line.split_whitespace().collect(); let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 3 { if parts.len() >= 3 {

View file

@ -75,11 +75,13 @@ impl QmpClient {
async fn send_command(&mut self, execute: &str, arguments: Option<Value>) -> Result<()> { async fn send_command(&mut self, execute: &str, arguments: Option<Value>) -> Result<()> {
let mut cmd = serde_json::json!({ "execute": execute }); let mut cmd = serde_json::json!({ "execute": execute });
if let Some(args) = arguments { if let Some(args) = arguments {
cmd.as_object_mut() if let Some(obj) = cmd.as_object_mut() {
.unwrap() obj.insert("arguments".into(), args);
.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'); line.push('\n');
trace!(cmd = %line.trim(), "QMP send"); trace!(cmd = %line.trim(), "QMP send");
self.writer self.writer
@ -197,4 +199,29 @@ impl QmpClient {
.to_string(); .to_string();
Ok(status) Ok(status)
} }
/// Query the VNC server address. Returns `"host:port"` if VNC is active.
pub async fn query_vnc(&mut self) -> Result<Option<String>> {
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}")))
}
} }

View file

@ -9,11 +9,11 @@ use tracing::warn;
use crate::error::{Result, VmError}; use crate::error::{Result, VmError};
use crate::types::SshConfig; 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. /// Tries in-memory key first, then key file path.
pub fn connect(ip: &str, config: &SshConfig) -> Result<Session> { pub fn connect(ip: &str, port: u16, config: &SshConfig) -> Result<Session> {
let addr = format!("{ip}:22"); let addr = format!("{ip}:{port}");
let tcp = TcpStream::connect(&addr).map_err(|e| VmError::SshFailed { let tcp = TcpStream::connect(&addr).map_err(|e| VmError::SshFailed {
detail: format!("TCP connect to {addr}: {e}"), 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. /// Retries the connection until `timeout` elapses, with exponential backoff capped at 5 seconds.
pub async fn connect_with_retry( pub async fn connect_with_retry(
ip: &str, ip: &str,
port: u16,
config: &SshConfig, config: &SshConfig,
timeout: Duration, timeout: Duration,
) -> Result<Session> { ) -> Result<Session> {
@ -137,7 +138,8 @@ pub async fn connect_with_retry(
let config_clone = config.clone(); let config_clone = config.clone();
// Run the blocking SSH connect on a blocking thread // 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 { match result {
Ok(Ok(sess)) => return Ok(sess), Ok(Ok(sess)) => return Ok(sess),

View file

@ -10,17 +10,22 @@ pub trait Hypervisor: Send + Sync {
/// Allocate resources (overlay disk, cloud-init ISO, zone config, etc.) and return a handle. /// Allocate resources (overlay disk, cloud-init ISO, zone config, etc.) and return a handle.
fn prepare(&self, spec: &VmSpec) -> impl Future<Output = Result<VmHandle>> + Send; fn prepare(&self, spec: &VmSpec) -> impl Future<Output = Result<VmHandle>> + Send;
/// Boot the VM. /// Boot the VM. Returns the updated handle with PID, VNC addr, etc.
fn start(&self, vm: &VmHandle) -> impl Future<Output = Result<()>> + Send; fn start(&self, vm: &VmHandle) -> impl Future<Output = Result<VmHandle>> + Send;
/// Gracefully stop the VM. Falls back to forceful termination after `timeout`. /// Gracefully stop the VM. Falls back to forceful termination after `timeout`.
fn stop(&self, vm: &VmHandle, timeout: Duration) -> impl Future<Output = Result<()>> + Send; /// Returns the updated handle with cleared runtime fields.
fn stop(
&self,
vm: &VmHandle,
timeout: Duration,
) -> impl Future<Output = Result<VmHandle>> + Send;
/// Pause VM execution (freeze vCPUs). /// Pause VM execution (freeze vCPUs). Returns the updated handle.
fn suspend(&self, vm: &VmHandle) -> impl Future<Output = Result<()>> + Send; fn suspend(&self, vm: &VmHandle) -> impl Future<Output = Result<VmHandle>> + Send;
/// Resume a suspended VM. /// Resume a suspended VM. Returns the updated handle.
fn resume(&self, vm: &VmHandle) -> impl Future<Output = Result<()>> + Send; fn resume(&self, vm: &VmHandle) -> impl Future<Output = Result<VmHandle>> + Send;
/// Stop the VM (if running) and clean up all resources. /// Stop the VM (if running) and clean up all resources.
fn destroy(&self, vm: VmHandle) -> impl Future<Output = Result<()>> + Send; fn destroy(&self, vm: VmHandle) -> impl Future<Output = Result<()>> + Send;

View file

@ -34,7 +34,8 @@ pub struct VmSpec {
} }
/// Network configuration for a VM. /// Network configuration for a VM.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum NetworkConfig { pub enum NetworkConfig {
/// TAP device bridged to a host bridge (default on Linux). /// TAP device bridged to a host bridge (default on Linux).
Tap { bridge: String }, Tap { bridge: String },
@ -94,6 +95,32 @@ pub struct VmHandle {
pub console_socket: Option<PathBuf>, pub console_socket: Option<PathBuf>,
/// VNC listen address (e.g. "127.0.0.1:5900"). /// VNC listen address (e.g. "127.0.0.1:5900").
pub vnc_addr: Option<String>, pub vnc_addr: Option<String>,
/// 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<u32>,
/// 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<u16>,
/// MAC address assigned to this VM.
#[serde(default)]
pub mac_addr: Option<String>,
}
fn default_vcpus() -> u16 {
1
}
fn default_memory_mb() -> u64 {
1024
} }
/// Observed VM lifecycle state. /// Observed VM lifecycle state.

View file

@ -51,14 +51,58 @@ pub struct CreateArgs {
} }
pub async fn run(args: CreateArgs) -> Result<()> { 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 // Resolve image
let image_path = if let Some(ref path) = args.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() path.clone()
} else if let Some(ref url) = args.image_url { } else if let Some(ref url) = args.image_url {
let mgr = vm_manager::image::ImageManager::new(); let mgr = vm_manager::image::ImageManager::new();
mgr.pull(url, Some(&args.name)).await.into_diagnostic()? mgr.pull(url, Some(&args.name)).await.into_diagnostic()?
} else { } 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 // 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"); info!(name = %args.name, id = %handle.id, "VM created");
// Persist handle // Persist handle
let mut store = state::load_store().await?;
store.insert(args.name.clone(), handle.clone()); store.insert(args.name.clone(), handle.clone());
state::save_store(&store).await?; state::save_store(&store).await?;
println!("VM '{}' created (id: {})", args.name, handle.id); println!("VM '{}' created (id: {})", args.name, handle.id);
if args.start { 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); println!("VM '{}' started", args.name);
} }

View file

@ -1,5 +1,6 @@
use clap::Args; use clap::Args;
use miette::Result; use miette::Result;
use vm_manager::NetworkConfig;
use super::state; use super::state;
@ -14,19 +15,34 @@ pub async fn run(_args: ListArgs) -> Result<()> {
return Ok(()); return Ok(());
} }
println!("{:<20} {:<12} {:<40} WORK DIR", "NAME", "BACKEND", "ID"); println!(
println!("{}", "-".repeat(90)); "{:<16} {:<8} {:>5} {:>6} {:<10} {:<8} SSH",
"NAME", "BACKEND", "VCPUS", "MEM", "NETWORK", "PID"
);
println!("{}", "-".repeat(72));
let mut entries: Vec<_> = store.iter().collect(); let mut entries: Vec<_> = store.iter().collect();
entries.sort_by_key(|(name, _)| (*name).clone()); entries.sort_by_key(|(name, _)| (*name).clone());
for (name, handle) in entries { 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!( println!(
"{:<20} {:<12} {:<40} {}", "{:<16} {:<8} {:>5} {:>4}MB {:<10} {:<8} {}",
name, name, handle.backend, handle.vcpus, handle.memory_mb, net, pid, ssh
handle.backend,
handle.id,
handle.work_dir.display()
); );
} }

View file

@ -3,10 +3,13 @@ use std::time::Duration;
use clap::Args; use clap::Args;
use miette::{IntoDiagnostic, Result}; use miette::{IntoDiagnostic, Result};
use vm_manager::{Hypervisor, RouterHypervisor, SshConfig}; use vm_manager::{Hypervisor, NetworkConfig, RouterHypervisor, SshConfig};
use super::state; 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)] #[derive(Args)]
pub struct SshArgs { pub struct SshArgs {
/// VM name /// VM name
@ -21,6 +24,20 @@ pub struct SshArgs {
key: Option<PathBuf>, key: Option<PathBuf>,
} }
/// Find the first existing SSH key in the user's .ssh directory.
fn find_ssh_key() -> Option<PathBuf> {
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<()> { pub async fn run(args: SshArgs) -> Result<()> {
let store = state::load_store().await?; let store = state::load_store().await?;
let handle = store let handle = store
@ -30,12 +47,18 @@ pub async fn run(args: SshArgs) -> Result<()> {
let hv = RouterHypervisor::new(None, None); let hv = RouterHypervisor::new(None, None);
let ip = hv.guest_ip(handle).await.into_diagnostic()?; let ip = hv.guest_ip(handle).await.into_diagnostic()?;
let key_path = args.key.unwrap_or_else(|| { // Determine SSH port: use the forwarded host port for user-mode networking
dirs::home_dir() let port = match handle.network {
.unwrap_or_else(|| PathBuf::from("/root")) NetworkConfig::User => handle.ssh_host_port.unwrap_or(22),
.join(".ssh") _ => 22,
.join("id_ed25519") };
});
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 { let config = SshConfig {
user: args.user.clone(), user: args.user.clone(),
@ -44,9 +67,9 @@ pub async fn run(args: SshArgs) -> Result<()> {
private_key_pem: None, 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 .await
.into_diagnostic()?; .into_diagnostic()?;
@ -54,21 +77,25 @@ pub async fn run(args: SshArgs) -> Result<()> {
// We use the system ssh binary for interactive terminal support. // We use the system ssh binary for interactive terminal support.
drop(sess); drop(sess);
let status = tokio::process::Command::new("ssh") let mut cmd = tokio::process::Command::new("ssh");
.arg("-o") cmd.arg("-o")
.arg("StrictHostKeyChecking=no") .arg("StrictHostKeyChecking=no")
.arg("-o") .arg("-o")
.arg("UserKnownHostsFile=/dev/null") .arg("UserKnownHostsFile=/dev/null");
.args(
config // Add port if non-standard
.private_key_path if port != 22 {
.iter() cmd.arg("-p").arg(port.to_string());
.flat_map(|p| ["-i".to_string(), p.display().to_string()]), }
)
.arg(format!("{}@{}", args.user, ip)) // Add key
.status() if let Some(ref key) = config.private_key_path {
.await cmd.arg("-i").arg(key);
.into_diagnostic()?; }
cmd.arg(format!("{}@{}", args.user, ip));
let status = cmd.status().await.into_diagnostic()?;
if !status.success() { if !status.success() {
miette::bail!("SSH exited with status {}", status); miette::bail!("SSH exited with status {}", status);

View file

@ -11,7 +11,7 @@ pub struct StartArgs {
} }
pub async fn run_start(args: StartArgs) -> Result<()> { 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(|| { let handle = store.get(&args.name).ok_or_else(|| {
miette::miette!( miette::miette!(
"VM '{}' not found — run `vmctl list` to see available VMs", "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); 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); println!("VM '{}' started", args.name);
Ok(()) Ok(())
} }
@ -32,13 +36,17 @@ pub struct SuspendArgs {
} }
pub async fn run_suspend(args: SuspendArgs) -> Result<()> { pub async fn run_suspend(args: SuspendArgs) -> Result<()> {
let store = state::load_store().await?; let mut store = state::load_store().await?;
let handle = store let handle = store
.get(&args.name) .get(&args.name)
.ok_or_else(|| miette::miette!("VM '{}' not found", args.name))?; .ok_or_else(|| miette::miette!("VM '{}' not found", args.name))?;
let hv = RouterHypervisor::new(None, None); 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); println!("VM '{}' suspended", args.name);
Ok(()) Ok(())
} }
@ -50,13 +58,17 @@ pub struct ResumeArgs {
} }
pub async fn run_resume(args: ResumeArgs) -> Result<()> { pub async fn run_resume(args: ResumeArgs) -> Result<()> {
let store = state::load_store().await?; let mut store = state::load_store().await?;
let handle = store let handle = store
.get(&args.name) .get(&args.name)
.ok_or_else(|| miette::miette!("VM '{}' not found", args.name))?; .ok_or_else(|| miette::miette!("VM '{}' not found", args.name))?;
let hv = RouterHypervisor::new(None, None); 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); println!("VM '{}' resumed", args.name);
Ok(()) Ok(())
} }

View file

@ -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::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
@ -27,13 +27,17 @@ pub async fn load_store() -> Result<Store> {
Ok(store) 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<()> { pub async fn save_store(store: &Store) -> Result<()> {
let path = state_path(); let path = state_path();
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await.into_diagnostic()?; tokio::fs::create_dir_all(parent).await.into_diagnostic()?;
} }
let data = serde_json::to_string_pretty(store).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(()) Ok(())
} }

View file

@ -1,6 +1,6 @@
use clap::Args; use clap::Args;
use miette::{IntoDiagnostic, Result}; use miette::{IntoDiagnostic, Result};
use vm_manager::{Hypervisor, RouterHypervisor}; use vm_manager::{Hypervisor, NetworkConfig, RouterHypervisor};
use super::state; use super::state;
@ -23,6 +23,12 @@ pub async fn run(args: StatusArgs) -> Result<()> {
println!("ID: {}", handle.id); println!("ID: {}", handle.id);
println!("Backend: {}", handle.backend); println!("Backend: {}", handle.backend);
println!("State: {}", state); 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()); println!("WorkDir: {}", handle.work_dir.display());
if let Some(ref overlay) = handle.overlay_path { 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 { if let Some(ref vnc) = handle.vnc_addr {
println!("VNC: {}", vnc); 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(()) 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(),
}
}

View file

@ -17,15 +17,20 @@ pub struct StopArgs {
} }
pub async fn run(args: StopArgs) -> Result<()> { pub async fn run(args: StopArgs) -> Result<()> {
let store = state::load_store().await?; let mut store = state::load_store().await?;
let handle = store let handle = store
.get(&args.name) .get(&args.name)
.ok_or_else(|| miette::miette!("VM '{}' not found", args.name))?; .ok_or_else(|| miette::miette!("VM '{}' not found", args.name))?;
let hv = RouterHypervisor::new(None, None); 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 .await
.into_diagnostic()?; .into_diagnostic()?;
store.insert(args.name.clone(), updated);
state::save_store(&store).await?;
println!("VM '{}' stopped", args.name); println!("VM '{}' stopped", args.name);
Ok(()) Ok(())
} }