diff --git a/.gitignore b/.gitignore index 6408f85..8d5dea6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,12 @@ /target /crates/*/target +# Staged emberd binary for image builds (built with `make emberd-image`) +/images/emberd + +# Coverage +.coverage + # Swift build artifacts /ember-vz/.build /ember-vz/.swiftpm 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/error.rs b/crates/ember-core/src/error.rs index 39944e7..e6214eb 100644 --- a/crates/ember-core/src/error.rs +++ b/crates/ember-core/src/error.rs @@ -41,6 +41,10 @@ pub enum Error { #[error("image: {0}")] Image(String), + /// Vsock CID allocation error. + #[error("vsock: {0}")] + Vsock(String), + /// SSH connection or command error. #[error("ssh: {0}")] Ssh(String), diff --git a/crates/ember-core/src/state/mod.rs b/crates/ember-core/src/state/mod.rs index 5c547e9..d3c0a8e 100644 --- a/crates/ember-core/src/state/mod.rs +++ b/crates/ember-core/src/state/mod.rs @@ -1,2 +1,3 @@ pub mod store; pub mod vm; +pub mod vsock; diff --git a/crates/ember-core/src/state/store.rs b/crates/ember-core/src/state/store.rs index 91f3d11..cfbeef1 100644 --- a/crates/ember-core/src/state/store.rs +++ b/crates/ember-core/src/state/store.rs @@ -26,10 +26,13 @@ use crate::error::{Error, Result}; /// ├── vms/ /// │ └── / /// │ ├── vm.json +/// │ ├── vsock.sock /// │ ├── firecracker.sock /// │ ├── firecracker.log /// │ ├── console.log /// │ └── firecracker.pid +/// ├── vsock/ +/// │ └── cids.json /// └── network/ /// └── allocations.json /// ``` @@ -65,6 +68,7 @@ impl StateStore { self.kernel_dir(), self.root.join("images"), self.root.join("vms"), + self.root.join("vsock"), self.root.join("network"), ]; for dir in &dirs { @@ -101,6 +105,11 @@ impl StateStore { self.root.join("network").join("allocations.json") } + /// Path to vsock CID allocation tracking. + pub fn vsock_allocations_path(&self) -> PathBuf { + self.root.join("vsock").join("cids.json") + } + /// Path to the global config file. pub fn config_path(&self) -> PathBuf { self.root.join("config.json") @@ -149,8 +158,53 @@ impl StateStore { })?; } + let _lock = FileLock::exclusive(path)?; + self.write_inner(path, data) + } + + /// Atomically read, modify, and write a JSON file under a single + /// exclusive lock. + /// + /// If the file doesn't exist, `default` is used as the initial value. + /// The closure receives a mutable reference and returns a result. + /// The modified value is written back while the lock is still held, + /// preventing TOCTOU races between concurrent processes. + pub fn update(&self, path: &Path, default: T, f: F) -> Result + where + T: Serialize + DeserializeOwned, + F: FnOnce(&mut T) -> Result, + { + // Ensure parent directory exists. + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| Error::Io { + path: parent.to_path_buf(), + source: e, + })?; + } + + // Hold exclusive lock for the entire read-modify-write cycle. let _lock = FileLock::exclusive(path)?; + let mut value: T = match fs::read_to_string(path) { + Ok(contents) => serde_json::from_str(&contents)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => default, + Err(e) => { + return Err(Error::Io { + path: path.to_path_buf(), + source: e, + }) + } + }; + + let result = f(&mut value)?; + self.write_inner(path, &value)?; + Ok(result) + } + + /// Write data to a JSON file atomically (temp file + rename). + /// + /// Caller must hold the appropriate lock. + fn write_inner(&self, path: &Path, data: &T) -> Result<()> { // Write to temp file in the same directory (same filesystem for rename). let tmp_path = tmp_path_for(path); let json = serde_json::to_string_pretty(data)?; @@ -354,6 +408,7 @@ mod tests { assert!(root.join("kernels").is_dir()); assert!(root.join("images").is_dir()); assert!(root.join("vms").is_dir()); + assert!(root.join("vsock").is_dir()); assert!(root.join("network").is_dir()); } @@ -422,6 +477,10 @@ mod tests { store.network_allocations_path(), PathBuf::from("/var/lib/ember/network/allocations.json") ); + assert_eq!( + store.vsock_allocations_path(), + PathBuf::from("/var/lib/ember/vsock/cids.json") + ); assert_eq!( store.config_path(), PathBuf::from("/var/lib/ember/config.json") 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-core/src/state/vsock.rs b/crates/ember-core/src/state/vsock.rs new file mode 100644 index 0000000..db028f4 --- /dev/null +++ b/crates/ember-core/src/state/vsock.rs @@ -0,0 +1,156 @@ +//! Vsock CID allocation for VMs. +//! +//! Each VM with vsock enabled needs a unique guest CID (Context Identifier). +//! CIDs 0–2 are reserved (0 = hypervisor, 1 = reserved, 2 = host). +//! Allocations start at CID 3 and increment sequentially. +//! +//! Allocations are tracked in `vsock/cids.json` via the state store +//! with flock-based locking for concurrent safety. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, Result}; +use crate::state::store::StateStore; + +/// First allocatable guest CID (0–2 are reserved). +const MIN_CID: u32 = 3; + +/// Maximum guest CID. The vsock CID space is 32 bits, but we cap at a +/// reasonable limit. Firecracker and AVF both use u32 CIDs. +const MAX_CID: u32 = 0xFFFF_FFFE; // 2^32 - 2 (0xFFFFFFFF is reserved) + +/// Persisted CID allocation state, stored as `vsock/cids.json`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct CidAllocations { + /// Map from CID to VM name. + pub allocations: HashMap, +} + +/// Allocate a unique guest CID for a VM. +/// +/// Finds the lowest available CID starting at 3, records the allocation, +/// and persists it to the state store. The entire read-modify-write cycle +/// is performed under an exclusive file lock, preventing concurrent +/// processes from allocating the same CID. +pub fn allocate(store: &StateStore, vm_name: &str) -> Result { + let path = store.vsock_allocations_path(); + let vm = vm_name.to_string(); + + store.update(&path, CidAllocations::default(), |allocs| { + let cid = (MIN_CID..=MAX_CID) + .find(|c| !allocs.allocations.contains_key(c)) + .ok_or_else(|| Error::Vsock("no free CIDs available".to_string()))?; + + allocs.allocations.insert(cid, vm); + Ok(cid) + }) +} + +/// Release a VM's CID allocation. +/// +/// Removes all allocation entries for the given VM name, making the CID +/// available for reuse. Uses an exclusive lock for consistency. +/// Idempotent — does nothing if the VM has no allocation or the +/// allocations file doesn't exist. +pub fn release(store: &StateStore, vm_name: &str) -> Result<()> { + let path = store.vsock_allocations_path(); + + // If no allocations file exists yet, nothing to release. + if !path.exists() { + return Ok(()); + } + + store.update(&path, CidAllocations::default(), |allocs| { + allocs.allocations.retain(|_, name| name != vm_name); + Ok(()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_store() -> (tempfile::TempDir, StateStore) { + let dir = tempfile::tempdir().unwrap(); + let store = StateStore::new(dir.path().to_path_buf()); + store.init().unwrap(); + (dir, store) + } + + #[test] + fn allocate_starts_at_3() { + let (_dir, store) = test_store(); + let cid = allocate(&store, "vm1").unwrap(); + assert_eq!(cid, 3); + } + + #[test] + fn allocate_sequential() { + let (_dir, store) = test_store(); + let c1 = allocate(&store, "vm1").unwrap(); + let c2 = allocate(&store, "vm2").unwrap(); + let c3 = allocate(&store, "vm3").unwrap(); + assert_eq!(c1, 3); + assert_eq!(c2, 4); + assert_eq!(c3, 5); + } + + #[test] + fn allocate_reuses_released_cid() { + let (_dir, store) = test_store(); + allocate(&store, "vm1").unwrap(); + allocate(&store, "vm2").unwrap(); + allocate(&store, "vm3").unwrap(); + + // Release the middle one (CID 4). + release(&store, "vm2").unwrap(); + + // Next allocation should reuse CID 4. + let c4 = allocate(&store, "vm4").unwrap(); + assert_eq!(c4, 4); + } + + #[test] + fn release_idempotent() { + let (_dir, store) = test_store(); + // Release with no allocations file at all. + release(&store, "nonexistent").unwrap(); + + // Allocate then release twice. + allocate(&store, "vm1").unwrap(); + release(&store, "vm1").unwrap(); + release(&store, "vm1").unwrap(); + } + + #[test] + fn release_only_removes_target_vm() { + let (_dir, store) = test_store(); + allocate(&store, "vm1").unwrap(); + allocate(&store, "vm2").unwrap(); + allocate(&store, "vm3").unwrap(); + + release(&store, "vm2").unwrap(); + + // vm1 and vm3 should still be allocated. + let path = store.vsock_allocations_path(); + let allocs: CidAllocations = store.read(&path).unwrap(); + assert_eq!(allocs.allocations.len(), 2); + assert_eq!(allocs.allocations[&3], "vm1"); + assert_eq!(allocs.allocations[&5], "vm3"); + } + + #[test] + fn allocations_persist_across_reads() { + let (_dir, store) = test_store(); + allocate(&store, "vm1").unwrap(); + allocate(&store, "vm2").unwrap(); + + let path = store.vsock_allocations_path(); + let allocs: CidAllocations = store.read(&path).unwrap(); + assert_eq!(allocs.allocations.len(), 2); + assert_eq!(allocs.allocations[&3], "vm1"); + assert_eq!(allocs.allocations[&4], "vm2"); + } +} 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..18e60d1 100644 --- a/crates/ember-macos/src/vm.rs +++ b/crates/ember-macos/src/vm.rs @@ -125,11 +125,17 @@ impl VmBackend for MacosVm { .arg("--ready-fd") .arg(write_fd_num.to_string()); - // Redirect stdout/stderr to the serial log / null so the helper - // doesn't interfere with ember's terminal output. + // 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 to null, stderr to a log file for debugging. + let stderr_log = std::fs::File::create(vm_dir.join("ember-vz.log")) + .unwrap_or_else(|_| std::fs::File::create("/dev/null").unwrap()); cmd.stdin(Stdio::null()); cmd.stdout(Stdio::null()); - cmd.stderr(Stdio::null()); + cmd.stderr(Stdio::from(stderr_log)); // SAFETY: pre_exec runs between fork and exec. We clear the // close-on-exec flag on the write fd so ember-vz inherits it. diff --git a/ember-vz/Sources/EmberVZ/Start.swift b/ember-vz/Sources/EmberVZ/Start.swift index 6b2f6eb..222d4b1 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,245 @@ 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 + + // Keep server fd alive for the process lifetime. + _vsockServerFdRef = serverFd + + // 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 (errno \(errno)): \(String(cString: strerror(errno)))\n", stderr) + break + } + + guard let dev = _vsockDeviceRef else { + fputs("warning: vsock bridge: device ref lost, exiting accept loop\n", stderr) + close(clientFd) + break + } + + // Connect to the guest on the default vsock port (1024). + // VZVirtioSocketDevice must be used from the main queue. + fputs("vsock bridge: accepted client, connecting to guest port 1024...\n", stderr) + DispatchQueue.main.async { + 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, label: String = "") { + 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 { + let err = String(cString: strerror(errno)) + fputs("vsock bridge: \(label) read error: \(err)\n", stderr) + break + } + if n == 0 { break } // EOF + var written = 0 + while written < n { + let w = write(dstFd, buf + written, n - written) + if w <= 0 { + let err = String(cString: strerror(errno)) + fputs("vsock bridge: \(label) write error: \(err)\n", stderr) + 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. +/// +/// IMPORTANT: We must hold a strong reference to `vsockConnection` for the +/// lifetime of the bridge. If ARC deallocates it, the underlying fd is closed +/// and the bridge silently fails with empty reads. +func bridgeConnection(clientFd: Int32, vsockConnection: VZVirtioSocketConnection) { + let vsockFd = vsockConnection.fileDescriptor + fputs("vsock bridge: bridging client fd \(clientFd) <-> vsock fd \(vsockFd)\n", stderr) + + // Hold a strong ref so ARC doesn't close the fd while copyFd is running. + let connectionRef = vsockConnection + + let group = DispatchGroup() + + // client → guest + group.enter() + DispatchQueue.global().async { + copyFd(from: clientFd, to: vsockFd, label: "client→guest") + shutdown(vsockFd, SHUT_WR) + group.leave() + } + + // guest → client + group.enter() + DispatchQueue.global().async { + copyFd(from: vsockFd, to: clientFd, label: "guest→client") + close(clientFd) + group.leave() + } + + // Release the connection reference only after both copy loops finish. + DispatchQueue.global().async { + group.wait() + fputs("vsock bridge: connection closed\n", stderr) + _keepAlive(connectionRef) + } +} + +/// Prevent the compiler from optimizing away a strong reference. +@inline(never) +func _keepAlive(_ obj: AnyObject) { + withExtendedLifetime(obj) {} +} + +/// 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 _vsockServerFdRef: Int32 = -1 +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..149a384 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, @@ -224,6 +228,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 +293,44 @@ struct ResolvedVmCreate { ssh_user: Option, /// SSH private key override from YAML config. ssh_key: Option, + /// Whether vsock is enabled for this VM. + vsock: bool, +} + +/// Maximum Unix domain socket path length. +/// macOS: 104 bytes, Linux: 108 bytes. Use the smaller to be safe. +const MAX_UDS_PATH_LEN: usize = 104; + +impl ResolvedVmCreate { + /// Build a `VsockInfo` if vsock is enabled, allocating a unique CID. + fn vsock_info(&self, store: &StateStore) -> anyhow::Result> { + if self.vsock { + let uds_path = store.vm_dir(&self.name).join("vsock.sock"); + validate_uds_path(&uds_path)?; + let cid = ember_core::state::vsock::allocate(store, &self.name)?; + Ok(Some(vm::VsockInfo { + uds_path, + guest_cid: cid, + })) + } else { + Ok(None) + } + } +} + +/// Validate that a UDS path doesn't exceed the OS limit for `sockaddr_un.sun_path`. +fn validate_uds_path(path: &Path) -> anyhow::Result<()> { + let path_str = path.to_string_lossy(); + if path_str.len() >= MAX_UDS_PATH_LEN { + anyhow::bail!( + "vsock UDS path is too long ({} bytes, max {}):\n {}\n\ + Hint: use a shorter --state-dir or VM name", + path_str.len(), + MAX_UDS_PATH_LEN - 1, + path_str, + ); + } + Ok(()) } /// Resolve VM creation config by merging defaults, YAML config, and CLI flags. @@ -344,6 +390,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 +404,7 @@ fn resolve_create_config( no_start: args.no_start, ssh_user, ssh_key, + vsock, }) } @@ -547,6 +596,7 @@ fn create_post_clone( key: ssh_key, }, parent_vm: None, + vsock: resolved.vsock_info(store)?, }; vm::save(store, &metadata)?; @@ -666,6 +716,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 || source.vsock.is_some() { + let uds_path = store.vm_dir(&args.name).join("vsock.sock"); + validate_uds_path(&uds_path)?; + let cid = ember_core::state::vsock::allocate(&store, &args.name)?; + Some(vm::VsockInfo { + uds_path, + guest_cid: cid, + }) + } else { + None + }, }; vm::save(&store, &metadata)?; @@ -1097,6 +1158,11 @@ pub fn force_delete_vm(store: &StateStore, metadata: &VmMetadata) -> anyhow::Res } } + // Release vsock CID if one was allocated. + if metadata.vsock.is_some() { + let _ = ember_core::state::vsock::release(store, &metadata.name); + } + // Clean up networking via the backend. let net_backend = Network::new(store.clone()); let _ = net_backend.teardown(metadata); @@ -1206,6 +1272,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()); @@ -1214,3 +1286,33 @@ fn inspect(args: &InspectArgs, state_dir: &Path) -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_uds_path_short_ok() { + let path = Path::new("/tmp/ember/vms/myvm/vsock.sock"); + assert!(validate_uds_path(path).is_ok()); + } + + #[test] + fn validate_uds_path_at_limit_fails() { + // Build a path exactly at the limit (104 bytes). + let long_name = "x".repeat(MAX_UDS_PATH_LEN); + let path = PathBuf::from(long_name); + assert!(validate_uds_path(&path).is_err()); + } + + #[test] + fn validate_uds_path_over_limit_fails() { + let long_name = "x".repeat(MAX_UDS_PATH_LEN + 50); + let path = PathBuf::from(long_name); + let err = validate_uds_path(&path).unwrap_err(); + assert!( + err.to_string().contains("too long"), + "error should mention 'too long': {err}" + ); + } +} diff --git a/tests/vsock.rs b/tests/vsock.rs new file mode 100644 index 0000000..17367bf --- /dev/null +++ b/tests/vsock.rs @@ -0,0 +1,497 @@ +//! Integration tests for vsock support. +//! +//! Tests verify CID allocation, UDS path creation, inspect output, +//! and end-to-end vsock connectivity on both platforms. +//! +//! Cross-platform tests (no hypervisor needed) use `TestEnv::with_vm()`. +//! Platform-specific tests require a running VM. +//! +//! To run: +//! cargo test --test vsock -- --ignored + +#[allow(dead_code)] +mod common; + +use std::os::unix::net::UnixStream; +use std::time::Duration; + +// --------------------------------------------------------------------------- +// Cross-platform tests (no hypervisor needed) +// --------------------------------------------------------------------------- + +/// Create a VM with --vsock, verify CID is allocated and inspect shows vsock info. +#[test] +#[ignore] +fn vsock_create_shows_in_inspect() { + let env = common::TestEnv::with_image("vsock_inspect"); + let state = env.state(); + + // Create a dummy kernel. + let kernel = env.state_dir.parent().unwrap().join("vmlinux-dummy"); + std::fs::write(&kernel, b"not a real kernel").unwrap(); + + let output = common::ember(&[ + "--state-dir", + state, + "vm", + "create", + "vsockvm", + "--image", + "alpine:latest", + "--kernel", + kernel.to_str().unwrap(), + "--vsock", + "--no-start", + ]); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "vm create --vsock failed.\nstderr: {stderr}" + ); + + // Verify JSON inspect contains vsock info. + let output = common::ember(&[ + "--state-dir", + state, + "vm", + "inspect", + "vsockvm", + "--format", + "json", + ]); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + + let parsed: serde_json::Value = serde_json::from_str(&stdout) + .unwrap_or_else(|e| panic!("invalid JSON: {e}\noutput: {stdout}")); + + // vsock field must be present with uds_path and guest_cid. + let vsock = &parsed["vsock"]; + assert!( + !vsock.is_null(), + "expected vsock field in inspect output: {stdout}" + ); + let uds_path = vsock["uds_path"].as_str().unwrap(); + assert!( + uds_path.ends_with("/vsock.sock"), + "unexpected uds_path: {uds_path}" + ); + assert!( + uds_path.contains("/vms/vsockvm/"), + "uds_path should contain VM name: {uds_path}" + ); + + // CID should be >= 3 (0-2 are reserved). + let cid = vsock["guest_cid"].as_u64().unwrap(); + assert!(cid >= 3, "guest_cid should be >= 3, got {cid}"); + + // Verify table-format inspect also shows vsock. + let output = common::ember(&["--state-dir", state, "vm", "inspect", "vsockvm"]); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + assert!( + stdout.contains("Vsock:"), + "table inspect should show Vsock section: {stdout}" + ); + assert!( + stdout.contains("UDS path:"), + "table inspect should show UDS path: {stdout}" + ); + assert!( + stdout.contains("Guest CID:"), + "table inspect should show Guest CID: {stdout}" + ); +} + +/// Multiple VMs with --vsock get unique CIDs. +#[test] +#[ignore] +fn vsock_unique_cids() { + let env = common::TestEnv::with_image("vsock_cids"); + let state = env.state(); + + let kernel = env.state_dir.parent().unwrap().join("vmlinux-dummy"); + std::fs::write(&kernel, b"not a real kernel").unwrap(); + + // Create three VMs with vsock. + for name in &["vm1", "vm2", "vm3"] { + let output = common::ember(&[ + "--state-dir", + state, + "vm", + "create", + name, + "--image", + "alpine:latest", + "--kernel", + kernel.to_str().unwrap(), + "--vsock", + "--no-start", + ]); + assert!( + output.status.success(), + "vm create {} failed: {}", + name, + String::from_utf8_lossy(&output.stderr) + ); + } + + // Collect CIDs from all three VMs. + let mut cids = Vec::new(); + for name in &["vm1", "vm2", "vm3"] { + let output = common::ember(&[ + "--state-dir", + state, + "vm", + "inspect", + name, + "--format", + "json", + ]); + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let cid = parsed["vsock"]["guest_cid"].as_u64().unwrap(); + cids.push(cid); + } + + // All CIDs should be unique. + assert_eq!(cids[0], 3, "first VM should get CID 3"); + assert_eq!(cids[1], 4, "second VM should get CID 4"); + assert_eq!(cids[2], 5, "third VM should get CID 5"); +} + +/// Deleting a VM with vsock frees its CID for reuse. +#[test] +#[ignore] +fn vsock_cid_reuse_after_delete() { + let env = common::TestEnv::with_image("vsock_reuse"); + let state = env.state(); + + let kernel = env.state_dir.parent().unwrap().join("vmlinux-dummy"); + std::fs::write(&kernel, b"not a real kernel").unwrap(); + + // Create two VMs. + for name in &["vm1", "vm2"] { + let output = common::ember(&[ + "--state-dir", + state, + "vm", + "create", + name, + "--image", + "alpine:latest", + "--kernel", + kernel.to_str().unwrap(), + "--vsock", + "--no-start", + ]); + assert!(output.status.success()); + } + + // Delete vm1 (CID 3). + let output = common::ember(&["--state-dir", state, "vm", "delete", "vm1"]); + assert!(output.status.success()); + + // Create vm3 — should reuse CID 3. + let output = common::ember(&[ + "--state-dir", + state, + "vm", + "create", + "vm3", + "--image", + "alpine:latest", + "--kernel", + kernel.to_str().unwrap(), + "--vsock", + "--no-start", + ]); + assert!(output.status.success()); + + let output = common::ember(&[ + "--state-dir", + state, + "vm", + "inspect", + "vm3", + "--format", + "json", + ]); + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let cid = parsed["vsock"]["guest_cid"].as_u64().unwrap(); + assert_eq!(cid, 3, "vm3 should reuse freed CID 3, got {cid}"); +} + +/// VM without --vsock should have no vsock in inspect. +#[test] +#[ignore] +fn vsock_not_present_without_flag() { + let env = common::TestEnv::with_vm("vsock_none", "plainvm"); + let state = env.state(); + + let output = common::ember(&[ + "--state-dir", + state, + "vm", + "inspect", + "plainvm", + "--format", + "json", + ]); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!( + parsed.get("vsock").is_none() || parsed["vsock"].is_null(), + "VM without --vsock should have no vsock field: {stdout}" + ); +} + +/// `vm list` shows vsock checkmark for VMs with vsock enabled. +#[test] +#[ignore] +fn vsock_list_shows_checkmark() { + let env = common::TestEnv::with_image("vsock_list"); + let state = env.state(); + + let kernel = env.state_dir.parent().unwrap().join("vmlinux-dummy"); + std::fs::write(&kernel, b"not a real kernel").unwrap(); + + // Create one VM with vsock and one without. + let output = common::ember(&[ + "--state-dir", + state, + "vm", + "create", + "with-vsock", + "--image", + "alpine:latest", + "--kernel", + kernel.to_str().unwrap(), + "--vsock", + "--no-start", + ]); + assert!(output.status.success()); + + let output = common::ember(&[ + "--state-dir", + state, + "vm", + "create", + "no-vsock", + "--image", + "alpine:latest", + "--kernel", + kernel.to_str().unwrap(), + "--no-start", + ]); + assert!(output.status.success()); + + let output = common::ember(&["--state-dir", state, "vm", "list"]); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(output.status.success()); + + // Find the lines for each VM. + let with_line = stdout + .lines() + .find(|l| l.contains("with-vsock")) + .expect("with-vsock not in list"); + let without_line = stdout + .lines() + .find(|l| l.contains("no-vsock")) + .expect("no-vsock not in list"); + + assert!( + with_line.contains('✓'), + "with-vsock should show ✓: {with_line}" + ); + assert!( + !without_line.contains('✓'), + "no-vsock should not show ✓: {without_line}" + ); +} + +// --------------------------------------------------------------------------- +// macOS end-to-end test (requires ember-vz + AVF) +// --------------------------------------------------------------------------- + +/// Boot a VM with --vsock, verify the UDS appears and accepts connections. +/// +/// This test: +/// 1. Creates and starts a VM with --vsock +/// 2. Verifies the UDS file exists at the expected path +/// 3. Verifies a host process can connect to the UDS +/// 4. Cleans up the VM +/// +/// Note: Full data exchange requires a vsock listener in the guest (emberd), +/// which is not yet implemented. This test verifies the host-side plumbing. +#[cfg(target_os = "macos")] +#[test] +#[ignore] +fn vsock_uds_accepts_connections_macos() { + let env = common::TestEnv::with_image("vsock_e2e_macos"); + let state = env.state(); + + let kernel = common::macos::ensure_kernel(); + + // Create VM with vsock. + let output = common::ember(&[ + "--state-dir", + state, + "vm", + "create", + "vsocktest", + "--image", + "alpine:latest", + "--kernel", + kernel.to_str().unwrap(), + "--cpus", + "1", + "--memory", + "256M", + "--vsock", + "--no-start", + ]); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "vm create failed.\nstderr: {stderr}" + ); + + // Get the UDS path from inspect before starting (it's in metadata). + let output = common::ember(&[ + "--state-dir", + state, + "vm", + "inspect", + "vsocktest", + "--format", + "json", + ]); + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let uds_path = parsed["vsock"]["uds_path"] + .as_str() + .expect("no uds_path in inspect") + .to_string(); + eprintln!("Expected UDS path: {uds_path}"); + + // Start the VM. + let output = common::ember(&["--state-dir", state, "vm", "start", "vsocktest"]); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "vm start failed.\nstderr: {stderr}" + ); + + // Give ember-vz time to set up the vsock bridge and UDS listener. + std::thread::sleep(Duration::from_secs(3)); + + // Verify the UDS file exists. + let uds = std::path::Path::new(&uds_path); + assert!(uds.exists(), "UDS file not found at {uds_path}"); + + // Verify we can connect to the UDS. + // ember-vz creates a UDS listener that accepts connections and bridges + // them to guest port 1024. The connect should succeed even if no guest + // listener is running (ember-vz accepts the connection, then the guest + // connect may fail — but the UDS connect itself should work). + let connect_result = UnixStream::connect(&uds_path); + eprintln!("UDS connect result: {connect_result:?}"); + assert!( + connect_result.is_ok(), + "failed to connect to vsock UDS at {uds_path}: {}", + connect_result.unwrap_err() + ); + + // Clean up. + common::stop_and_delete_vm(state, "vsocktest"); +} + +// --------------------------------------------------------------------------- +// Linux end-to-end test (requires Firecracker + KVM) +// --------------------------------------------------------------------------- + +/// Boot a VM with --vsock on Linux, verify the UDS appears. +/// +/// Firecracker creates the vsock UDS directly (unlike macOS where ember-vz +/// manages it). Verifies the PUT /vsock API call succeeds and the UDS exists. +#[cfg(target_os = "linux")] +#[test] +#[ignore] +fn vsock_uds_created_linux() { + let env = common::TestEnv::with_image("vsock_e2e_linux"); + let state = env.state(); + + common::linux::require_firecracker(); + let kernel = common::linux::ensure_kernel(); + + // Create VM with vsock. + let output = common::ember(&[ + "--state-dir", + state, + "vm", + "create", + "vsocktest", + "--image", + "alpine:latest", + "--kernel", + kernel.to_str().unwrap(), + "--cpus", + "1", + "--memory", + "128M", + "--vsock", + "--no-start", + ]); + assert!( + output.status.success(), + "vm create failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Get the UDS path from inspect. + let output = common::ember(&[ + "--state-dir", + state, + "vm", + "inspect", + "vsocktest", + "--format", + "json", + ]); + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let uds_path = parsed["vsock"]["uds_path"] + .as_str() + .expect("no uds_path in inspect") + .to_string(); + + // Start the VM (Firecracker creates the UDS via PUT /vsock). + let output = common::ember(&["--state-dir", state, "vm", "start", "vsocktest"]); + assert!( + output.status.success(), + "vm start failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Firecracker creates the UDS synchronously during boot. + std::thread::sleep(Duration::from_secs(2)); + + // Verify the UDS file exists. + let uds = std::path::Path::new(&uds_path); + assert!(uds.exists(), "UDS file not found at {uds_path}"); + + // Verify we can connect to the UDS. + let connect_result = UnixStream::connect(&uds_path); + assert!( + connect_result.is_ok(), + "failed to connect to vsock UDS at {uds_path}: {}", + connect_result.unwrap_err() + ); + + // Clean up. + common::stop_and_delete_vm(state, "vsocktest"); +}