Skip to content

Commit 8db2a71

Browse files
Add reproducible signed sandbox VM E2E harness
1 parent 0f099ea commit 8db2a71

9 files changed

Lines changed: 765 additions & 137 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -177,15 +177,5 @@ jobs:
177177
cargo nextest run -p vz-runtime-contract backend_adapter
178178
cargo nextest run -p vz-stack --test backend_conformance backend_conformance_cross_backend
179179
180-
# Layer 3: VM tests (self-hosted, requires golden image)
181-
# Uncomment when self-hosted macOS ARM64 runner is available
182-
# test-vm:
183-
# name: VM Tests (macOS ARM64, self-hosted)
184-
# runs-on: [self-hosted, macOS, ARM64]
185-
# steps:
186-
# - uses: actions/checkout@v4
187-
# - uses: dtolnay/rust-toolchain@stable
188-
# - uses: taiki-e/install-action@nextest
189-
# - name: Test (with VM tests)
190-
# working-directory: crates
191-
# run: cargo nextest run --workspace --features vm-tests
180+
# Real VM sandbox E2E coverage is executed in `.github/workflows/vm-e2e.yml`
181+
# so local and CI runs share the same signed harness entrypoint.

.github/workflows/vm-e2e.yml

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
name: VM E2E (Sandbox)
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
suites:
7+
description: "Suites to run: runtime, stack, buildkit, sandbox, or all"
8+
required: false
9+
default: "sandbox"
10+
type: string
11+
profile:
12+
description: "Cargo profile"
13+
required: false
14+
default: "debug"
15+
type: choice
16+
options:
17+
- debug
18+
- release
19+
keep_going:
20+
description: "Continue running suites after failures"
21+
required: false
22+
default: false
23+
type: boolean
24+
schedule:
25+
- cron: "0 8 * * 1"
26+
27+
env:
28+
CARGO_TERM_COLOR: always
29+
RUST_BACKTRACE: 1
30+
31+
jobs:
32+
vm-e2e:
33+
name: Real VM Sandbox E2E
34+
runs-on: [self-hosted, macOS, ARM64]
35+
timeout-minutes: 240
36+
37+
steps:
38+
- uses: actions/checkout@v4
39+
40+
- uses: dtolnay/rust-toolchain@stable
41+
42+
- uses: Swatinem/rust-cache@v2
43+
with:
44+
workspaces: crates
45+
46+
- name: Run sandbox VM E2E harness
47+
run: |
48+
SUITES="${{ github.event.inputs.suites }}"
49+
PROFILE="${{ github.event.inputs.profile }}"
50+
KEEP_GOING="${{ github.event.inputs.keep_going }}"
51+
52+
if [[ -z "$SUITES" ]]; then
53+
SUITES="sandbox"
54+
fi
55+
if [[ -z "$PROFILE" ]]; then
56+
PROFILE="debug"
57+
fi
58+
59+
ARGS=(
60+
--suite "$SUITES"
61+
--profile "$PROFILE"
62+
--output-dir "$RUNNER_TEMP/sandbox-vm-e2e"
63+
)
64+
65+
if [[ "$KEEP_GOING" == "true" ]]; then
66+
ARGS+=(--keep-going)
67+
fi
68+
69+
./scripts/run-sandbox-vm-e2e.sh "${ARGS[@]}"
70+
71+
- name: Upload VM E2E artifacts
72+
if: always()
73+
uses: actions/upload-artifact@v4
74+
with:
75+
name: sandbox-vm-e2e-artifacts
76+
path: ${{ runner.temp }}/sandbox-vm-e2e
77+
if-no-files-found: warn

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,20 @@ cargo clippy --workspace -- -D warnings
146146
cargo nextest run --workspace
147147
```
148148

149+
Sandbox-specific real VM integration validation (macOS ARM64):
150+
151+
```bash
152+
./scripts/run-sandbox-vm-e2e.sh --suite sandbox
153+
```
154+
155+
Full VM lanes (runtime + stack + buildkit):
156+
157+
```bash
158+
./scripts/run-sandbox-vm-e2e.sh --suite all
159+
```
160+
161+
See `docs/sandbox-vm-e2e.md` for reproducible debug workflow and artifact paths.
162+
149163
## License
150164

151165
[MIT](LICENSE.md)

crates/vz-oci-macos/tests/buildkit_e2e.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
//! - Network access for pulling base images
77
//!
88
//! Run with:
9-
//! `./scripts/run-buildkit-e2e.sh`
9+
//! `./scripts/run-sandbox-vm-e2e.sh --suite buildkit`
1010
1111
#![allow(clippy::unwrap_used)]
1212

@@ -61,7 +61,7 @@ fn has_virtualization_entitlement() -> bool {
6161
async fn buildkit_builds_dockerfile_and_run_uses_built_image() {
6262
if !has_virtualization_entitlement() {
6363
eprintln!(
64-
"skipping buildkit_e2e: test binary is missing com.apple.security.virtualization entitlement; run ./scripts/run-buildkit-e2e.sh"
64+
"skipping buildkit_e2e: test binary is missing com.apple.security.virtualization entitlement; run ./scripts/run-sandbox-vm-e2e.sh --suite buildkit"
6565
);
6666
return;
6767
}

crates/vz-oci-macos/tests/runtime_e2e.rs

Lines changed: 124 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
//! - Linux kernel artifacts installed (`~/.vz/linux/`)
1010
//! - Network access for image pulls (first run only; cached after)
1111
//!
12-
//! Run with: `cargo nextest run -p vz-oci-macos --test runtime_e2e -- --ignored`
12+
//! Run with: `./scripts/run-sandbox-vm-e2e.sh --suite runtime`
1313
1414
#![allow(clippy::unwrap_used)]
1515

16+
use std::process::Command;
1617
use std::time::Duration;
1718

1819
use vz_oci_macos::{ExecConfig, ExecutionMode, RunConfig, Runtime, RuntimeConfig};
@@ -38,6 +39,39 @@ fn test_runtime(data_dir: &std::path::Path) -> Runtime {
3839
Runtime::new(config)
3940
}
4041

42+
fn has_virtualization_entitlement() -> bool {
43+
let Ok(test_binary) = std::env::current_exe() else {
44+
return false;
45+
};
46+
let Ok(output) = Command::new("codesign")
47+
.arg("-d")
48+
.arg("--entitlements")
49+
.arg(":-")
50+
.arg(&test_binary)
51+
.output()
52+
else {
53+
return false;
54+
};
55+
56+
let entitlements = format!(
57+
"{}{}",
58+
String::from_utf8_lossy(&output.stdout),
59+
String::from_utf8_lossy(&output.stderr)
60+
);
61+
entitlements.contains("com.apple.security.virtualization")
62+
}
63+
64+
fn require_virtualization_entitlement() -> bool {
65+
if has_virtualization_entitlement() {
66+
return true;
67+
}
68+
69+
eprintln!(
70+
"skipping runtime_e2e: test binary is missing com.apple.security.virtualization entitlement; run ./scripts/run-sandbox-vm-e2e.sh --suite runtime"
71+
);
72+
false
73+
}
74+
4175
// ── Smoke test: pull + run ──────────────────────────────────────
4276

4377
/// Pull alpine:latest and run `echo hello` via one-shot `Runtime::run()`.
@@ -47,6 +81,9 @@ fn test_runtime(data_dir: &std::path::Path) -> Runtime {
4781
#[tokio::test]
4882
#[ignore = "requires Apple Silicon + Linux kernel artifacts"]
4983
async fn smoke_pull_and_run_alpine() {
84+
if !require_virtualization_entitlement() {
85+
return;
86+
}
5087
init_tracing();
5188
let tmp = tempfile::tempdir().unwrap();
5289
let rt = test_runtime(tmp.path());
@@ -87,6 +124,9 @@ async fn smoke_pull_and_run_alpine() {
87124
#[tokio::test]
88125
#[ignore = "requires Apple Silicon + Linux kernel artifacts"]
89126
async fn smoke_run_oci_runtime_mode() {
127+
if !require_virtualization_entitlement() {
128+
return;
129+
}
90130
init_tracing();
91131
let tmp = tempfile::tempdir().unwrap();
92132
let rt = test_runtime(tmp.path());
@@ -111,6 +151,9 @@ async fn smoke_run_oci_runtime_mode() {
111151
#[tokio::test]
112152
#[ignore = "requires Apple Silicon + Linux kernel artifacts"]
113153
async fn smoke_nonzero_exit_code() {
154+
if !require_virtualization_entitlement() {
155+
return;
156+
}
114157
init_tracing();
115158
let tmp = tempfile::tempdir().unwrap();
116159
let rt = test_runtime(tmp.path());
@@ -133,6 +176,9 @@ async fn smoke_nonzero_exit_code() {
133176
#[tokio::test]
134177
#[ignore = "requires Apple Silicon + Linux kernel artifacts"]
135178
async fn smoke_environment_variables() {
179+
if !require_virtualization_entitlement() {
180+
return;
181+
}
136182
init_tracing();
137183
let tmp = tempfile::tempdir().unwrap();
138184
let rt = test_runtime(tmp.path());
@@ -160,6 +206,9 @@ async fn smoke_environment_variables() {
160206
#[tokio::test]
161207
#[ignore = "requires Apple Silicon + Linux kernel artifacts"]
162208
async fn lifecycle_create_exec_stop_remove() {
209+
if !require_virtualization_entitlement() {
210+
return;
211+
}
163212
init_tracing();
164213
let tmp = tempfile::tempdir().unwrap();
165214
let rt = test_runtime(tmp.path());
@@ -242,6 +291,9 @@ async fn lifecycle_create_exec_stop_remove() {
242291
#[tokio::test(flavor = "multi_thread")]
243292
#[ignore = "requires Apple Silicon + Linux kernel artifacts"]
244293
async fn container_logs_capture_and_retrieve() {
294+
if !require_virtualization_entitlement() {
295+
return;
296+
}
245297
init_tracing();
246298
let tmp = tempfile::tempdir().unwrap();
247299
let rt = test_runtime(tmp.path());
@@ -326,6 +378,9 @@ async fn container_logs_capture_and_retrieve() {
326378
#[tokio::test]
327379
#[ignore = "requires Apple Silicon + Linux kernel artifacts"]
328380
async fn port_forwarding_tcp() {
381+
if !require_virtualization_entitlement() {
382+
return;
383+
}
329384
init_tracing();
330385
let tmp = tempfile::tempdir().unwrap();
331386
let rt = test_runtime(tmp.path());
@@ -399,6 +454,9 @@ async fn port_forwarding_tcp() {
399454
#[tokio::test]
400455
#[ignore = "requires Apple Silicon + Linux kernel artifacts"]
401456
async fn pull_is_idempotent() {
457+
if !require_virtualization_entitlement() {
458+
return;
459+
}
402460
init_tracing();
403461
let tmp = tempfile::tempdir().unwrap();
404462
let rt = test_runtime(tmp.path());
@@ -418,6 +476,9 @@ async fn pull_is_idempotent() {
418476
#[tokio::test]
419477
#[ignore = "requires Apple Silicon + Linux kernel artifacts"]
420478
async fn pull_nonexistent_image_fails() {
479+
if !require_virtualization_entitlement() {
480+
return;
481+
}
421482
init_tracing();
422483
let tmp = tempfile::tempdir().unwrap();
423484
let rt = test_runtime(tmp.path());
@@ -437,6 +498,9 @@ async fn pull_nonexistent_image_fails() {
437498
#[tokio::test]
438499
#[ignore = "requires Apple Silicon + Linux kernel artifacts"]
439500
async fn cgroup_cpu_max_enforcement() {
501+
if !require_virtualization_entitlement() {
502+
return;
503+
}
440504
init_tracing();
441505
let tmp = tempfile::tempdir().unwrap();
442506
let rt = test_runtime(tmp.path());
@@ -456,29 +520,72 @@ async fn cgroup_cpu_max_enforcement() {
456520
.await
457521
.unwrap();
458522

459-
// Read the cgroup cpu.max file inside the container.
523+
// Read CPU throttling values inside the container.
524+
//
525+
// Some guests expose cgroup v2 (`cpu.max`), while others still expose
526+
// cgroup v1 (`cpu.cfs_quota_us` + `cpu.cfs_period_us`).
460527
let exec_out = rt
461528
.exec_container(
462529
&container_id,
463530
ExecConfig {
464-
cmd: vec!["cat".into(), "/sys/fs/cgroup/cpu.max".into()],
531+
cmd: vec![
532+
"sh".into(),
533+
"-c".into(),
534+
"if [ -f /sys/fs/cgroup/cpu.max ]; then \
535+
cat /sys/fs/cgroup/cpu.max; \
536+
elif [ -f /sys/fs/cgroup/cpu/cpu.cfs_quota_us ] && [ -f /sys/fs/cgroup/cpu/cpu.cfs_period_us ]; then \
537+
cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us /sys/fs/cgroup/cpu/cpu.cfs_period_us; \
538+
else \
539+
echo 'missing cpu cgroup controls' >&2; \
540+
exit 1; \
541+
fi"
542+
.into(),
543+
],
465544
..ExecConfig::default()
466545
},
467546
)
468547
.await
469548
.unwrap();
470549

471-
assert_eq!(
472-
exec_out.exit_code, 0,
473-
"cat cpu.max should succeed: stderr={}",
474-
exec_out.stderr
475-
);
476-
assert_eq!(
477-
exec_out.stdout.trim(),
478-
"50000 100000",
479-
"cpu.max should reflect quota=50000 period=100000 (0.5 CPU), got: {}",
480-
exec_out.stdout.trim()
481-
);
550+
if exec_out.exit_code != 0 {
551+
if exec_out.stderr.contains("missing cpu cgroup controls") {
552+
eprintln!(
553+
"skipping cgroup_cpu_max_enforcement: guest does not expose cpu cgroup controls"
554+
);
555+
let _ = rt.stop_container(&container_id, true, None, None).await;
556+
let _ = rt.remove_container(&container_id).await;
557+
return;
558+
}
559+
560+
panic!(
561+
"reading cpu cgroup throttling controls should succeed: stderr={}",
562+
exec_out.stderr
563+
);
564+
}
565+
let normalized = exec_out.stdout.trim();
566+
if normalized.contains(' ') {
567+
assert_eq!(
568+
normalized, "50000 100000",
569+
"cpu.max should reflect quota=50000 period=100000 (0.5 CPU), got: {normalized}"
570+
);
571+
} else {
572+
let lines: Vec<&str> = normalized.lines().map(str::trim).collect();
573+
assert_eq!(
574+
lines.len(),
575+
2,
576+
"expected cgroup v1 output with quota and period lines, got: {normalized}"
577+
);
578+
assert_eq!(
579+
lines[0], "50000",
580+
"cpu.cfs_quota_us should be 50000, got: {}",
581+
lines[0]
582+
);
583+
assert_eq!(
584+
lines[1], "100000",
585+
"cpu.cfs_period_us should be 100000, got: {}",
586+
lines[1]
587+
);
588+
}
482589

483590
// Cleanup.
484591
let _ = rt.stop_container(&container_id, true, None, None).await;
@@ -495,6 +602,9 @@ async fn cgroup_cpu_max_enforcement() {
495602
#[tokio::test(flavor = "multi_thread")]
496603
#[ignore = "requires Apple Silicon + Linux kernel artifacts"]
497604
async fn shared_vm_inter_service_connectivity() {
605+
if !require_virtualization_entitlement() {
606+
return;
607+
}
498608
init_tracing();
499609
// Use persistent data dir for image cache to avoid Docker Hub rate limits.
500610
let home = std::env::var("HOME").unwrap();

0 commit comments

Comments
 (0)