mirror of
https://github.com/CloudNebulaProject/vm-manager.git
synced 2026-04-10 13:20:41 +00:00
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:
parent
9dc492f90f
commit
407baab42f
15 changed files with 508 additions and 108 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<()> {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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}")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue