8000 test(minvmd): vsock stub and bridge end-to-end test by gominimal-aw-bot[bot] · Pull Request #350 · gominimal/minimal · GitHub
[go: up one dir, main page]

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
26 changes: 20 additions & 6 deletions .github/workflows/ci-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,13 @@ jobs:
# A setup failure fails the job — it is not hidden behind the non-gating
# test below.
run: scripts/build-rootfs.sh
- name: Build boot E2E + minvmd (no run)
# Build the test harness + minvmd bin, then codesign, then run the test
# BINARY DIRECTLY (below). Invoking `cargo test` again after signing
# relinks (and unsigns) target/debug/minvmd, so krun_start_enter fails
# with EINVAL. A direct-binary run never touches the signature.
run: cargo test -p minvmd --test boot_e2e --no-run
- name: Build boot + bridge E2E + minvmd (no run)
# Build both test harnesses + the minvmd bin, then codesign, then run the
# test BINARIES DIRECTLY (below). Invoking `cargo test` again after
# signing relinks (and unsigns) target/debug/minvmd, so krun_start_enter
# fails with EINVAL. A direct-binary run never touches the signature, so
# both binaries must be built here before the single codesign.
run: cargo test -p minvmd --test boot_e2e --test bridge_e2e --no-run
- name: Codesign minvmd (hypervisor entitlement, R1.4)
run: codesign --entitlements crates/minvmd/minvmd.entitlements --force -s - target/debug/minvmd
- name: Boot E2E (READY round-trip)
Expand All @@ -113,6 +114,19 @@ jobs:
MINVMD_ROOTFS_PATH="$(scripts/build-rootfs.sh --print-path)" \
MINVMD_BOOT_LOG="$RUNNER_TEMP/boot.log" \
"$testbin" --include-ignored --nocapture
- name: Bridge E2E (concurrent UDS↔vsock round-trip)
# Gating (R3.3, R3.4): boots a real microVM, opens 5 concurrent host UDS
# connections to the minimald bridge socket, and asserts each payload
# round-trips through the libkrun UDS↔vsock bridge. Runs the prebuilt
# binary directly so the codesigned minvmd (CARGO_BIN_EXE_minvmd) keeps
# its hypervisor entitlement. The test isolates XDG_RUNTIME_DIR per run.
run: |
testbin="$(ls -1t target/debug/deps/bridge_e2e-* | grep -v '\.d$' | head -1)"
test -x "$testbin" || { echo "::error::bridge_e2e test binary not found"; exit 1; }
MINVMD_E2E=1 \
MINVMD_KERNEL_PATH="$RUNNER_TEMP/kernel/vmlinuz" \
MINVMD_ROOTFS_PATH="$(scripts/build-rootfs.sh --print-path)" \
"$testbin" --include-ignored --nocapture
Comment on lines +126 to +129
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW · correctness — Missing MINVMD_BOOT_LOG capture.

The adjacent boot E2E step (line 115) sets MINVMD_BOOT_LOG="$RUNNER_TEMP/boot.log" so the hvc0 console output is captured and uploaded on failure. This step boots its own VM but omits the env var, so if this boot fails there is no console log to debug it — the upload step only has the boot_e2e log.

Consider adding e.g. MINVMD_BOOT_LOG="$RUNNER_TEMP/bridge-boot.log" and a matching upload step (or adjusting the existing upload to glob both).

- name: Upload guest boot console log
# Capture hvc0 output for diagnosing a stuck/failed boot (e.g. a missing
# virtio driver in the kernel). Always runs so a red boot is debuggable.
Expand Down
4 changes: 4 additions & 0 deletions crates/minvmd/src/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ impl VmConfig {
// EEXIST otherwise (e.g. on a persistent runner).
crate::sock::remove_stale_socket(&uds_path)
.map_err(|source| crate::error::VmError::Io { source })?;
// R3.5: TSI ~62-concurrent-connection cap. libkrun's TSI layer
// multiplexes guest vsock connections over a single host transport; the
// practical ceiling is ~62 concurrent connections on this port before
// new ones queue. Acceptable for v0.1 workloads (<10 concurrent).
ctx.add_vsock_port2(crate::sock::VSOCK_BRIDGE_PORT, &uds_path, true)?;
Ok(())
}
Expand Down
172 changes: 172 additions & 0 deletions crates/minvmd/tests/bridge_e2e.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
//! Bridge end-to-end test: concurrent UDS↔vsock round-trips (R3.3, R3.4).
//!
//! Gates:
//! - `#[cfg(target_os = "macos")]`: libkrun is macOS-only.
//! - `#[ignore]`: skipped by default; run with `cargo test -- --include-ignored`.
//! - `MINVMD_E2E=1`: even with `--include-ignored`, the test self-skips unless
//! this env var is set (prevents accidental VM boot on a dev machine without
//! a kernel and rootfs).
//! - `MINVMD_KERNEL_PATH` and `MINVMD_ROOTFS_PATH` must point to the kernel
//! image and Alpine minirootfs respectively.
//!
//! The test boots a VM via `minvmd boot --foreground`, waits up to 15 s for
//! `vm-up` on stdout, then opens 5 concurrent host UDS connections to the
//! minimald bridge socket. Each writes a distinct payload and reads back
//! exactly that many bytes (via `read_exact`). All 5 must succeed (R3.3, R3.4).
//!
//! The UDS is isolated per-run via a `tempfile::TempDir` set as
//! `XDG_RUNTIME_DIR`, so concurrent test runs on the same machine don't share
//! a socket path.

