Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
91 commits
Select commit Hold shift + click to select a range
a95d2bf
Bundle the Firecracker jailer alongside the VMM
epompeii Jul 27, 2026
a8b80ce
Add a runner state directory, jail sweep, and empty network namespace
epompeii Jul 27, 2026
b375853
Confine the Firecracker VMM with the jailer
epompeii Jul 27, 2026
8caa692
Test jail confinement, placement policy, and teardown
epompeii Jul 27, 2026
14dda3b
Identify the jailed VMM by inode, not by root path
epompeii Jul 27, 2026
c238701
Propagate the state directory to daemon jobs
epompeii Jul 28, 2026
a62f31a
Unwind every stacked mount at the network namespace handle
epompeii Jul 28, 2026
15116c1
Serialize the jail lifecycle with an advisory lock
epompeii Jul 28, 2026
fdcc034
Assert the jailer argument vector
epompeii Jul 28, 2026
4322071
Make the jail uid and gid configurable, defaulting to 61016
epompeii Jul 28, 2026
4e98275
Run the KVM scenarios as root in CI
epompeii Jul 28, 2026
bd393c0
Address jail sockets through a descriptor, not a long path
epompeii Jul 28, 2026
0a53f1c
Make the jail scenarios fail when the product is broken
epompeii Jul 28, 2026
f0862d1
Prepare the host on demand rather than at daemon startup
epompeii Jul 28, 2026
9b1bbbf
Reject a root jail user, and harden the namespace and state directory
epompeii Jul 28, 2026
dc01537
Use a marker the runner cannot print
epompeii Jul 28, 2026
e44e79d
Reap the VMM and cgroup a killed runner leaves behind
epompeii Jul 28, 2026
a1808ae
Unlink jail sockets through the host view
epompeii Jul 28, 2026
3b65040
Elevate the smoke test's sandboxed runner
epompeii Jul 28, 2026
ae7d8c3
Document that sandboxed Jobs now require a root Runner
epompeii Jul 28, 2026
82b3f6f
Degrade when the cpuset controller is absent, fail when it refuses
epompeii Jul 28, 2026
cc2af91
Stop a failed sweep from wedging a runner permanently
epompeii Jul 28, 2026
7f27678
Own the host preparation latch, and name the VM identity
epompeii Jul 28, 2026
6c8d5c4
Wrap the original error instead of formatting it into a string
epompeii Jul 28, 2026
2de2ccc
Give Scenario a Default, and split the changelog entry
epompeii Jul 28, 2026
abf6e58
Let the sweep reap the orphan the scenario creates
epompeii Jul 28, 2026
98ddcdd
Keep the chroot when the reap leaves a VMM running, and finish the sweep
epompeii Jul 28, 2026
d936d35
Read back the cpuset so Applied means the kernel agreed
epompeii Jul 28, 2026
ba9a3f6
Correct the reason a stale jail matters, and read back cpuset.mems
epompeii Jul 28, 2026
c1318a9
Fail the job when a stale jail still has a VMM running
epompeii Jul 28, 2026
607d79f
Fail fast when the jailer exits before the API socket appears
epompeii Jul 29, 2026
a6b8fcb
Reap every process in a jail, not just the first
epompeii Jul 29, 2026
e3f0461
Take the image pull out from under the jail lock
epompeii Jul 29, 2026
42a6e0e
Test the stacked-mount unwind, and stop the sweep tests reading host …
epompeii Jul 29, 2026
c233e26
Assert cgroup placement rather than noticing its absence
epompeii Jul 29, 2026
489cd52
Re-sweep after a failed teardown, widen the startup budget, fix three…
epompeii Jul 29, 2026
85d9203
Skip non-UTF-8 jail names, own only what we created, say what isolati…
epompeii Jul 29, 2026
e106943
Drain the orphan path's pipes, refuse to build as root, note the sock…
epompeii Jul 29, 2026
fd942ed
Name the jail lock file once
epompeii Jul 30, 2026
26f991f
Remove a stale jail's cgroup before the chroot that names it
epompeii Jul 30, 2026
f27b689
Hold a job's chroot when its cgroup could not be removed
epompeii Jul 30, 2026
0f9d2ef
Refuse to build a jail without root, and say so
epompeii Jul 30, 2026
9d01ecd
Delete the resource limits nothing applies
epompeii Jul 30, 2026
e8af769
Refuse a state directory whose contents cannot be read
epompeii Jul 30, 2026
a629391
Refuse a jail whose scan and kill disagree
epompeii Jul 30, 2026
770b83a
Require an absolute state directory
epompeii Jul 30, 2026
6ae0d51
Punctuate the build script overrides consistently
epompeii Jul 30, 2026
6846fa2
Spend the reclaim signal only on a sweep that finished
epompeii Jul 30, 2026
913cd4d
Let the runner own a dedicated filesystem
epompeii Jul 30, 2026
cbc396c
Name both ways to give up the sandbox
epompeii Jul 30, 2026
1d96162
Track which archive entries are still wanted
epompeii Jul 30, 2026
a5e7669
Say what the scenarios' state directory actually is
epompeii Jul 30, 2026
d1a92c1
Report a jail directory the sweep could not read
epompeii Jul 30, 2026
307d933
Keep the checked socket path end to end
epompeii Jul 30, 2026
a95dd7a
Hold the jailer's exit status, not a rendering of it
epompeii Jul 30, 2026
bd10461
Unwind the network namespace handle through clear
epompeii Jul 30, 2026
c24a432
Drop the empty error channel on cgroup cleanup
epompeii Jul 30, 2026
c47927c
Report a probe timeout when it happens, not minutes later
epompeii Jul 30, 2026
e8e9a2a
Return the scenarios' state directory to its invoker
epompeii Jul 30, 2026
eb97a64
Owe another sweep when a chroot will not go away
epompeii Jul 30, 2026
df48dc3
Refuse to call a jail clear without having looked in it
epompeii Jul 30, 2026
ec74798
Write down what a failing teardown step does
epompeii Jul 30, 2026
fa1dfc1
Say that the jail uid is shared across concurrent runners
epompeii Jul 30, 2026
d4d793f
Stop the scenarios' assertions passing without looking
epompeii Jul 30, 2026
67ad1de
Never report a measurement read that did not happen
epompeii Jul 30, 2026
402628e
Keep a chroot for its own cgroup, not for any stale jail
epompeii Jul 30, 2026
b3df643
Extend the table to what a run measures
epompeii Jul 30, 2026
1b46586
Announce the network namespace lock's wait
epompeii Jul 30, 2026
a02a1c2
Keep the API harness out of the real state directory
epompeii Jul 30, 2026
f599006
Put the absolute state directory rule on the type
epompeii Jul 30, 2026
4f0501b
Prove the state directory is ours by its tree, not by a name
epompeii Jul 30, 2026
201df93
Stop a failed stat from authorizing destruction
epompeii Jul 30, 2026
7eeccb2
Catch a jailed process that exits during the last sleep
epompeii Jul 30, 2026
f2aec94
Report a malformed HTTP response as malformed
epompeii Jul 30, 2026
47eef34
Warn about the jail user once per runner, not once per job
epompeii Jul 30, 2026
dfc253c
Say in the table that a read may gate an action
epompeii Jul 30, 2026
c6807fb
Search for one binary in one place
epompeii Jul 30, 2026
042ef29
Widen the syscall arguments explicitly
epompeii Jul 30, 2026
644b252
Say what the state directory actually holds
epompeii Jul 30, 2026
a378095
Write down why the metrics stat may stay
epompeii Jul 30, 2026
ea75511
Record the jail user warning when it is given
epompeii Jul 30, 2026
b3e1d7d
Fix a typo in the stale cgroup reasoning
epompeii Jul 30, 2026
f379837
Look once more before calling a VMM still running
epompeii Jul 30, 2026
1dbe0f5
Say why the grace period's fall-through is not the same bug
epompeii Jul 30, 2026
9ba83d5
Match the reap's outcomes exhaustively
epompeii Jul 30, 2026
82e2273
Document the invocation the scenarios actually accept
epompeii Jul 30, 2026
c6e92e2
Test that host tuning applies and unwinds
epompeii Jul 31, 2026
6d545bd
Document the tuning scenario
epompeii Jul 31, 2026
8350d9b
Remove the cgroup the tuning partition leaves behind
epompeii Jul 31, 2026
5303562
Have the tuning scenario diagnose its own partition failure
epompeii Jul 31, 2026
cc94a36
Reap what the state directory wipe would strand
epompeii Jul 31, 2026
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
14 changes: 13 additions & 1 deletion .github/workflows/runner.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,20 @@ jobs:
run: |
echo '{"features": {"containerd-snapshotter": true}}' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
- name: Build scenario binaries
run: cargo test-runner scenarios --build-only
- name: Run scenarios
run: cargo test-runner scenarios
# The scenarios run as root. The sandbox is built by dropping
# privilege rather than by starting without it: the jailer creates the
# chroot's device nodes with mknod, chowns the tree to the jail user,
# pivot_roots, and joins a network namespace. The udev rule above makes
# /dev/kvm usable by an unprivileged process, which is necessary but
# not sufficient.
#
# Only this step is elevated. The build above stays as the CI user, so
# cargo's cache and target directory are not left root-owned, and
# BENCHER_RUNNER_BIN keeps cargo out of the elevated step entirely.
run: sudo BENCHER_RUNNER_BIN=./target/debug/runner ./target/debug/test_runner scenarios

build_runner:
name: Build Runner (${{ matrix.build }})
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,10 @@ Defined in `.cargo/config.toml`:
- `cargo test-api seed` needs the API server already running (`cargo run` in `services/api`) with a fresh database (`services/api/data` holds only a tracked `.gitignore`; delete `services/api/data/bencher.db`, not the whole directory)
- `cargo test-api seed` also needs the `bencher` CLI binary already built (`cargo build --bin bencher`); it shells out via `assert_cmd`, which panics with `` `CARGO_BIN_EXE_bencher` is unset `` if the binary is missing
- Pass `--no-git` when running the seed test in this repo: there is no colocated `.git`, so `bencher run` cannot derive a git context and the on-the-fly project naming assertions (`bencher` vs `Project`) will fail without it
- `cargo test-runner` - Runner integration tests (requires Linux + KVM)
- `cargo test-runner` - Runner integration tests (requires Linux + KVM + root)
- `cargo test-runner scenarios` always fails unelevated: the sandbox is built by dropping privilege, so the scenarios refuse to start without it. Build unprivileged, then run elevated, which also keeps `cargo` from leaving root-owned artifacts in `target`:
- `cargo test-runner scenarios --build-only`
- `sudo BENCHER_RUNNER_BIN=./target/debug/runner ./target/debug/test_runner scenarios`

