Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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** | | | |
Expand Down Expand Up @@ -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 <ip>` marker from console |
| **Entropy** | | | |
| VirtIO RNG | Supported | Supported | /dev/random in guest |
| **Advanced (API mode)** | | | |
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
51 changes: 46 additions & 5 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand All @@ -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),
Expand All @@ -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]
Expand All @@ -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");
Expand Down
44 changes: 29 additions & 15 deletions src/driver/apple_vz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::collections::HashMap<String, VZVirtualMachine>>,
vms: Mutex<std::collections::HashMap<String, RegisteredVm>>,
}

impl AppleVzDriver {
Expand All @@ -52,18 +57,17 @@ impl AppleVzDriver {
VZVirtualMachine::supported()
}

fn vm_state(handle: &VmHandle, vm: &VZVirtualMachine) -> VmState {
fn vm_state(vm: &VZVirtualMachine, ready_ip: Option<String>) -> 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<String>) -> 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
})?;
Expand All @@ -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()
Expand Down Expand Up @@ -415,16 +426,19 @@ impl VmDriver for AppleVzDriver {
}

fn state(&self, handle: &VmHandle) -> Result<VmState, VmError> {
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)
}
}
Expand Down Expand Up @@ -485,7 +499,7 @@ mod tests {
VZVirtualMachineState::VZVirtualMachineStateRunning,
None,
),
VmState::Starting
VmState::Running
);
}

Expand All @@ -496,7 +510,7 @@ mod tests {
VZVirtualMachineState::VZVirtualMachineStateRunning,
Some("10.0.0.2".into()),
),
VmState::Running {
VmState::Ready {
ip: "10.0.0.2".into(),
}
);
Expand Down
Loading
Loading