#![cfg(target_os = "macos")]

use serial_test::serial;
use std::io::{BufRead, BufReader, Read, Write};
use std::os::unix::net::UnixStream;
use std::process::{Command, Stdio};
use std::time::Duration;

const CONCURRENT: usize = 5;
const BOOT_TIMEOUT: Duration = Duration::from_secs(15);
const IO_TIMEOUT: Duration = Duration::from_secs(5);

#[test]
#[serial]
#[ignore = "gated MINVMD_E2E=1; requires Mac with libkrun, kernel, and rootfs"]
fn bridge_e2e_concurrent_round_trip() {
if std::env::var("MINVMD_E2E").as_deref() != Ok("1") {
eprintln!("bridge_e2e: MINVMD_E2E != 1, skipping");
return;
}

for var in &["MINVMD_KERNEL_PATH", "MINVMD_ROOTFS_PATH"] {
assert!(
std::env::var(var).is_ok(),
"bridge_e2e: {var} must be set when MINVMD_E2E=1"
);
}

let runtime_dir = tempfile::TempDir::new().expect("creating isolated runtime dir");
let state_dir = tempfile::TempDir::new().expect("creating isolated state dir");
let sock_path = runtime_dir.path().join("minimal/minimald.sock");

let exe = env!("CARGO_BIN_EXE_minvmd");
let mut child = Command::new(exe)
.args(["boot", "--foreground"])
.env("XDG_RUNTIME_DIR", runtime_dir.path())
.env("XDG_STATE_HOME", state_dir.path())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.expect("spawning minvmd boot --foreground");

// Wait for vm-up, signalling that the READY marker round-trip completed.
let stdout = child.stdout.take().expect("child stdout");
le 10BC8 t (tx, rx) = std::sync::mpsc::channel::<bool>();
std::thread::spawn(move || {
let reader = BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
if line.trim() == "vm-up" {
let _ = tx.send(true);
return;
}
}
let _ = tx.send(false);
});

let got_vm_up = rx.recv_timeout(BOOT_TIMEOUT).unwrap_or(false);
if !got_vm_up {
let _ = child.kill();
let _ = child.wait();
panic!(
"bridge_e2e: did not receive 'vm-up' within {} s; \
check that the guest rootfs writes READY\\n to vsock port {} \
and that MINVMD_KERNEL_PATH/MINVMD_ROOTFS_PATH are correct",
BOOT_TIMEOUT.as_secs(),
minvmd::cmd::VSOCK_MARKER_PORT,
);
}

// The guest stub sends READY before exec-ing the vsock 2222 listener.
// Brief sleep to reduce initial connection failures; the per-connection
// retry loop below handles races if socat is not yet listening.
std::thread::sleep(Duration::from_millis(200));

// Open 5 concurrent host UDS connections; each writes a distinct payload
// and reads it back through the libkrun UDS↔vsock bridge (R3.3, R3.4).
let handles: Vec<_> = (0..CONCURRENT)
.map(|i| {
let path = sock_path.clone();
std::thread::spawn(move || -> Result<(), String> {
let payload = format!("BRIDGE_PAYLOAD_{i}\n");

// Retry briefly in case the guest's socat listener is not yet up.
let mut stream = {
let mut last_err = None;
let mut conn = None;
for _ in 0..10 {
match UnixStream::connect(&path) {
Ok(s) => {
conn = Some(s);
break;
}
Err(e) => {
last_err = Some(e);
std::thread::sleep(Duration::from_millis(100));
}
}
}
match conn {
Some(s) => s,
None => return Err(format!("connect: {}", last_err.unwrap())),
}
};

stream
.set_read_timeout(Some(IO_TIMEOUT))
.map_err(|e| format!("set_read_timeout: {e}"))?;

stream
.write_all(payload.as_bytes())
.map_err(|e| format!("write: {e}"))?;

// Read back exactly as many bytes as we sent. This avoids
// relying on write-side shutdown propagation through the
// UDS↔vsock bridge; cat echoes data as it arrives, so
// read_exact is sufficient and never blocks waiting for EOF.
let mut got = vec![0u8; payload.len()];
stream
.read_exact(&mut got)
.map_err(|e| format!("read_exact: {e}"))?;

if got == payload.as_bytes() {
Ok(())
} else {
Err(format!(
"payload mismatch: expected {payload:?}, got {:?}",
String::from_utf8_lossy(&got)
))
}
})
})
.collect();

let results: Vec<Result<(), String>> = handles
.into_iter()
.map(|h| {
h.join()
.unwrap_or_else(|_| Err("thread panicked".to_string()))
})
.collect();

let _ = child.kill();
let _ = child.wait();

for (i, result) in results.iter().enumerate() {
assert!(
result.is_ok(),
"bridge_e2e: connection {i} failed round-trip: {:?}",
result
);
}
}
Loading
0