## Git Flow

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion plus/bencher_runner/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ libc = { workspace = true }

# Linux-only dependencies
[target.'cfg(target_os = "linux")'.dependencies]
nix = { workspace = true, features = ["feature", "poll", "sched", "signal"] }
nix = { workspace = true, features = [
"feature",
"mount",
"poll",
"sched",
"signal",
] }

[build-dependencies]
flate2 = { workspace = true }
Expand Down
209 changes: 129 additions & 80 deletions plus/bencher_runner/build.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Build script for `bencher_runner`.
//!
//! Bundles the `bencher-init`, `firecracker`, and `vmlinux` binaries
//! for distribution as a single binary.
//! Bundles the `bencher-init`, `firecracker`, `jailer`, and `vmlinux`
//! binaries for distribution as a single binary.
//!
//! In release builds, binaries are embedded via `include_bytes!`.
//! In debug builds, they are downloaded/cached locally and loaded from disk at runtime.
Expand All @@ -20,9 +20,10 @@
//!
//! # Environment Variable Overrides
//!
//! - `BENCHER_INIT_PATH` — path to a pre-built bencher-init binary
//! - `BENCHER_FIRECRACKER_PATH` — path to a pre-built firecracker binary
//! - `BENCHER_KERNEL_PATH` — path to a pre-built vmlinux kernel
//! - `BENCHER_INIT_PATH`: path to a pre-built bencher-init binary
//! - `BENCHER_FIRECRACKER_PATH`: path to a pre-built firecracker binary
//! - `BENCHER_JAILER_PATH`: path to a pre-built jailer binary
//! - `BENCHER_KERNEL_PATH`: path to a pre-built vmlinux kernel

