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
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<()> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}")))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue