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
}
async fn start(&self, vm: &VmHandle) -> Result<()> {
async fn start(&self, vm: &VmHandle) -> Result<VmHandle> {
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<VmHandle> {
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<VmHandle> {
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<VmHandle> {
match vm.backend {
#[cfg(target_os = "linux")]
BackendTag::Qemu => match self.qemu {

View file

@ -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<VmHandle> {
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");
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");
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");
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());
}
}

View file

@ -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<VmHandle> {
// 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<VmHandle> {
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<VmHandle> {
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)");
Ok(())
Ok(vm.clone())
}
async fn destroy(&self, vm: VmHandle) -> Result<()> {

View file

@ -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<VmHandle> {
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<String> = 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<VmHandle> {
// 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<VmHandle> {
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<VmHandle> {
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<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")
.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 {

View file

@ -75,11 +75,13 @@ impl QmpClient {
async fn send_command(&mut self, execute: &str, arguments: Option<Value>) -> 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<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::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<Session> {
let addr = format!("{ip}:22");
pub fn connect(ip: &str, port: u16, config: &SshConfig) -> Result<Session> {
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<Session> {
@ -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),

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.
fn prepare(&self, spec: &VmSpec) -> impl Future<Output = Result<VmHandle>> + Send;
/// Boot the VM.
fn start(&self, vm: &VmHandle) -> impl Future<Output = Result<()>> + Send;
/// Boot the VM. Returns the updated handle with PID, VNC addr, etc.
fn start(&self, vm: &VmHandle) -> impl Future<Output = Result<VmHandle>> + Send;
/// 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).
fn suspend(&self, vm: &VmHandle) -> impl Future<Output = Result<()>> + Send;
/// Pause VM execution (freeze vCPUs). Returns the updated handle.
fn suspend(&self, vm: &VmHandle) -> impl Future<Output = Result<VmHandle>> + Send;
/// Resume a suspended VM.
fn resume(&self, vm: &VmHandle) -> impl Future<Output = Result<()>> + Send;
/// Resume a suspended VM. Returns the updated handle.
fn resume(&self, vm: &VmHandle) -> impl Future<Output = Result<VmHandle>> + Send;
/// Stop the VM (if running) and clean up all resources.
fn destroy(&self, vm: VmHandle) -> impl Future<Output = Result<()>> + Send;

View file

@ -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<PathBuf>,
/// VNC listen address (e.g. "127.0.0.1:5900").
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.

View file

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

View file

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

View file

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

View file

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

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::path::PathBuf;
@ -27,13 +27,17 @@ pub async fn load_store() -> Result<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<()> {
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(())
}

View file

@ -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(),
}
}

View file

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