#![expect(
clippy::expect_used,
Expand Down Expand Up @@ -103,16 +104,26 @@ fn main() {
generate_stub_module("init", &out_dir);
}

// --- firecracker ---
let firecracker_path = find_or_download_firecracker(&out_dir);
if is_release {
let firecracker_path = firecracker_path.unwrap_or_else(|| panic!("firecracker binary not found. Set BENCHER_FIRECRACKER_PATH or ensure download succeeds."));
generate_binary_module("firecracker", &firecracker_path, is_release, &out_dir);
} else if let Some(firecracker_path) = firecracker_path {
generate_binary_module("firecracker", &firecracker_path, is_release, &out_dir);
} else {
eprintln!("WARNING: firecracker not found, generating stub module for debug build");
generate_stub_module("firecracker", &out_dir);
// --- firecracker and jailer ---
// Both ship in the same release archive, so a single download under a
// single hash check yields both. Bundling them together also keeps the
// VMM and its jailer at the same version across a runner self-update.
let (firecracker_path, jailer_path) = find_or_download_firecracker_release(&out_dir);
for (name, path) in [("firecracker", firecracker_path), ("jailer", jailer_path)] {
if is_release {
let path = path.unwrap_or_else(|| {
panic!(
"{name} binary not found. Set BENCHER_{}_PATH or ensure download succeeds.",
name.to_uppercase()
)
});
generate_binary_module(name, &path, is_release, &out_dir);
} else if let Some(path) = path {
generate_binary_module(name, &path, is_release, &out_dir);
} else {
eprintln!("WARNING: {name} not found, generating stub module for debug build");
generate_stub_module(name, &out_dir);
}
}

// --- kernel (vmlinux) ---
Expand All @@ -132,6 +143,7 @@ fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-env-changed=BENCHER_INIT_PATH");
println!("cargo:rerun-if-env-changed=BENCHER_FIRECRACKER_PATH");
println!("cargo:rerun-if-env-changed=BENCHER_JAILER_PATH");
println!("cargo:rerun-if-env-changed=BENCHER_KERNEL_PATH");
println!("cargo:rerun-if-env-changed=PROFILE");
}
Expand Down Expand Up @@ -200,70 +212,88 @@ fn find_init_binary() -> Option<PathBuf> {
None
}

