diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5982d1..1c36c13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,10 @@ jobs: - name: Network switch tests run: cargo test --test network_switch --target ${{ matrix.target }} + - name: Apple VZ FFI smoke tests + if: runner.os == 'macOS' + run: cargo test --test ffi_smoke --target ${{ matrix.target }} + # ── OCI registry tests (requires internet) ───────────────────────── oci: name: OCI Pull diff --git a/CAPABILITIES.md b/CAPABILITIES.md index acdf056..a18727e 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -13,7 +13,7 @@ Status: experimental and under active development. This matrix reflects current | Boot VM | Supported | Supported | Kernel + initramfs + root disk | | Stop (graceful) | Supported | Supported | ACPI shutdown | | Kill (force) | Supported | Supported | Immediate termination | -| Query state | Supported | Supported | Starting → Running → Stopped / Failed | +| Query state | Supported | Supported | Starting → Running → Ready → Stopped / Failed | | Reboot | Not supported | Planned | CH: `vm.reboot` or `ch-remote reboot` | | Delete/cleanup | Supported | Supported | Remove VM state and resources | | **CPU** | | | | @@ -49,7 +49,7 @@ Status: experimental and under active development. This matrix reflects current | Read-only mounts | Supported | Planned | Immutable shared data | | **Serial Console** | | | | | File output | Supported | Supported | Log serial to file | -| Readiness detection | Supported | Supported | Parse `8STACK_READY` marker from console | +| Readiness detection | Supported | Supported | Parse `VMRS_READY ` marker from console | | **Entropy** | | | | | VirtIO RNG | Supported | Supported | /dev/random in guest | | **Advanced (API mode)** | | | | diff --git a/README.md b/README.md index 92ecbda..ac827f1 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,11 @@ let config = VmConfig { }; let handle = manager.start(&config)?; -// VM is booting — poll state until Running +// VM is booting — poll state until Ready let state = manager.state("my-vm")?; +if state.is_ready() { + eprintln!("guest IP = {}", state.ip().unwrap()); +} ``` ## Features @@ -118,7 +121,7 @@ Each platform driver is an in-process implementation optimized for its hyperviso ## Testing -156 tests across 8 test suites. CI runs on macOS (aarch64), Linux (x86_64), and Windows (x86_64). +The default CI runs on macOS and Linux. Real hypervisor lifecycle tests are available, but they are opt-in because they require signed binaries or test kernel assets. ```bash cargo test # everything @@ -127,6 +130,8 @@ cargo test --test vm_manager # VmManager with mock driver cargo test --test network_switch # L2 switch integration cargo test --test oci_pull # OCI registry (needs internet) cargo test --test ffi_smoke # Apple VZ FFI (macOS only) +cargo test --test vm_lifecycle -- --ignored + # real hypervisor lifecycle tests with assets ``` ## License diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index 18972fb..29f64eb 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -21,7 +21,7 @@ macOS via Apple Virtualization.framework, Linux via Cloud Hypervisor. | Boot VM | Verified | Verified | Direct Linux boot or UEFI | | Stop (graceful) | Verified | Verified | VZ: requestStopWithError. CH: SIGTERM (ACPI) | | Kill (force) | Verified | Verified | VZ: stopWithCompletionHandler. CH: SIGKILL | -| Query state | Verified | Verified | Starting → Running → Stopped / Failed | +| Query state | Verified | Verified | Starting → Running → Ready → Stopped / Failed | | Pause / Resume | Wired | Planned | VZ: wired + mock-tested. CH: needs API mode | | Save / Restore | FFI only | Planned | VZ: saveMachineStateTo/restoreMachineStateFrom (14+) | | Delete/cleanup | Verified | Verified | Registry removal / process reap | @@ -121,7 +121,7 @@ VZSpiceAgentPortAttachment. | `tests/seed_iso.rs` | 4 | Cloud-init ISO creation with hdiutil/genisoimage | | `tests/disk_clone.rs` | 4 | APFS/reflink CoW cloning | -**Total: 156 tests.** CI runs on both macOS and Linux. +The default CI runs on macOS and Linux. Real hypervisor lifecycle coverage is still opt-in because it requires signed binaries or external kernel/initramfs assets. ## Roadmap diff --git a/src/config.rs b/src/config.rs index c49b72d..eaf43a3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -210,8 +210,10 @@ pub struct VmHandle { pub enum VmState { /// VM is being created / booting. Starting, - /// VM is running and reachable. - Running { + /// VM is executing according to the hypervisor, but guest readiness is not confirmed yet. + Running, + /// VM is running and has reported readiness. + Ready { /// IP address assigned to the VM. ip: String, }, @@ -226,11 +228,32 @@ pub enum VmState { }, } +impl VmState { + /// Returns true when the hypervisor reports the VM as executing. + pub fn is_running(&self) -> bool { + matches!(self, Self::Running | Self::Ready { .. }) + } + + /// Returns true when the guest has emitted the readiness marker. + pub fn is_ready(&self) -> bool { + matches!(self, Self::Ready { .. }) + } + + /// Returns the guest IP address once readiness has been confirmed. + pub fn ip(&self) -> Option<&str> { + match self { + Self::Ready { ip } => Some(ip), + _ => None, + } + } +} + impl std::fmt::Display for VmState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { VmState::Starting => write!(f, "starting"), - VmState::Running { ip } => write!(f, "running ({})", ip), + VmState::Running => write!(f, "running"), + VmState::Ready { ip } => write!(f, "ready ({})", ip), VmState::Paused => write!(f, "paused"), VmState::Stopped => write!(f, "stopped"), VmState::Failed { reason } => write!(f, "failed: {}", reason), @@ -249,10 +272,15 @@ mod tests { #[test] fn vm_state_display_running() { - let state = VmState::Running { + let state = VmState::Ready { ip: "10.0.1.2".into(), }; - assert_eq!(state.to_string(), "running (10.0.1.2)"); + assert_eq!(state.to_string(), "ready (10.0.1.2)"); + } + + #[test] + fn vm_state_display_running_without_ready_ip() { + assert_eq!(VmState::Running.to_string(), "running"); } #[test] @@ -275,6 +303,19 @@ mod tests { assert_ne!(VmState::Starting, VmState::Stopped); } + #[test] + fn vm_state_helper_methods() { + let ready = VmState::Ready { + ip: "10.0.1.2".into(), + }; + assert!(VmState::Running.is_running()); + assert!(!VmState::Running.is_ready()); + assert!(ready.is_running()); + assert!(ready.is_ready()); + assert_eq!(ready.ip(), Some("10.0.1.2")); + assert_eq!(VmState::Starting.ip(), None); + } + #[test] fn ready_marker_value() { assert_eq!(READY_MARKER, "VMRS_READY"); diff --git a/src/driver/apple_vz.rs b/src/driver/apple_vz.rs index 812f43e..24d12bb 100644 --- a/src/driver/apple_vz.rs +++ b/src/driver/apple_vz.rs @@ -33,11 +33,16 @@ use crate::ffi::apple_vz::{ }; use crate::config::{NetworkAttachment, VmConfig, VmHandle, VmState}; -use crate::driver::{VmDriver, VmError}; +use crate::driver::{ReadyMarkerCache, VmDriver, VmError}; + +struct RegisteredVm { + vm: VZVirtualMachine, + ready: ReadyMarkerCache, +} /// Apple Virtualization.framework driver for macOS. pub struct AppleVzDriver { - vms: Mutex>, + vms: Mutex>, } impl AppleVzDriver { @@ -52,18 +57,17 @@ impl AppleVzDriver { VZVirtualMachine::supported() } - fn vm_state(handle: &VmHandle, vm: &VZVirtualMachine) -> VmState { + fn vm_state(vm: &VZVirtualMachine, ready_ip: Option) -> VmState { // SAFETY: VM references come from the driver's registry and stay alive // for the lifetime of the driver entry. - let ready_ip = super::check_ready_marker(&handle.serial_log); Self::map_native_state(unsafe { vm.state() }, ready_ip) } fn map_native_state(state: VZVirtualMachineState, ready_ip: Option) -> VmState { match state { VZVirtualMachineState::VZVirtualMachineStateRunning => match ready_ip { - Some(ip) => VmState::Running { ip }, - None => VmState::Starting, + Some(ip) => VmState::Ready { ip }, + None => VmState::Running, }, VZVirtualMachineState::VZVirtualMachineStatePaused | VZVirtualMachineState::VZVirtualMachineStatePausing @@ -269,7 +273,13 @@ impl VmDriver for AppleVzDriver { .vms .lock() .map_err(|e| VmError::Hypervisor(format!("VM registry lock poisoned: {}", e)))?; - registry.insert(name.to_string(), vm.clone()); + registry.insert( + name.to_string(), + RegisteredVm { + vm: vm.clone(), + ready: ReadyMarkerCache::default(), + }, + ); } // Start VM on its dispatch queue with synchronous error reporting via channel @@ -351,7 +361,7 @@ impl VmDriver for AppleVzDriver { .lock() .map_err(|e| VmError::Hypervisor(format!("VM registry lock poisoned: {}", e)))? .get(&handle.name) - .cloned() + .map(|entry| entry.vm.clone()) .ok_or_else(|| VmError::NotFound { name: handle.name.clone(), })?; @@ -373,7 +383,8 @@ impl VmDriver for AppleVzDriver { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); while std::time::Instant::now() < deadline { - match Self::vm_state(handle, &vm) { + let ready_ip = super::check_ready_marker(&handle.serial_log); + match Self::vm_state(&vm, ready_ip) { VmState::Stopped => { self.vms .lock() @@ -415,16 +426,19 @@ impl VmDriver for AppleVzDriver { } fn state(&self, handle: &VmHandle) -> Result { - let registry = self + let mut registry = self .vms .lock() .map_err(|e| VmError::Hypervisor(format!("VM registry lock poisoned: {}", e)))?; - let state = match registry.get(&handle.name) { - Some(vm) => Self::vm_state(handle, vm), + let state = match registry.get_mut(&handle.name) { + Some(entry) => { + let ready_ip = entry.ready.scan(&handle.serial_log); + Self::vm_state(&entry.vm, ready_ip) + } None => VmState::Stopped, }; - tracing::debug!(vm = %handle.name, state = %state, "Apple VZ VM state queried"); + tracing::debug!(driver = "apple_vz", vm = %handle.name, state = %state, "VM state queried"); Ok(state) } } @@ -485,7 +499,7 @@ mod tests { VZVirtualMachineState::VZVirtualMachineStateRunning, None, ), - VmState::Starting + VmState::Running ); } @@ -496,7 +510,7 @@ mod tests { VZVirtualMachineState::VZVirtualMachineStateRunning, Some("10.0.0.2".into()), ), - VmState::Running { + VmState::Ready { ip: "10.0.0.2".into(), } ); diff --git a/src/driver/cloud_hv.rs b/src/driver/cloud_hv.rs index 78aa132..3979ef0 100644 --- a/src/driver/cloud_hv.rs +++ b/src/driver/cloud_hv.rs @@ -12,7 +12,7 @@ use std::process::{Child, Command, Stdio}; use std::sync::Mutex; use crate::config::{NetworkAttachment, VmConfig, VmHandle, VmState, VmmProcess}; -use crate::driver::{VmDriver, VmError}; +use crate::driver::{ReadyMarkerCache, VmDriver, VmError}; // --------------------------------------------------------------------------- // Internal VM tracking @@ -26,8 +26,10 @@ struct VmProcess { child: Child, /// TAP device names (for cleanup on stop). tap_devices: Vec, - /// virtiofsd sidecar PIDs (for VirtioFS shared dirs, cleaned up on stop). - virtiofsd_pids: Vec, + /// virtiofsd sidecar child handles retained so they can be terminated and reaped. + virtiofsd_children: Vec, + /// Cached guest readiness marker from the serial log. + ready: ReadyMarkerCache, } /// Cloud Hypervisor driver for Linux. @@ -58,6 +60,7 @@ impl VmDriver for CloudHvDriver { let name = &config.name; tracing::info!( + driver = "cloud_hv", vm = %name, cpus = config.cpus, memory_mb = config.memory_mb, @@ -167,6 +170,7 @@ impl VmDriver for CloudHvDriver { })?; tracing::info!( + driver = "cloud_hv", vm = %name, tag = %vol.tag, pid = child.id(), @@ -178,9 +182,7 @@ impl VmDriver for CloudHvDriver { let socket_ready = wait_for_socket(&socket_path, std::time::Duration::from_secs(5)); if !socket_ready { // Clean up already-started virtiofsd processes - for mut c in virtiofsd_children { - let _ = c.kill(); - } + cleanup_virtiofsd(virtiofsd_children); return Err(VmError::BootFailed { name: name.clone(), detail: format!( @@ -200,36 +202,51 @@ impl VmDriver for CloudHvDriver { // Spawn — redirect stdout/stderr to a log file for debugging let vmm_log_path = config.serial_log.with_extension("vmm.log"); - let vmm_log = std::fs::File::create(&vmm_log_path).map_err(|e| VmError::BootFailed { - name: name.clone(), - detail: format!("failed to create VMM log file: {}", e), - })?; - let vmm_log_stderr = vmm_log.try_clone().map_err(VmError::Io)?; - let mut process = cmd - .stdout(vmm_log) - .stderr(vmm_log_stderr) - .spawn() - .map_err(|e| VmError::BootFailed { - name: name.clone(), - detail: format!("failed to spawn cloud-hypervisor: {}", e), - })?; + let vmm_log = match std::fs::File::create(&vmm_log_path) { + Ok(file) => file, + Err(e) => { + cleanup_virtiofsd(virtiofsd_children); + return Err(VmError::BootFailed { + name: name.clone(), + detail: format!("failed to create VMM log file: {}", e), + }); + } + }; + let vmm_log_stderr = match vmm_log.try_clone() { + Ok(file) => file, + Err(e) => { + cleanup_virtiofsd(virtiofsd_children); + return Err(VmError::Io(e)); + } + }; + let mut process = match cmd.stdout(vmm_log).stderr(vmm_log_stderr).spawn() { + Ok(child) => child, + Err(e) => { + cleanup_virtiofsd(virtiofsd_children); + return Err(VmError::BootFailed { + name: name.clone(), + detail: format!("failed to spawn cloud-hypervisor: {}", e), + }); + } + }; let pid = process.id(); - let identity = process_identity(pid, name)?; - tracing::info!(vm = %name, pid = pid, "Cloud Hypervisor process started"); - - // Collect virtiofsd PIDs for cleanup, then drop the Child handles. - // Dropping Child on Unix does NOT kill the process — it only closes our - // handle. The virtiofsd processes keep running and are killed via raw - // kill() in stop/cleanup using the saved PIDs. - let virtiofsd_pids: Vec = virtiofsd_children.iter().map(|c| c.id()).collect(); - drop(virtiofsd_children); + let identity = match process_identity(pid, name) { + Ok(identity) => identity, + Err(e) => { + let _ = process.kill(); + let _ = process.wait(); + cleanup_virtiofsd(virtiofsd_children); + return Err(e); + } + }; + tracing::info!(driver = "cloud_hv", vm = %name, pid = pid, "Cloud Hypervisor process started"); // Brief pause then check if process exited immediately (bad binary, permissions, etc.) std::thread::sleep(std::time::Duration::from_millis(100)); if let Some(status) = process.try_wait().map_err(VmError::Io)? { // Clean up virtiofsd processes since VM failed - cleanup_virtiofsd(&virtiofsd_pids); + cleanup_virtiofsd(virtiofsd_children); return Err(VmError::BootFailed { name: name.clone(), detail: format!( @@ -253,7 +270,8 @@ impl VmDriver for CloudHvDriver { child: process, identity: identity.clone(), tap_devices, - virtiofsd_pids, + virtiofsd_children, + ready: ReadyMarkerCache::default(), }, ); } @@ -269,7 +287,7 @@ impl VmDriver for CloudHvDriver { } fn stop(&self, handle: &VmHandle) -> Result<(), VmError> { - tracing::info!(vm = %handle.name, "requesting graceful stop via Cloud Hypervisor"); + tracing::info!(driver = "cloud_hv", vm = %handle.name, "requesting graceful stop via Cloud Hypervisor"); let mut vms = self .vms .lock() @@ -301,47 +319,58 @@ impl VmDriver for CloudHvDriver { // SIGTERM → Cloud Hypervisor handles graceful ACPI shutdown // SAFETY: Sending SIGTERM to a PID we spawned. PID validity confirmed by prior operations. let ret = unsafe { libc::kill(process.identity.pid() as i32, libc::SIGTERM) }; - if ret != 0 { + let wait_result = if ret != 0 { let errno = std::io::Error::last_os_error(); tracing::warn!( + driver = "cloud_hv", vm = %handle.name, pid = process.identity.pid(), error = %errno, "SIGTERM failed (process may already be stopped)" ); + Ok(()) } else { // Wait for process to exit (up to 10s) - wait_for_exit(process.child, std::time::Duration::from_secs(10)); - } + wait_for_exit(process.child, std::time::Duration::from_secs(10)).map_err(|e| { + VmError::StopFailed { + name: handle.name.clone(), + detail: format!( + "cloud-hypervisor PID {} did not exit cleanly: {}", + process.identity.pid(), + e + ), + } + }) + }; cleanup_taps(&process.tap_devices); - cleanup_virtiofsd(&process.virtiofsd_pids); - Ok(()) + cleanup_virtiofsd(process.virtiofsd_children); + wait_result } fn kill(&self, handle: &VmHandle) -> Result<(), VmError> { - tracing::warn!(vm = %handle.name, "force-killing Cloud Hypervisor VM"); + tracing::warn!(driver = "cloud_hv", vm = %handle.name, "force-killing Cloud Hypervisor VM"); let mut vms = self .vms .lock() .map_err(|e| VmError::Hypervisor(format!("lock poisoned: {}", e)))?; - let (identity, mut child, virtiofsd_pids) = if let Some(process) = vms.remove(&handle.name) - { - cleanup_taps(&process.tap_devices); - ( - process.identity, - Some(process.child), - process.virtiofsd_pids, - ) - } else if let Some(ref process) = handle.process { - validate_cloud_hypervisor_process(process, &handle.name)?; - (process.clone(), None, Vec::new()) - } else { - return Err(VmError::NotFound { - name: handle.name.clone(), - }); - }; + let (identity, mut child, virtiofsd_children, tap_devices) = + if let Some(process) = vms.remove(&handle.name) { + ( + process.identity, + Some(process.child), + process.virtiofsd_children, + process.tap_devices, + ) + } else if let Some(ref process) = handle.process { + validate_cloud_hypervisor_process(process, &handle.name)?; + (process.clone(), None, Vec::new(), Vec::new()) + } else { + return Err(VmError::NotFound { + name: handle.name.clone(), + }); + }; let kill_result = if let Some(child) = child.as_mut() { child.kill().map_err(|e| VmError::StopFailed { @@ -364,14 +393,21 @@ impl VmDriver for CloudHvDriver { Ok(()) } }; - kill_result?; - - if let Some(child) = child { - wait_for_exit(child, std::time::Duration::from_secs(2)); - } + let wait_result = if let Some(child) = child { + wait_for_exit(child, std::time::Duration::from_secs(2)).map_err(|e| { + VmError::StopFailed { + name: handle.name.clone(), + detail: format!("failed to reap killed VM PID {}: {}", identity.pid(), e), + } + }) + } else { + Ok(()) + }; - cleanup_virtiofsd(&virtiofsd_pids); - Ok(()) + cleanup_taps(&tap_devices); + cleanup_virtiofsd(virtiofsd_children); + kill_result?; + wait_result } fn state(&self, handle: &VmHandle) -> Result { @@ -394,9 +430,9 @@ impl VmDriver for CloudHvDriver { return Ok(VmState::Stopped); } if let Some(ip) = super::check_ready_marker(&handle.serial_log) { - return Ok(VmState::Running { ip }); + return Ok(VmState::Ready { ip }); } - return Ok(VmState::Starting); + return Ok(VmState::Running); } }; @@ -406,10 +442,10 @@ impl VmDriver for CloudHvDriver { } // Process alive — check serial log for readiness marker - if let Some(ip) = super::check_ready_marker(&handle.serial_log) { - Ok(VmState::Running { ip }) + if let Some(ip) = process.ready.scan(&handle.serial_log) { + Ok(VmState::Ready { ip }) } else { - Ok(VmState::Starting) + Ok(VmState::Running) } } } @@ -513,47 +549,53 @@ fn wait_for_socket(path: &Path, timeout: std::time::Duration) -> bool { false } -/// Kill virtiofsd sidecar processes. -fn cleanup_virtiofsd(pids: &[u32]) { - for &pid in pids { - // SAFETY: Sending SIGKILL to PIDs we spawned. - let ret = unsafe { libc::kill(pid as i32, libc::SIGKILL) }; - if ret == 0 { - tracing::debug!(pid = pid, "virtiofsd killed"); - } else { - let errno = std::io::Error::last_os_error(); - tracing::warn!(pid = pid, error = %errno, "failed to kill virtiofsd sidecar"); +/// Kill and reap virtiofsd sidecar processes. +fn cleanup_virtiofsd(children: Vec) { + for child in children { + let pid = child.id(); + match wait_for_exit(child, std::time::Duration::from_secs(1)) { + Ok(()) => tracing::debug!(pid = pid, "virtiofsd exited"), + Err(e) if e.kind() == std::io::ErrorKind::TimedOut => { + tracing::debug!(pid = pid, "virtiofsd required forced termination: {}", e); + } + Err(e) => { + tracing::warn!(pid = pid, error = %e, "failed to clean up virtiofsd sidecar"); + } } } } /// Wait for a tracked child process to exit. -/// If the process doesn't exit within the timeout, escalates to SIGKILL. -fn wait_for_exit(mut child: Child, timeout: std::time::Duration) { +/// If the process doesn't exit within the timeout, escalates to SIGKILL and +/// reports that the graceful stop timed out. +fn wait_for_exit(mut child: Child, timeout: std::time::Duration) -> std::io::Result<()> { let start = std::time::Instant::now(); let pid = child.id(); while start.elapsed() < timeout { match child.try_wait() { Ok(Some(status)) => { tracing::debug!(pid = pid, %status, "process exited"); - return; + return Ok(()); } Ok(None) => {} Err(e) => { tracing::warn!(pid = pid, error = %e, "failed to query child exit status"); - break; + return Err(e); } } std::thread::sleep(std::time::Duration::from_millis(200)); } tracing::warn!(pid = pid, elapsed_ms = %timeout.as_millis(), "process did not exit within timeout, sending SIGKILL"); - if let Err(e) = child.kill() { - tracing::warn!(pid = pid, error = %e, "SIGKILL failed while waiting for child exit"); - return; - } - if let Err(e) = child.wait() { - tracing::warn!(pid = pid, error = %e, "failed to reap child after SIGKILL"); - } + child.kill()?; + let _status = child.wait()?; + Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!( + "PID {} required SIGKILL after waiting {} ms", + pid, + timeout.as_millis() + ), + )) } fn wait_for_pid_exit( diff --git a/src/driver/mod.rs b/src/driver/mod.rs index 8431dac..28b483e 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -16,7 +16,10 @@ pub mod boot; #[cfg(target_os = "windows")] pub mod whp; +mod ready; + use crate::config::{VmConfig, VmHandle, VmState}; +pub(crate) use ready::{check_ready_marker, ReadyMarkerCache}; /// Platform-agnostic VM lifecycle. /// @@ -28,7 +31,7 @@ pub trait VmDriver: Send + Sync { /// /// Returns a handle that can be used to query state, stop, or kill the VM. /// The VM may still be in `Starting` state when this returns — use - /// `state()` to poll for `Running`. + /// `state()` to poll for `Running`/`Ready`. fn boot(&self, config: &VmConfig) -> Result; /// Stop a running VM gracefully. @@ -108,26 +111,3 @@ impl From for VmError { VmError::Hypervisor(format!("setup error: {}", e)) } } - -/// Check a serial console log for the VM readiness marker. -/// -/// The guest writes `VMRS_READY ` when boot completes. -/// Returns `Some(ip)` if found, `None` otherwise. -pub(crate) fn check_ready_marker(log_path: &std::path::Path) -> Option { - let content = match std::fs::read_to_string(log_path) { - Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None, - Err(e) => { - tracing::warn!(path = %log_path.display(), "failed to read serial log: {}", e); - return None; - } - }; - let pos = content.find(crate::config::READY_MARKER)?; - let after = &content[pos + crate::config::READY_MARKER.len()..]; - let ip = after.split_whitespace().next()?.trim().to_string(); - if ip.is_empty() { - None - } else { - Some(ip) - } -} diff --git a/src/driver/ready.rs b/src/driver/ready.rs new file mode 100644 index 0000000..42672c3 --- /dev/null +++ b/src/driver/ready.rs @@ -0,0 +1,155 @@ +use std::fs::File; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +/// Incremental readiness-marker scanner for serial logs. +#[derive(Debug, Default)] +pub(crate) struct ReadyMarkerCache { + ready_ip: Option, + scan_offset: u64, + tail: String, +} + +impl ReadyMarkerCache { + #[cfg(test)] + pub(crate) fn ready_ip(&self) -> Option<&str> { + self.ready_ip.as_deref() + } + + pub(crate) fn scan(&mut self, log_path: &Path) -> Option { + if let Some(ip) = &self.ready_ip { + return Some(ip.clone()); + } + + let mut file = match File::open(log_path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None, + Err(e) => { + tracing::warn!( + path = %log_path.display(), + "failed to open serial log while checking readiness: {}", + e + ); + return None; + } + }; + + let file_len = match file.metadata() { + Ok(meta) => meta.len(), + Err(e) => { + tracing::warn!( + path = %log_path.display(), + "failed to stat serial log while checking readiness: {}", + e + ); + return None; + } + }; + if file_len < self.scan_offset { + self.scan_offset = 0; + self.tail.clear(); + } + + if let Err(e) = file.seek(SeekFrom::Start(self.scan_offset)) { + tracing::warn!( + path = %log_path.display(), + offset = self.scan_offset, + "failed to seek serial log while checking readiness: {}", + e + ); + return None; + } + + let mut buf = Vec::new(); + if let Err(e) = file.read_to_end(&mut buf) { + tracing::warn!( + path = %log_path.display(), + "failed to read serial log while checking readiness: {}", + e + ); + return None; + } + self.scan_offset = file_len; + + if buf.is_empty() && self.tail.is_empty() { + return None; + } + + let chunk = String::from_utf8_lossy(&buf); + let mut combined = String::with_capacity(self.tail.len() + chunk.len()); + combined.push_str(&self.tail); + combined.push_str(&chunk); + + if let Some(ip) = parse_ready_marker(&combined) { + self.ready_ip = Some(ip.clone()); + self.tail.clear(); + return Some(ip); + } + + self.tail = trailing_overlap(&combined); + None + } +} + +pub(crate) fn check_ready_marker(log_path: &Path) -> Option { + let content = match std::fs::read_to_string(log_path) { + Ok(c) => c, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None, + Err(e) => { + tracing::warn!(path = %log_path.display(), "failed to read serial log: {}", e); + return None; + } + }; + parse_ready_marker(&content) +} + +fn parse_ready_marker(content: &str) -> Option { + let pos = content.find(crate::config::READY_MARKER)?; + let after = &content[pos + crate::config::READY_MARKER.len()..]; + let ip = after.split_whitespace().next()?.trim().to_string(); + if ip.is_empty() { + None + } else { + Some(ip) + } +} + +fn trailing_overlap(content: &str) -> String { + let overlap = crate::config::READY_MARKER.len() + 64; + let mut chars = content.chars().rev().take(overlap).collect::>(); + chars.reverse(); + chars.into_iter().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_ready_marker_extracts_ip() { + assert_eq!( + parse_ready_marker("noise\nVMRS_READY 10.0.0.2\n"), + Some("10.0.0.2".into()) + ); + } + + #[test] + fn cache_detects_split_marker_across_reads() { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("serial.log"); + std::fs::write(&path, "vmrs VMRS_RE").expect("write partial marker"); + + let mut cache = ReadyMarkerCache::default(); + assert_eq!(cache.scan(&path), None); + + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("open for append"); + use std::io::Write; + writeln!(file, "ADY 10.0.0.2").expect("append marker remainder"); + + assert_eq!(cache.scan(&path), Some("10.0.0.2".into())); + assert_eq!(cache.ready_ip(), Some("10.0.0.2")); + } +} diff --git a/src/driver/whp.rs b/src/driver/whp.rs index 4a9f65f..302824a 100644 --- a/src/driver/whp.rs +++ b/src/driver/whp.rs @@ -329,7 +329,7 @@ impl VmDriver for WhpDriver { // ── Step 6: Spawn vCPU thread ── - let state = Arc::new(RwLock::new(VmState::Starting)); + let state = Arc::new(RwLock::new(VmState::Running)); let stop_flag = Arc::new(AtomicBool::new(false)); let serial_log = config.serial_log.clone(); @@ -378,7 +378,7 @@ impl VmDriver for WhpDriver { Ok(VmHandle { name: name.clone(), namespace: config.namespace.clone(), - state: VmState::Starting, + state: VmState::Running, process: None, // In-process, no separate PID serial_log, machine_id: None, @@ -463,7 +463,7 @@ impl VmDriver for WhpDriver { .read() .map_err(|e| VmError::Hypervisor(format!("state lock poisoned: {e}")))? .clone(); - if !matches!(current_state, VmState::Running { .. }) { + if !current_state.is_running() { return Err(VmError::Hypervisor("can only pause a running VM".into())); } @@ -513,7 +513,7 @@ impl VmDriver for WhpDriver { // Reset stop flag and spawn new vCPU thread vm.stop_flag.store(false, Ordering::Release); - let resumed_state = vm.resume_state.clone().unwrap_or(VmState::Starting); + let resumed_state = vm.resume_state.clone().unwrap_or(VmState::Running); let sendable = SendablePartition(vm.partition); let state_clone = Arc::clone(&vm.state); @@ -840,7 +840,7 @@ fn handle_io_port( let ip = ip.trim().to_string(); if !ip.is_empty() { tracing::info!(vm = %vm_name, ip = %ip, "VM ready"); - update_state(state, VmState::Running { ip }); + update_state(state, VmState::Ready { ip }); serial_buffer.clear(); } } diff --git a/src/vm/disk.rs b/src/vm/disk.rs new file mode 100644 index 0000000..f95aee5 --- /dev/null +++ b/src/vm/disk.rs @@ -0,0 +1,75 @@ +use std::path::Path; + +use crate::driver::VmError; + +pub(super) fn clone_disk(base: &Path, target: &Path) -> Result<(), VmError> { + if target.exists() { + if let (Ok(base_meta), Ok(target_meta)) = + (std::fs::metadata(base), std::fs::metadata(target)) + { + if base_meta.len() == target_meta.len() { + tracing::debug!( + target = %target.display(), + "disk clone target already exists with matching size, skipping" + ); + return Ok(()); + } + tracing::warn!( + target = %target.display(), + base_size = base_meta.len(), + target_size = target_meta.len(), + "disk clone target exists but size differs, re-cloning" + ); + std::fs::remove_file(target).map_err(VmError::Io)?; + } + } + + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent).map_err(VmError::Io)?; + } + + #[cfg(target_os = "macos")] + { + let status = std::process::Command::new("cp") + .args(["-c"]) + .arg(base) + .arg(target) + .status() + .map_err(VmError::Io)?; + if !status.success() { + tracing::warn!( + base = %base.display(), + target = %target.display(), + status = %status, + "APFS clone failed; falling back to a full file copy" + ); + std::fs::copy(base, target).map_err(VmError::Io)?; + } + } + + #[cfg(target_os = "linux")] + { + let status = std::process::Command::new("cp") + .args(["--reflink=auto"]) + .arg(base) + .arg(target) + .status() + .map_err(VmError::Io)?; + if !status.success() { + tracing::warn!( + base = %base.display(), + target = %target.display(), + status = %status, + "reflink clone failed; falling back to a full file copy" + ); + std::fs::copy(base, target).map_err(VmError::Io)?; + } + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + std::fs::copy(base, target).map_err(VmError::Io)?; + } + + Ok(()) +} diff --git a/src/vm.rs b/src/vm/manager.rs similarity index 64% rename from src/vm.rs rename to src/vm/manager.rs index 2a88480..3892dfa 100644 --- a/src/vm.rs +++ b/src/vm/manager.rs @@ -1,13 +1,9 @@ -//! VmManager — multi-VM lifecycle orchestration. -//! -//! Auto-selects the correct driver for the current platform: -//! - macOS: AppleVzDriver (Apple Virtualization.framework) -//! - Linux: CloudHvDriver (Cloud Hypervisor REST API) - use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::RwLock; +use super::disk; +use super::platform::create_platform_driver; use crate::config::{VmConfig, VmHandle, VmState}; use crate::driver::{VmDriver, VmError}; @@ -59,7 +55,6 @@ impl VmManager { pub fn start(&self, config: &VmConfig) -> Result { config.validate()?; - // Reserve the name up front so concurrent callers cannot boot the same VM twice. { let mut vms = self .vms @@ -86,7 +81,6 @@ impl VmManager { ); } - // Ensure VM directory exists let vm_dir = self.vm_dir(&config.name); if let Err(e) = std::fs::create_dir_all(&vm_dir) { let mut vms = self @@ -117,14 +111,11 @@ impl VmManager { } }; - // Track the VM - { - let mut vms = self - .vms - .write() - .map_err(|e| VmError::Hypervisor(format!("lock poisoned: {}", e)))?; - vms.insert(config.name.clone(), handle.clone()); - } + let mut vms = self + .vms + .write() + .map_err(|e| VmError::Hypervisor(format!("lock poisoned: {}", e)))?; + vms.insert(config.name.clone(), handle.clone()); Ok(handle) } @@ -134,16 +125,7 @@ impl VmManager { tracing::info!(vm = %name, "stopping VM"); let handle = self.get_handle(name)?; self.driver.stop(&handle)?; - - // Update state - let mut vms = self - .vms - .write() - .map_err(|e| VmError::Hypervisor(format!("lock poisoned: {}", e)))?; - if let Some(h) = vms.get_mut(name) { - h.state = VmState::Stopped; - } - Ok(()) + self.update_cached_state(name, VmState::Stopped) } /// Force-kill a VM. @@ -151,62 +133,28 @@ impl VmManager { tracing::info!(vm = %name, "force-killing VM"); let handle = self.get_handle(name)?; self.driver.kill(&handle)?; - - let mut vms = self - .vms - .write() - .map_err(|e| VmError::Hypervisor(format!("lock poisoned: {}", e)))?; - if let Some(h) = vms.get_mut(name) { - h.state = VmState::Stopped; - } - Ok(()) + self.update_cached_state(name, VmState::Stopped) } /// Stop a VM using a pre-built handle (e.g. restored from persisted metadata). - /// - /// Use this when the VM was started by a previous daemon process and isn't - /// tracked in the in-memory map. pub fn stop_by_handle(&self, handle: &VmHandle) -> Result<(), VmError> { tracing::info!(vm = %handle.name, "stopping VM by handle"); self.driver.stop(handle)?; - - let mut vms = self - .vms - .write() - .map_err(|e| VmError::Hypervisor(format!("lock poisoned: {}", e)))?; - if let Some(h) = vms.get_mut(&handle.name) { - h.state = VmState::Stopped; - } - Ok(()) + self.update_cached_state(&handle.name, VmState::Stopped) } /// Force-kill a VM using a pre-built handle (e.g. restored from persisted metadata). pub fn kill_by_handle(&self, handle: &VmHandle) -> Result<(), VmError> { tracing::info!(vm = %handle.name, "force-killing VM by handle"); self.driver.kill(handle)?; - - let mut vms = self - .vms - .write() - .map_err(|e| VmError::Hypervisor(format!("lock poisoned: {}", e)))?; - if let Some(h) = vms.get_mut(&handle.name) { - h.state = VmState::Stopped; - } - Ok(()) + self.update_cached_state(&handle.name, VmState::Stopped) } /// Pause a running VM. pub fn pause(&self, name: &str) -> Result<(), VmError> { let handle = self.get_handle(name)?; self.driver.pause(&handle)?; - let mut vms = self - .vms - .write() - .map_err(|e| VmError::Hypervisor(format!("lock poisoned: {}", e)))?; - if let Some(h) = vms.get_mut(name) { - h.state = VmState::Paused; - } - Ok(()) + self.update_cached_state(name, VmState::Paused) } /// Resume a paused VM. @@ -214,38 +162,20 @@ impl VmManager { let handle = self.get_handle(name)?; self.driver.resume(&handle)?; let resumed_state = self.driver.state(&handle)?; - let mut vms = self - .vms - .write() - .map_err(|e| VmError::Hypervisor(format!("lock poisoned: {}", e)))?; - if let Some(h) = vms.get_mut(name) { - h.state = resumed_state; - } - Ok(()) + self.update_cached_state(name, resumed_state) } /// Query current state of a VM. pub fn state(&self, name: &str) -> Result { let handle = self.get_handle(name)?; let state = self.driver.state(&handle)?; - - // Update cached state - let mut vms = self - .vms - .write() - .map_err(|e| VmError::Hypervisor(format!("lock poisoned: {}", e)))?; - if let Some(h) = vms.get_mut(name) { - h.state = state.clone(); - } + self.update_cached_state(name, state.clone())?; Ok(state) } - /// Get the IP address of a running VM. + /// Get the IP address of a ready VM. pub fn get_ip(&self, name: &str) -> Result, VmError> { - match self.state(name)? { - VmState::Running { ip } => Ok(Some(ip)), - _ => Ok(None), - } + Ok(self.state(name)?.ip().map(ToOwned::to_owned)) } /// List all tracked VMs. @@ -257,23 +187,14 @@ impl VmManager { Ok(vms.values().cloned().collect()) } - /// Wait for all VMs to reach Running state (with timeout). + /// Wait for all VMs to emit the readiness marker within the timeout. pub fn wait_all_ready(&self, timeout_secs: u64) -> Result<(), VmError> { let start = std::time::Instant::now(); let timeout = std::time::Duration::from_secs(timeout_secs); loop { if start.elapsed() > timeout { - let pending: Vec = { - let vms = self - .vms - .read() - .map_err(|e| VmError::Hypervisor(format!("lock poisoned: {}", e)))?; - vms.iter() - .filter(|(_, h)| !matches!(h.state, VmState::Running { .. })) - .map(|(name, _)| name.clone()) - .collect() - }; + let pending = self.pending_names(|state| state.is_ready())?; return Err(VmError::Hypervisor(format!( "timeout waiting for VMs: {}", pending.join(", ") @@ -281,17 +202,11 @@ impl VmManager { } let mut all_ready = true; - let names: Vec = { - let vms = self - .vms - .read() - .map_err(|e| VmError::Hypervisor(format!("lock poisoned: {}", e)))?; - vms.keys().cloned().collect() - }; + let names = self.vm_names()?; for name in &names { match self.state(name)? { - VmState::Running { .. } => {} + state if state.is_ready() => {} VmState::Failed { reason } => { return Err(VmError::BootFailed { name: name.clone(), @@ -308,21 +223,14 @@ impl VmManager { return Ok(()); } - { - let pending: Vec = { - let vms = self - .vms - .read() - .map_err(|e| VmError::Hypervisor(format!("lock poisoned: {}", e)))?; - vms.iter() - .filter(|(_, h)| !matches!(h.state, VmState::Running { .. })) - .map(|(name, _)| name.clone()) - .collect() - }; - let elapsed = start.elapsed().as_secs(); - if elapsed > 0 && elapsed.is_multiple_of(10) { - tracing::info!(pending = ?pending, elapsed_secs = start.elapsed().as_secs(), "waiting for VMs to become ready"); - } + let elapsed = start.elapsed().as_secs(); + if elapsed > 0 && elapsed.is_multiple_of(10) { + let pending = self.pending_names(|state| state.is_ready())?; + tracing::info!( + pending = ?pending, + elapsed_secs = elapsed, + "waiting for VMs to become ready" + ); } std::thread::sleep(std::time::Duration::from_secs(1)); @@ -330,77 +238,8 @@ impl VmManager { } /// Create a disk image as a CoW clone of a base image. - /// - /// On macOS: APFS clone (`cp -c`), instant and zero-space. - /// On Linux: reflink (`cp --reflink=auto`), falls back to regular copy. pub fn clone_disk(base: &Path, target: &Path) -> Result<(), VmError> { - if target.exists() { - if let (Ok(base_meta), Ok(target_meta)) = - (std::fs::metadata(base), std::fs::metadata(target)) - { - if base_meta.len() == target_meta.len() { - tracing::debug!(target = %target.display(), "disk clone target already exists with matching size, skipping"); - return Ok(()); - } - tracing::warn!( - target = %target.display(), - base_size = base_meta.len(), - target_size = target_meta.len(), - "disk clone target exists but size differs, re-cloning" - ); - std::fs::remove_file(target).map_err(VmError::Io)?; - } - } - - // Ensure parent directory exists - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent).map_err(VmError::Io)?; - } - - #[cfg(target_os = "macos")] - { - let status = std::process::Command::new("cp") - .args(["-c"]) - .arg(base) - .arg(target) - .status() - .map_err(VmError::Io)?; - if !status.success() { - tracing::warn!( - base = %base.display(), - target = %target.display(), - status = %status, - "APFS clone failed; falling back to a full file copy" - ); - std::fs::copy(base, target).map_err(VmError::Io)?; - } - } - - #[cfg(target_os = "linux")] - { - let status = std::process::Command::new("cp") - .args(["--reflink=auto"]) - .arg(base) - .arg(target) - .status() - .map_err(VmError::Io)?; - if !status.success() { - tracing::warn!( - base = %base.display(), - target = %target.display(), - status = %status, - "reflink clone failed; falling back to a full file copy" - ); - std::fs::copy(base, target).map_err(VmError::Io)?; - } - } - - #[cfg(not(any(target_os = "macos", target_os = "linux")))] - { - std::fs::copy(base, target).map_err(VmError::Io)?; - } - - Ok(()) + disk::clone_disk(base, target) } fn get_handle(&self, name: &str) -> Result { @@ -412,31 +251,36 @@ impl VmManager { name: name.to_string(), }) } -} - -/// Create the platform-appropriate VM driver. -fn create_platform_driver() -> Result, VmError> { - #[cfg(target_os = "macos")] - { - Ok(Box::new(crate::driver::apple_vz::AppleVzDriver::new())) - } - #[cfg(target_os = "linux")] - { - Ok(Box::new(crate::driver::cloud_hv::CloudHvDriver::new())) + fn update_cached_state(&self, name: &str, state: VmState) -> Result<(), VmError> { + let mut vms = self + .vms + .write() + .map_err(|e| VmError::Hypervisor(format!("lock poisoned: {}", e)))?; + if let Some(handle) = vms.get_mut(name) { + handle.state = state; + } + Ok(()) } - #[cfg(target_os = "windows")] - { - Ok(Box::new(crate::driver::whp::WhpDriver::new())) + fn vm_names(&self) -> Result, VmError> { + let vms = self + .vms + .read() + .map_err(|e| VmError::Hypervisor(format!("lock poisoned: {}", e)))?; + Ok(vms.keys().cloned().collect()) } - #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] - { - Err(VmError::Hypervisor(format!( - "unsupported platform: {}", - std::env::consts::OS - ))) + fn pending_names(&self, predicate: impl Fn(&VmState) -> bool) -> Result, VmError> { + let vms = self + .vms + .read() + .map_err(|e| VmError::Hypervisor(format!("lock poisoned: {}", e)))?; + Ok(vms + .iter() + .filter(|(_, handle)| !predicate(&handle.state)) + .map(|(name, _)| name.clone()) + .collect()) } } @@ -454,6 +298,9 @@ mod tests { } struct FailedStateDriver; + struct ReadyAfterTwoPollsDriver { + polls: AtomicUsize, + } impl VmDriver for Arc { fn boot(&self, config: &VmConfig) -> Result { @@ -511,6 +358,38 @@ mod tests { } } + impl VmDriver for ReadyAfterTwoPollsDriver { + fn boot(&self, config: &VmConfig) -> Result { + Ok(VmHandle { + name: config.name.clone(), + namespace: config.namespace.clone(), + state: VmState::Starting, + process: None, + serial_log: config.serial_log.clone(), + machine_id: None, + }) + } + + fn stop(&self, _handle: &VmHandle) -> Result<(), VmError> { + Ok(()) + } + + fn kill(&self, _handle: &VmHandle) -> Result<(), VmError> { + Ok(()) + } + + fn state(&self, _handle: &VmHandle) -> Result { + let poll = self.polls.fetch_add(1, Ordering::SeqCst); + if poll == 0 { + Ok(VmState::Running) + } else { + Ok(VmState::Ready { + ip: "10.0.0.2".into(), + }) + } + } + } + fn test_config(base_dir: &Path) -> VmConfig { VmConfig { name: "test-vm".into(), @@ -585,4 +464,26 @@ mod tests { .start(&config) .expect("restart after failed state should be allowed"); } + + #[test] + fn wait_all_ready_waits_for_ready_not_just_running() { + let tmp = tempfile::tempdir().expect("tempdir"); + let manager = VmManager::with_driver( + Box::new(ReadyAfterTwoPollsDriver { + polls: AtomicUsize::new(0), + }), + tmp.path().to_path_buf(), + ) + .expect("manager"); + let config = test_config(tmp.path()); + + manager.start(&config).expect("boot"); + manager + .wait_all_ready(2) + .expect("wait_all_ready should wait until ready"); + assert_eq!( + manager.get_ip(&config.name).expect("ip query"), + Some("10.0.0.2".into()) + ); + } } diff --git a/src/vm/mod.rs b/src/vm/mod.rs new file mode 100644 index 0000000..dd14695 --- /dev/null +++ b/src/vm/mod.rs @@ -0,0 +1,7 @@ +//! VmManager — multi-VM lifecycle orchestration. + +mod disk; +mod manager; +mod platform; + +pub use manager::VmManager; diff --git a/src/vm/platform.rs b/src/vm/platform.rs new file mode 100644 index 0000000..f3e33fd --- /dev/null +++ b/src/vm/platform.rs @@ -0,0 +1,26 @@ +use crate::driver::{VmDriver, VmError}; + +pub(super) fn create_platform_driver() -> Result, VmError> { + #[cfg(target_os = "macos")] + { + Ok(Box::new(crate::driver::apple_vz::AppleVzDriver::new())) + } + + #[cfg(target_os = "linux")] + { + Ok(Box::new(crate::driver::cloud_hv::CloudHvDriver::new())) + } + + #[cfg(target_os = "windows")] + { + Ok(Box::new(crate::driver::whp::WhpDriver::new())) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + Err(VmError::Hypervisor(format!( + "unsupported platform: {}", + std::env::consts::OS + ))) + } +} diff --git a/tests/vm_lifecycle.rs b/tests/vm_lifecycle.rs index fcd8d16..05d2f85 100644 --- a/tests/vm_lifecycle.rs +++ b/tests/vm_lifecycle.rs @@ -94,7 +94,7 @@ fn boot_vm_reaches_running_state() { let mut final_state = VmState::Starting; while start.elapsed() < timeout { match manager.state("test-boot") { - Ok(state @ VmState::Running { .. }) => { + Ok(state @ VmState::Ready { .. }) => { final_state = state; break; } @@ -110,8 +110,8 @@ fn boot_vm_reaches_running_state() { } assert!( - matches!(final_state, VmState::Running { .. }), - "VM did not reach Running state within 30s, stuck at: {}", + matches!(final_state, VmState::Ready { .. }), + "VM did not reach Ready state within 30s, stuck at: {}", final_state ); diff --git a/tests/vm_manager.rs b/tests/vm_manager.rs index 9b2220f..412c33a 100644 --- a/tests/vm_manager.rs +++ b/tests/vm_manager.rs @@ -68,7 +68,7 @@ impl VmDriver for MockDriver { let handle = VmHandle { name: config.name.clone(), namespace: config.namespace.clone(), - state: VmState::Running { + state: VmState::Ready { ip: "10.0.0.99".into(), }, process: None, @@ -82,7 +82,7 @@ impl VmDriver for MockDriver { .insert( config.name.clone(), MockVmState { - state: VmState::Running { + state: VmState::Ready { ip: "10.0.0.99".into(), }, }, @@ -129,7 +129,7 @@ impl VmDriver for MockDriver { let mut vms = self.vms.lock().expect("vms lock should not be poisoned"); match vms.get_mut(&handle.name) { Some(vm) => { - if !matches!(vm.state, VmState::Running { .. }) { + if !vm.state.is_running() { return Err(VmError::Hypervisor("can only pause a running VM".into())); } vm.state = VmState::Paused; @@ -148,7 +148,7 @@ impl VmDriver for MockDriver { if vm.state != VmState::Paused { return Err(VmError::Hypervisor("can only resume a paused VM".into())); } - vm.state = VmState::Running { + vm.state = VmState::Ready { ip: "10.0.0.99".into(), }; Ok(()) @@ -208,11 +208,11 @@ fn boot_and_state_transitions() { let handle = manager.start(&config).expect("boot should succeed"); assert_eq!(handle.name, "mock-boot"); - // State should be Running (mock returns Running immediately) + // State should be Ready (mock returns readiness immediately) let state = manager.state("mock-boot").expect("state query"); assert!( - matches!(state, VmState::Running { .. }), - "expected Running, got: {}", + matches!(state, VmState::Ready { .. }), + "expected Ready, got: {}", state ); } @@ -387,14 +387,14 @@ fn concurrent_boots_different_names() { } #[test] -fn wait_all_ready_succeeds_when_all_running() { +fn wait_all_ready_succeeds_when_all_ready() { let driver = MockDriver::new(); let manager = make_manager(driver); let config = make_config("mock-ready"); manager.start(&config).expect("boot"); - // Mock driver returns Running immediately, so wait should succeed fast + // Mock driver returns readiness immediately, so wait should succeed fast manager .wait_all_ready(5) .expect("wait_all_ready should succeed"); @@ -434,15 +434,15 @@ fn resume_paused_vm_returns_to_running() { .find(|vm| vm.name == "mock-resume") .expect("resumed VM should still be tracked"); assert!( - matches!(listed_vm.state, VmState::Running { .. }), - "cached handle state should be Running after resume, got: {}", + matches!(listed_vm.state, VmState::Ready { .. }), + "cached handle state should be Ready after resume, got: {}", listed_vm.state ); let state = manager.state("mock-resume").expect("state"); assert!( - matches!(state, VmState::Running { .. }), - "VM should be running after resume, got: {}", + matches!(state, VmState::Ready { .. }), + "VM should be ready after resume, got: {}", state ); } @@ -504,7 +504,7 @@ fn pause_resume_cycle() { manager.resume("mock-cycle").expect("resume"); let state = manager.state("mock-cycle").expect("state"); - assert!(matches!(state, VmState::Running { .. })); + assert!(matches!(state, VmState::Ready { .. })); // Can pause again after resume manager.pause("mock-cycle").expect("second pause");