diff --git a/crates/ember-core/src/config/vm.rs b/crates/ember-core/src/config/vm.rs index 1752631..53541c9 100644 --- a/crates/ember-core/src/config/vm.rs +++ b/crates/ember-core/src/config/vm.rs @@ -37,6 +37,8 @@ pub struct VmConfig { pub ssh: Option, /// Custom boot arguments for the kernel. pub boot_args: Option, + /// Enable vsock device for host-guest communication. + pub vsock: Option, } /// Network configuration within a VM YAML config. @@ -181,6 +183,17 @@ boot_args: "console=ttyS0 reboot=k panic=1 pci=off" assert!(config.boot_args.is_none()); } + #[test] + fn parse_vsock_config() { + let yaml = "image: alpine:latest\nvsock: true\n"; + let config: VmConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.vsock, Some(true)); + + let yaml = "image: alpine:latest\n"; + let config: VmConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(config.vsock.is_none()); + } + #[test] fn parse_empty_config() { let yaml = "---\n"; diff --git a/crates/ember-core/src/state/vm.rs b/crates/ember-core/src/state/vm.rs index c6ae877..d07656c 100644 --- a/crates/ember-core/src/state/vm.rs +++ b/crates/ember-core/src/state/vm.rs @@ -63,6 +63,22 @@ pub struct NetworkInfo { pub wan_iface: Option, } +/// Vsock configuration for host-guest communication. +/// +/// When enabled, a virtio-vsock device is attached to the VM and a +/// Unix domain socket is created on the host for communication. +/// The guest connects via `AF_VSOCK` to CID 2 (host); host programs +/// connect to the UDS at `uds_path`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct VsockInfo { + /// Path to the Unix domain socket on the host. + /// e.g., `/vms//vsock.sock` + pub uds_path: PathBuf, + /// Guest CID (Context Identifier). Defaults to 3. + /// CID 0 and 1 are reserved; CID 2 is the host. + pub guest_cid: u32, +} + /// SSH connection configuration for a VM. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SshConfig { @@ -132,6 +148,9 @@ pub struct VmMetadata { /// is purely informational — no cleanup or deletion constraints apply. #[serde(default, alias = "forked_from")] pub parent_vm: Option, + /// Vsock configuration, if vsock is enabled for this VM. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vsock: Option, } impl VmMetadata { @@ -162,6 +181,7 @@ impl VmMetadata { key: PathBuf::new(), }, parent_vm: None, + vsock: None, } } } @@ -354,6 +374,7 @@ mod tests { created_at: "2026-01-01T00:00:00Z".to_string(), ssh: SshConfig::default(), parent_vm: None, + vsock: None, } } @@ -497,6 +518,51 @@ mod tests { assert!(json["network"].is_null()); assert!(json["pid"].is_null()); assert_eq!(json["ssh"]["user"], "root"); + // vsock is None, so it should be absent from JSON (skip_serializing_if) + assert!(json.get("vsock").is_none()); + } + + #[test] + fn vm_with_vsock_round_trip() { + let (_dir, store) = test_store(); + let mut vm = sample_vm("vsockvm"); + vm.vsock = Some(VsockInfo { + uds_path: PathBuf::from("/var/lib/ember/vms/vsockvm/vsock.sock"), + guest_cid: 3, + }); + + save(&store, &vm).unwrap(); + let loaded = load(&store, "vsockvm").unwrap(); + assert_eq!(loaded, vm); + + let vsock = loaded.vsock.as_ref().unwrap(); + assert_eq!( + vsock.uds_path, + PathBuf::from("/var/lib/ember/vms/vsockvm/vsock.sock") + ); + assert_eq!(vsock.guest_cid, 3); + } + + #[test] + fn vm_without_vsock_deserializes() { + // Ensure backwards compatibility: old vm.json without vsock field + // still deserializes correctly (vsock defaults to None). + let json = r#"{ + "name": "oldvm", + "id": "00000000-0000-0000-0000-000000000000", + "status": "created", + "image": "alpine:latest", + "cpus": 1, + "memory_mib": 512, + "disk_size_gib": 4, + "kernel_path": "/boot/vmlinux", + "disk_path": "pool/vms/oldvm", + "api_socket": "/tmp/fc.sock", + "created_at": "2026-01-01T00:00:00Z", + "ssh": { "user": "root", "key": "/root/.ssh/id_ed25519" } + }"#; + let vm: VmMetadata = serde_json::from_str(json).unwrap(); + assert!(vm.vsock.is_none()); } #[test] diff --git a/crates/ember-linux/src/firecracker/api.rs b/crates/ember-linux/src/firecracker/api.rs index 2c0ea81..f6499e8 100644 --- a/crates/ember-linux/src/firecracker/api.rs +++ b/crates/ember-linux/src/firecracker/api.rs @@ -83,6 +83,18 @@ pub enum VmState { Resumed, } +/// Vsock device attached to the VM. +/// +/// Firecracker creates a Unix domain socket at `uds_path` on the host. +/// Guest programs connect via `AF_VSOCK` to CID 2 (host); host programs +/// connect to the UDS and specify the guest CID + port. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Vsock { + pub vsock_id: String, + pub guest_cid: u32, + pub uds_path: String, +} + /// Error body returned by Firecracker on failure. #[derive(Debug, Deserialize)] struct FaultResponse { @@ -130,6 +142,11 @@ impl FirecrackerClient { self.put(&path, iface).await } + /// `PUT /vsock` — attach a vsock device to the VM. + pub async fn put_vsock(&self, vsock: &Vsock) -> anyhow::Result<()> { + self.put("/vsock", vsock).await + } + /// `PUT /actions` — start the VM, send Ctrl+Alt+Del, etc. pub async fn put_action(&self, action: &InstanceAction) -> anyhow::Result<()> { self.put("/actions", action).await diff --git a/crates/ember-linux/src/firecracker/config.rs b/crates/ember-linux/src/firecracker/config.rs index 008296b..8ef0229 100644 --- a/crates/ember-linux/src/firecracker/config.rs +++ b/crates/ember-linux/src/firecracker/config.rs @@ -7,7 +7,7 @@ use std::path::PathBuf; use crate::firecracker::api::{ - BootSource, Drive, FirecrackerClient, InstanceAction, MachineConfig, NetworkInterface, + BootSource, Drive, FirecrackerClient, InstanceAction, MachineConfig, NetworkInterface, Vsock, }; /// Default boot arguments for the guest kernel. @@ -52,6 +52,11 @@ pub struct VmConfig { pub rootfs_path: PathBuf, /// Optional network interface configuration. pub network: Option, + /// Optional vsock device. When set, configures a virtio-vsock device + /// with the given UDS path and guest CID. + pub vsock_uds_path: Option, + /// Guest CID for vsock (default: 3). + pub vsock_guest_cid: u32, } impl VmConfig { @@ -72,6 +77,8 @@ impl VmConfig { boot_args: DEFAULT_BOOT_ARGS.to_string(), rootfs_path: rootfs_path.into(), network: None, + vsock_uds_path: None, + vsock_guest_cid: 3, } } @@ -87,6 +94,13 @@ impl VmConfig { self } + /// Enable vsock device with the given UDS path and guest CID. + pub fn with_vsock(mut self, uds_path: impl Into, guest_cid: u32) -> Self { + self.vsock_uds_path = Some(uds_path.into()); + self.vsock_guest_cid = guest_cid; + self + } + /// Build the full boot_args string. /// /// If networking is configured, appends the kernel `ip=` parameter @@ -172,6 +186,17 @@ impl VmConfig { .await?; } + // 5. Vsock device (if configured) + if let Some(ref uds_path) = self.vsock_uds_path { + client + .put_vsock(&Vsock { + vsock_id: "vsock0".to_string(), + guest_cid: self.vsock_guest_cid, + uds_path: uds_path.clone(), + }) + .await?; + } + Ok(()) } @@ -257,4 +282,22 @@ mod tests { "console=ttyS0 panic=1 ip=10.100.0.6::10.100.0.5:255.255.255.252:customvm:eth0:off:1.1.1.1" ); } + + #[test] + fn with_vsock() { + let config = VmConfig::new(2, 512, "/boot/vmlinux", "/dev/zvol/pool/vms/test") + .with_vsock("/var/lib/ember/vms/test/vsock.sock", 3); + assert_eq!( + config.vsock_uds_path.as_deref(), + Some("/var/lib/ember/vms/test/vsock.sock") + ); + assert_eq!(config.vsock_guest_cid, 3); + } + + #[test] + fn default_no_vsock() { + let config = VmConfig::new(1, 128, "/boot/vmlinux", "/dev/zvol/pool/vms/test"); + assert!(config.vsock_uds_path.is_none()); + assert_eq!(config.vsock_guest_cid, 3); + } } diff --git a/crates/ember-linux/src/vm.rs b/crates/ember-linux/src/vm.rs index fc337c9..68d208d 100644 --- a/crates/ember-linux/src/vm.rs +++ b/crates/ember-linux/src/vm.rs @@ -185,7 +185,7 @@ fn configure_and_boot( if let Some(ref boot_args) = vm.boot_args { vm_config = vm_config.with_boot_args(boot_args); } - let vm_config = vm_config.with_network(firecracker::config::VmNetworkConfig { + let mut vm_config = vm_config.with_network(firecracker::config::VmNetworkConfig { tap_device: net_info.tap_device.clone(), guest_ip: net_info.guest_ip.clone(), host_ip: net_info.host_ip.clone(), @@ -195,6 +195,14 @@ fn configure_and_boot( dns_servers, }); + // Configure vsock device if enabled. + if let Some(ref vsock) = vm.vsock { + vm_config = vm_config.with_vsock( + vsock.uds_path.to_string_lossy().to_string(), + vsock.guest_cid, + ); + } + // Run the async API calls in a blocking runtime. let rt = tokio::runtime::Runtime::new() .map_err(|e| Error::Firecracker(format!("failed to create tokio runtime: {e}")))?; diff --git a/crates/ember-macos/src/vm.rs b/crates/ember-macos/src/vm.rs index f2b9f89..6da860e 100644 --- a/crates/ember-macos/src/vm.rs +++ b/crates/ember-macos/src/vm.rs @@ -125,6 +125,11 @@ impl VmBackend for MacosVm { .arg("--ready-fd") .arg(write_fd_num.to_string()); + // Pass vsock UDS path if vsock is enabled. + if let Some(ref vsock) = vm.vsock { + cmd.arg("--vsock-path").arg(&vsock.uds_path); + } + // Redirect stdout/stderr to the serial log / null so the helper // doesn't interfere with ember's terminal output. cmd.stdin(Stdio::null()); diff --git a/ember-vz/Sources/EmberVZ/Start.swift b/ember-vz/Sources/EmberVZ/Start.swift index 6b2f6eb..9444162 100644 --- a/ember-vz/Sources/EmberVZ/Start.swift +++ b/ember-vz/Sources/EmberVZ/Start.swift @@ -1,6 +1,6 @@ import ArgumentParser import Foundation -import Virtualization +@preconcurrency import Virtualization /// Boot a Linux VM with the given kernel, disk, and configuration. /// @@ -43,6 +43,9 @@ struct Start: ParsableCommand { @Option(name: .long, help: "File descriptor to write ready notification (MAC address)") var readyFd: Int32? = nil + @Option(name: .long, help: "Path to Unix domain socket for vsock bridge") + var vsockPath: String? = nil + func run() throws { // Build the VM configuration (does not require main actor) let vmConfig = try buildConfiguration() @@ -151,6 +154,11 @@ struct Start: ParsableCommand { VZVirtioTraditionalMemoryBalloonDeviceConfiguration() ] + // Vsock: virtio-socket for host↔guest communication (if enabled) + if vsockPath != nil { + config.socketDevices = [VZVirtioSocketDeviceConfiguration()] + } + try config.validate() return config } @@ -268,6 +276,9 @@ struct Start: ParsableCommand { // Capture ready-fd for use in the start callback let readyFd = self.readyFd + // Capture vsockPath for use in start callback + let vsockPath = self.vsockPath + vm.start { result in switch result { case .success: @@ -281,6 +292,12 @@ struct Start: ParsableCommand { readyHandle.write(Data("\(mac)\n".utf8)) } + // Set up vsock UDS bridge if enabled. + if let path = vsockPath, + let socketDevice = vm.socketDevices.first as? VZVirtioSocketDevice { + startVsockBridge(device: socketDevice, udsPath: path) + } + case .failure(let error): fputs("error: vm failed to start: \(error.localizedDescription)\n", stderr) Darwin.exit(1) @@ -298,6 +315,197 @@ nonisolated(unsafe) var _sigtermSourceRef: DispatchSourceSignal? nonisolated(unsafe) var _sigusr1SourceRef: DispatchSourceSignal? nonisolated(unsafe) var _sigusr2SourceRef: DispatchSourceSignal? +// MARK: - sockaddr_un Helper + +/// Create a `sockaddr_un` from a Unix socket path string. +/// Returns nil if the path is too long. +func makeSockaddrUn(path: String) -> sockaddr_un? { + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = path.utf8CString + let maxLen = MemoryLayout.size(ofValue: addr.sun_path) + guard pathBytes.count <= maxLen else { return nil } + withUnsafeMutableBytes(of: &addr.sun_path) { dest in + pathBytes.withUnsafeBufferPointer { src in + dest.copyMemory(from: UnsafeRawBufferPointer(src)) + } + } + return addr +} + +// MARK: - Vsock UDS Bridge + +/// Start a Unix domain socket listener that bridges host connections to the +/// VM's vsock device. Host clients connect to the UDS; each connection is +/// proxied to the guest on the same port the client specifies via a simple +/// length-prefixed port header, or to a default port (1024). +/// +/// Also installs a vsock listener for guest-initiated connections on port 1024 +/// and bridges those to the UDS. +func startVsockBridge(device: VZVirtioSocketDevice, udsPath: String) { + // Remove stale socket file if it exists. + unlink(udsPath) + + // Create Unix domain socket. + let serverFd = socket(AF_UNIX, SOCK_STREAM, 0) + guard serverFd >= 0 else { + fputs("warning: vsock bridge: failed to create UDS: \(String(cString: strerror(errno)))\n", stderr) + return + } + + guard var addr = makeSockaddrUn(path: udsPath) else { + fputs("warning: vsock bridge: UDS path too long\n", stderr) + close(serverFd) + return + } + + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + Darwin.bind(serverFd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + fputs("warning: vsock bridge: bind failed: \(String(cString: strerror(errno)))\n", stderr) + close(serverFd) + return + } + + guard listen(serverFd, 16) == 0 else { + fputs("warning: vsock bridge: listen failed: \(String(cString: strerror(errno)))\n", stderr) + close(serverFd) + return + } + + fputs("vsock bridge: listening on \(udsPath)\n", stderr) + + // Store device reference for use in background accept loop. + _vsockDeviceRef = device + + // Accept loop on a background queue. + let acceptQueue = DispatchQueue(label: "vsock-accept", attributes: .concurrent) + acceptQueue.async { + while true { + let clientFd = Darwin.accept(serverFd, nil, nil) + guard clientFd >= 0 else { + if errno == EINTR { continue } + fputs("warning: vsock bridge: accept failed: \(String(cString: strerror(errno)))\n", stderr) + break + } + + guard let dev = _vsockDeviceRef else { close(clientFd); break } + + // Connect to the guest on the default vsock port (1024). + dev.connect(toPort: 1024) { result in + switch result { + case .success(let connection): + fputs("vsock bridge: connected to guest port 1024\n", stderr) + bridgeConnection(clientFd: clientFd, vsockConnection: connection) + case .failure(let error): + fputs("warning: vsock bridge: guest connect failed: \(error.localizedDescription)\n", stderr) + close(clientFd) + } + } + } + } + + // Listen for guest-initiated connections on port 1024. + let listenerDelegate = VsockListenerDelegate(udsPath: udsPath) + let listener = VZVirtioSocketListener() + listener.delegate = listenerDelegate + device.setSocketListener(listener, forPort: 1024) + _vsockListenerDelegateRef = listenerDelegate + _vsockListenerObjRef = listener + + fputs("vsock bridge: listening for guest connections on port 1024\n", stderr) +} + +/// Copy data from one file descriptor to another until EOF or error. +/// Returns when the source fd is closed or an error occurs. +func copyFd(from srcFd: Int32, to dstFd: Int32) { + let bufSize = 16384 + let buf = UnsafeMutableRawPointer.allocate(byteCount: bufSize, alignment: 1) + defer { buf.deallocate() } + + while true { + let n = read(srcFd, buf, bufSize) + if n <= 0 { break } + var written = 0 + while written < n { + let w = write(dstFd, buf + written, n - written) + if w <= 0 { return } + written += w + } + } +} + +/// Bridge data between a UDS file descriptor and a vsock connection. +/// Runs two concurrent copy loops (one per direction) until either side closes. +func bridgeConnection(clientFd: Int32, vsockConnection: VZVirtioSocketConnection) { + let vsockFd = vsockConnection.fileDescriptor + + // client → guest + DispatchQueue.global().async { + copyFd(from: clientFd, to: vsockFd) + shutdown(vsockFd, SHUT_WR) + } + + // guest → client + DispatchQueue.global().async { + copyFd(from: vsockFd, to: clientFd) + close(clientFd) + } +} + +/// Vsock listener delegate that accepts guest-initiated connections. +/// Bridges each guest connection to a new UDS connection. +class VsockListenerDelegate: NSObject, VZVirtioSocketListenerDelegate { + let udsPath: String + + init(udsPath: String) { + self.udsPath = udsPath + } + + func listener( + _ listener: VZVirtioSocketListener, + shouldAcceptNewConnection connection: VZVirtioSocketConnection, + from socketDevice: VZVirtioSocketDevice + ) -> Bool { + fputs("vsock bridge: guest connected on port 1024\n", stderr) + + // Connect to the host-side UDS so Thermite sees the guest connection. + let clientFd = socket(AF_UNIX, SOCK_STREAM, 0) + guard clientFd >= 0 else { + fputs("warning: vsock bridge: failed to create UDS client socket\n", stderr) + return false + } + + guard var addr = makeSockaddrUn(path: udsPath) else { + fputs("warning: vsock bridge: UDS path too long\n", stderr) + close(clientFd) + return false + } + + let connectResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + Darwin.connect(clientFd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + + if connectResult != 0 { + fputs("warning: vsock bridge: UDS connect failed: \(String(cString: strerror(errno)))\n", stderr) + close(clientFd) + return false + } + + bridgeConnection(clientFd: clientFd, vsockConnection: connection) + return true + } +} + +nonisolated(unsafe) var _vsockDeviceRef: VZVirtioSocketDevice? +nonisolated(unsafe) var _vsockListenerDelegateRef: VsockListenerDelegate? +nonisolated(unsafe) var _vsockListenerObjRef: VZVirtioSocketListener? + // MARK: - VM Delegate /// Handles VM lifecycle events. Exits the process when the guest stops. diff --git a/src/cli/vm.rs b/src/cli/vm.rs index 83ea157..bff0c0e 100644 --- a/src/cli/vm.rs +++ b/src/cli/vm.rs @@ -106,6 +106,10 @@ pub struct CreateArgs { #[arg(long = "vm-config")] pub vm_config: Option, + /// Enable vsock device for host-guest communication + #[arg(long)] + pub vsock: bool, + /// Don't start the VM after creation #[arg(long)] pub no_start: bool, @@ -119,8 +123,13 @@ pub struct StartArgs { #[derive(Args)] pub struct StopArgs { - /// VM name - pub name: String, + /// VM name (required unless --all is used) + #[arg(required_unless_present = "all")] + pub name: Option, + + /// Stop all running VMs + #[arg(long, conflicts_with = "name")] + pub all: bool, /// Force stop (SIGKILL) #[arg(long)] @@ -181,8 +190,13 @@ pub struct UpdateConfigArgs { #[derive(Args)] pub struct DeleteArgs { - /// VM name - pub name: String, + /// VM name (required unless --all is used) + #[arg(required_unless_present = "all")] + pub name: Option, + + /// Delete all VMs + #[arg(long, conflicts_with = "name")] + pub all: bool, /// Force delete (kill if running) #[arg(long)] @@ -224,6 +238,10 @@ pub struct ForkArgs { #[arg(long)] pub network: Option, + /// Enable vsock device for host-guest communication + #[arg(long)] + pub vsock: bool, + /// Don't start the VM after forking #[arg(long)] pub no_start: bool, @@ -285,6 +303,23 @@ struct ResolvedVmCreate { ssh_user: Option, /// SSH private key override from YAML config. ssh_key: Option, + /// Whether vsock is enabled for this VM. + vsock: bool, +} + +impl ResolvedVmCreate { + /// Build a `VsockInfo` if vsock is enabled, using the given state store + /// to derive the UDS path. + fn vsock_info(&self, store: &StateStore) -> Option { + if self.vsock { + Some(vm::VsockInfo { + uds_path: store.vm_dir(&self.name).join("vsock.sock"), + guest_cid: 3, + }) + } else { + None + } + } } /// Resolve VM creation config by merging defaults, YAML config, and CLI flags. @@ -344,6 +379,8 @@ fn resolve_create_config( .and_then(|s| s.key.as_ref().map(|p| config::vm::expand_tilde(p))) }); + let vsock = args.vsock || yaml.and_then(|c| c.vsock).unwrap_or(false); + Ok(ResolvedVmCreate { name: args.name.clone(), image, @@ -356,6 +393,7 @@ fn resolve_create_config( no_start: args.no_start, ssh_user, ssh_key, + vsock, }) } @@ -547,6 +585,7 @@ fn create_post_clone( key: ssh_key, }, parent_vm: None, + vsock: resolved.vsock_info(store), }; vm::save(store, &metadata)?; @@ -666,6 +705,17 @@ fn fork(args: &ForkArgs, state_dir: &Path) -> anyhow::Result<()> { created_at: vm::now_iso8601(), ssh: source.ssh.clone(), parent_vm: Some(args.source.clone()), + vsock: if args.vsock { + Some(vm::VsockInfo { + uds_path: store.vm_dir(&args.name).join("vsock.sock"), + guest_cid: 3, + }) + } else { + source.vsock.as_ref().map(|_| vm::VsockInfo { + uds_path: store.vm_dir(&args.name).join("vsock.sock"), + guest_cid: 3, + }) + }, }; vm::save(&store, &metadata)?; @@ -777,15 +827,20 @@ fn start(args: &StartArgs, state_dir: &Path) -> anyhow::Result<()> { /// if --force) → wait for exit → SIGKILL if still alive → clean up network + /// socket → update metadata. fn stop(args: &StopArgs, state_dir: &Path) -> anyhow::Result<()> { + if args.all { + return stop_all(args.force, state_dir); + } + + let name = args.name.as_deref().unwrap(); let store = StateStore::new(state_dir.to_path_buf()); // Load and validate VM state. - let mut metadata = vm::load(&store, &args.name)?; + let mut metadata = vm::load(&store, name)?; match metadata.status { VmStatus::Running | VmStatus::Paused => {} _ => { return Err(Error::VmWrongState { - name: args.name.clone(), + name: name.to_string(), actual: metadata.status.to_string(), expected: "running or paused".to_string(), } @@ -797,9 +852,9 @@ fn stop(args: &StopArgs, state_dir: &Path) -> anyhow::Result<()> { anyhow::anyhow!( "vm '{}' is {} but has no PID — state may be corrupted\n\ Hint: try 'ember vm delete --force {}' and recreate the VM", - args.name, + name, metadata.status, - args.name + name ) })?; @@ -810,7 +865,7 @@ fn stop(args: &StopArgs, state_dir: &Path) -> anyhow::Result<()> { println!("Force-stopping VM (pid {pid})..."); Vm::force_stop(&metadata)?; } else { - println!("Stopping VM '{}'...", args.name); + println!("Stopping VM '{}'...", name); Vm::stop(&metadata)?; } @@ -824,7 +879,36 @@ fn stop(args: &StopArgs, state_dir: &Path) -> anyhow::Result<()> { metadata.network = None; vm::save(&store, &metadata)?; - println!("VM '{}' stopped.", args.name); + println!("VM '{}' stopped.", name); + Ok(()) +} + +/// Stop all running/paused VMs. +fn stop_all(force: bool, state_dir: &Path) -> anyhow::Result<()> { + let store = StateStore::new(state_dir.to_path_buf()); + let vms = vm::list(&store)?; + let targets: Vec<_> = vms + .iter() + .filter(|v| matches!(v.status, VmStatus::Running | VmStatus::Paused)) + .collect(); + + if targets.is_empty() { + println!("No running VMs to stop."); + return Ok(()); + } + + println!("Stopping {} VMs...", targets.len()); + for metadata in &targets { + let stop_args = StopArgs { + name: Some(metadata.name.clone()), + all: false, + force, + }; + if let Err(e) = stop(&stop_args, state_dir) { + eprintln!("warning: failed to stop '{}': {}", metadata.name, e); + } + } + Ok(()) } @@ -1025,16 +1109,21 @@ fn update_config(args: &UpdateConfigArgs, state_dir: &Path) -> anyhow::Result<() /// /// Each cleanup step is idempotent — continues if the resource is already gone. fn delete(args: &DeleteArgs, state_dir: &Path) -> anyhow::Result<()> { + if args.all { + return delete_all(args.force, state_dir); + } + + let name = args.name.as_deref().unwrap(); let store = StateStore::new(state_dir.to_path_buf()); // Load VM metadata (must exist). - let metadata = vm::load(&store, &args.name)?; + let metadata = vm::load(&store, name)?; // If the VM is running or paused, require --force. if matches!(metadata.status, VmStatus::Running | VmStatus::Paused) && !args.force { anyhow::bail!( "vm '{}' is {} — stop it first or use --force", - args.name, + name, metadata.status ); } @@ -1043,13 +1132,13 @@ fn delete(args: &DeleteArgs, state_dir: &Path) -> anyhow::Result<()> { // On macOS/APFS this always returns empty — forks are independent. let config: GlobalConfig = store.read(&store.config_path())?; let storage = Storage::new(&config); - let dependents = storage.storage_dependents(&args.name)?; + let dependents = storage.storage_dependents(name)?; if !dependents.is_empty() { if !args.force { anyhow::bail!( "vm '{}' has dependent forks: {}\n\ Delete them first, or use --force to cascade-delete all dependents.", - args.name, + name, dependents.join(", ") ); } @@ -1066,6 +1155,40 @@ fn delete(args: &DeleteArgs, state_dir: &Path) -> anyhow::Result<()> { Ok(()) } +/// Delete all VMs. +fn delete_all(force: bool, state_dir: &Path) -> anyhow::Result<()> { + let store = StateStore::new(state_dir.to_path_buf()); + let vms = vm::list(&store)?; + + if vms.is_empty() { + println!("No VMs to delete."); + return Ok(()); + } + + if !force { + let running = vms + .iter() + .any(|v| matches!(v.status, VmStatus::Running | VmStatus::Paused)); + if running { + anyhow::bail!("some VMs are still running — use --force to stop and delete them"); + } + } + + println!("Deleting {} VMs...", vms.len()); + for metadata in &vms { + let delete_args = DeleteArgs { + name: Some(metadata.name.clone()), + all: false, + force, + }; + if let Err(e) = delete(&delete_args, state_dir) { + eprintln!("warning: failed to delete '{}': {}", metadata.name, e); + } + } + + Ok(()) +} + /// Force-delete a VM: kill process, clean up network, destroy storage, remove state. /// /// Idempotent — each cleanup step continues if the resource is already gone. @@ -1206,6 +1329,12 @@ fn inspect(args: &InspectArgs, state_dir: &Path) -> anyhow::Result<()> { } } + if let Some(ref vsock) = metadata.vsock { + println!("Vsock:"); + println!(" UDS path: {}", vsock.uds_path.display()); + println!(" Guest CID: {}", vsock.guest_cid); + } + println!("SSH:"); println!(" User: {}", metadata.ssh.user); println!(" Key: {}", metadata.ssh.key.display());