/// Find or download the Firecracker binary.
/// Find or download the `firecracker` and `jailer` binaries.
///
/// Checks `BENCHER_FIRECRACKER_PATH` env var first, then tries to download
/// the `.tgz` release archive from GitHub and extract the binary to `OUT_DIR`.
fn find_or_download_firecracker(out_dir: &Path) -> Option<PathBuf> {
// 1. Check explicit env var
if let Ok(path) = env::var("BENCHER_FIRECRACKER_PATH") {
let path = PathBuf::from(path);
if path.exists() {
eprintln!(
"Using firecracker from BENCHER_FIRECRACKER_PATH: {}",
path.display()
);
return Some(path);
}
eprintln!(
"WARNING: BENCHER_FIRECRACKER_PATH set but file not found: {}",
path.display()
);
}
/// Checks the `BENCHER_FIRECRACKER_PATH` and `BENCHER_JAILER_PATH` env vars
/// first, then downloads the `.tgz` release archive from GitHub once and
/// extracts whichever binaries are still missing into `OUT_DIR`.
///
/// Returns `(firecracker, jailer)`.
fn find_or_download_firecracker_release(out_dir: &Path) -> (Option<PathBuf>, Option<PathBuf>) {
let firecracker_override = binary_path_override("firecracker", "BENCHER_FIRECRACKER_PATH");
let jailer_override = binary_path_override("jailer", "BENCHER_JAILER_PATH");

// 2. Download from GitHub releases
let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default();
let arch = match target_arch.as_str() {
"x86_64" => "x86_64",
"aarch64" => "aarch64",
_ => {
eprintln!("Unsupported architecture for firecracker: {target_arch}");
return None;
eprintln!("Unsupported architecture for the Firecracker release: {target_arch}");
return (firecracker_override, jailer_override);
},
};

let dest = out_dir.join("firecracker");
if dest.exists() {
eprintln!("Using cached firecracker at: {}", dest.display());
return Some(dest);
}

let url = format!(
"https://github.com/firecracker-microvm/firecracker/releases/download/{DEFAULT_FIRECRACKER_VERSION}/firecracker-{DEFAULT_FIRECRACKER_VERSION}-{arch}.tgz",
);
// The binaries inside the tgz are at:
// release-{version}-{arch}/{name}-{version}-{arch}
let wanted: Vec<(String, PathBuf)> = ["firecracker", "jailer"]
.into_iter()
.zip([&firecracker_override, &jailer_override])
.filter(|&(_, overridden)| overridden.is_none())
.map(|(name, _)| {
(
format!(
"release-{DEFAULT_FIRECRACKER_VERSION}-{arch}/{name}-{DEFAULT_FIRECRACKER_VERSION}-{arch}",
),
out_dir.join(name),
)
})
.filter(|(_, dest)| {
let cached = dest.exists();
if cached {
eprintln!("Using cached binary at: {}", dest.display());
}
!cached
})
.collect();

// The binary inside the tgz is at:
// release-{version}-{arch}/firecracker-{version}-{arch}
let entry_name = format!(
"release-{DEFAULT_FIRECRACKER_VERSION}-{arch}/firecracker-{DEFAULT_FIRECRACKER_VERSION}-{arch}",
);
if !wanted.is_empty() {
let url = format!(
"https://github.com/firecracker-microvm/firecracker/releases/download/{DEFAULT_FIRECRACKER_VERSION}/firecracker-{DEFAULT_FIRECRACKER_VERSION}-{arch}.tgz",
);
let expected_hash = match arch {
"x86_64" => FIRECRACKER_TGZ_SHA256_X86_64,
"aarch64" => FIRECRACKER_TGZ_SHA256_AARCH64,
_ => unreachable!(),
};

eprintln!("Downloading the Firecracker release from: {url}");
if let Err(e) = download_and_extract_tgz(&url, &wanted, Some(expected_hash)) {
eprintln!("WARNING: Failed to download/extract the Firecracker release: {e}");
}
}

let expected_hash = match arch {
"x86_64" => FIRECRACKER_TGZ_SHA256_X86_64,
"aarch64" => FIRECRACKER_TGZ_SHA256_AARCH64,
_ => unreachable!(),
let resolved = |overridden: Option<PathBuf>, name: &str| {
overridden.or_else(|| {
let dest = out_dir.join(name);
dest.exists().then_some(dest)
})
};

eprintln!("Downloading firecracker from: {url}");
match download_and_extract_tgz(&url, &entry_name, &dest, Some(expected_hash)) {
Ok(()) => {
eprintln!("Extracted firecracker to: {}", dest.display());
Some(dest)
},
Err(e) => {
eprintln!("WARNING: Failed to download/extract firecracker: {e}");
None
},
(
resolved(firecracker_override, "firecracker"),
resolved(jailer_override, "jailer"),
)
}

/// Resolve a build-time binary path override from an env var.
fn binary_path_override(name: &str, var: &str) -> Option<PathBuf> {
let path = PathBuf::from(env::var(var).ok()?);
if path.exists() {
eprintln!("Using {name} from {var}: {}", path.display());
Some(path)
} else {
eprintln!("WARNING: {var} set but file not found: {}", path.display());
None
}
}

Expand Down Expand Up @@ -351,18 +381,17 @@ fn download_file(url: &str, dest: &Path, expected_sha256: Option<&str>) -> Resul
Ok(())
}

/// Download a `.tgz` archive and extract a single file from it.
/// Download a `.tgz` archive and extract the requested files from it.
///
/// # Arguments
///
/// * `url` - URL of the `.tgz` archive
/// * `entry_name` - Path of the entry to extract (e.g., `release-v1.15.1-x86_64/firecracker-v1.15.1-x86_64`)
/// * `dest` - Destination path for the extracted file
/// * `wanted` - `(entry_name, dest)` pairs, where `entry_name` is the path of
/// the entry inside the archive (e.g., `release-v1.15.1-x86_64/firecracker-v1.15.1-x86_64`)
/// * `expected_sha256` - If `Some`, verify the archive's SHA256 before extracting
fn download_and_extract_tgz(
url: &str,
entry_name: &str,
dest: &Path,
wanted: &[(String, PathBuf)],
expected_sha256: Option<&str>,
) -> Result<(), String> {
let response = ureq::get(url)
Expand Down Expand Up @@ -392,6 +421,13 @@ fn download_and_extract_tgz(
let gz = flate2::read::GzDecoder::new(archive_bytes.as_slice());
let mut archive = tar::Archive::new(gz);

// The names still to be found, not a count of them. A tar archive may
// legally carry the same path more than once, and counting extractions down
// gets that wrong in both directions: a third match against two wanted
// entries underflows and panics with a message that says nothing about the
// archive, and two matches on the same entry reach zero and report success
// while the other is still missing.
let mut outstanding: Vec<&str> = wanted.iter().map(|(name, _)| name.as_str()).collect();
for entry in archive
.entries()
.map_err(|e| format!("Failed to read tar entries: {e}"))?
Expand All @@ -400,19 +436,32 @@ fn download_and_extract_tgz(
let path = entry
.path()
.map_err(|e| format!("Failed to read entry path: {e}"))?;

if path.to_string_lossy() == entry_name {
let mut bytes = Vec::new();
entry
.read_to_end(&mut bytes)
.map_err(|e| format!("Failed to read entry data: {e}"))?;
fs::write(dest, &bytes)
.map_err(|e| format!("Failed to write to {}: {e}", dest.display()))?;
let path = path.to_string_lossy().into_owned();

let Some((_, dest)) = wanted.iter().find(|(name, _)| *name == path) else {
continue;
};

let mut bytes = Vec::new();
entry
.read_to_end(&mut bytes)
.map_err(|e| format!("Failed to read entry data: {e}"))?;
fs::write(dest, &bytes)
.map_err(|e| format!("Failed to write to {}: {e}", dest.display()))?;
eprintln!("Extracted '{path}' to: {}", dest.display());
outstanding.retain(|name| *name != path);
if outstanding.is_empty() {
return Ok(());
}
}

Err(format!("Entry '{entry_name}' not found in archive"))
let missing = wanted
.iter()
.filter(|(_, dest)| !dest.exists())
.map(|(name, _)| name.as_str())
.collect::<Vec<_>>()
.join(", ");
Err(format!("Entries not found in archive: {missing}"))
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -489,7 +538,7 @@ pub const {name_upper}_BUNDLED: bool = true;
fn generate_stub_modules() {
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR not set"));

for name in &["init", "firecracker", "kernel"] {
for name in &["init", "firecracker", "jailer", "kernel"] {
generate_stub_module(name, &out_dir);
}
}
Expand Down
Loading
Loading