Skip to content

Commit a363f03

Browse files
feat(repx-rs): add bwrap overlay fallback for kernels < 5.11 (#10)
1 parent 0a37f3b commit a363f03

File tree

8 files changed

+364
-9
lines changed

8 files changed

+364
-9
lines changed

.github/workflows/main.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ jobs:
6464
- e2e-mount-paths-docker
6565
- incremental-sync
6666
- large_lab
67+
- e2e-bwrap-overlay-fallback
6768
steps:
6869
- uses: actions/checkout@v4
6970
- uses: DeterminateSystems/nix-installer-action@v11

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/repx-executor/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ nix = { workspace = true, features = ["fs"] }
1212
tempfile = { workspace = true }
1313
serde = { workspace = true }
1414
serde_json = { workspace = true }
15+
chrono = { workspace = true }

crates/repx-executor/src/context.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,14 @@ impl<'a> RuntimeContext<'a> {
129129
}
130130
}
131131

132+
pub fn get_capabilities_cache_dir(&self) -> PathBuf {
133+
if let Some(local) = &self.request.node_local_path {
134+
local.join("repx").join("cache").join("capabilities")
135+
} else {
136+
self.request.base_path.join("cache").join("capabilities")
137+
}
138+
}
139+
132140
pub fn calculate_restricted_path(
133141
&self,
134142
required_system_binaries: &[&str],

crates/repx-executor/src/runtime/bwrap.rs

Lines changed: 173 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@ use crate::context::RuntimeContext;
22
use crate::error::{ExecutorError, Result};
33
use crate::ExecutionRequest;
44
use nix::fcntl::{Flock, FlockArg};
5-
use serde::Deserialize;
5+
use serde::{Deserialize, Serialize};
66
use std::path::{Path, PathBuf};
77
use std::process::Stdio;
88
use tokio::process::Command as TokioCommand;
99

10+
const EXCLUDED_ROOTFS_DIRS: &[&str] = &["dev", "proc", "tmp"];
11+
12+
#[derive(Debug, Serialize, Deserialize)]
13+
struct OverlayCapabilityCache {
14+
tmp_overlay_supported: bool,
15+
checked_at: String,
16+
}
17+
1018
pub struct BwrapRuntime;
1119

1220
const EXCLUDED_HOST_DIRS: &[&str] = &["dev", "proc", "sys", "nix"];
@@ -212,7 +220,20 @@ impl BwrapRuntime {
212220
Ok(union_dir)
213221
}
214222

215-
pub async fn check_overlay_support(ctx: &RuntimeContext<'_>, lower_dir: &Path) -> bool {
223+
pub async fn check_overlay_support(ctx: &RuntimeContext<'_>, _lower_dir: &Path) -> bool {
224+
let cache_dir = ctx.get_capabilities_cache_dir();
225+
let cache_file = cache_dir.join("overlay_support.json");
226+
227+
if let Ok(content) = tokio::fs::read_to_string(&cache_file).await {
228+
if let Ok(cached) = serde_json::from_str::<OverlayCapabilityCache>(&content) {
229+
return cached.tmp_overlay_supported;
230+
}
231+
}
232+
233+
Self::run_overlay_check(ctx).await
234+
}
235+
236+
async fn run_overlay_check(ctx: &RuntimeContext<'_>) -> bool {
216237
let bwrap_path = match ctx.get_host_tool_path("bwrap") {
217238
Ok(p) => p,
218239
Err(_) => return false,
@@ -234,10 +255,12 @@ impl BwrapRuntime {
234255
let upper = start_path.join("upper");
235256
let work = start_path.join("work");
236257
let merged = start_path.join("merged");
258+
let lower = start_path.join("lower");
237259

238260
if tokio::fs::create_dir(&upper).await.is_err()
239261
|| tokio::fs::create_dir(&work).await.is_err()
240262
|| tokio::fs::create_dir(&merged).await.is_err()
263+
|| tokio::fs::create_dir(&lower).await.is_err()
241264
{
242265
return false;
243266
}
@@ -249,7 +272,7 @@ impl BwrapRuntime {
249272
.arg("/")
250273
.arg("/")
251274
.arg("--overlay-src")
252-
.arg(lower_dir)
275+
.arg(&lower)
253276
.arg("--overlay")
254277
.arg(&upper)
255278
.arg(&work)
@@ -265,6 +288,103 @@ impl BwrapRuntime {
265288
}
266289
}
267290

291+
pub async fn check_tmp_overlay_support(ctx: &RuntimeContext<'_>, rootfs_path: &Path) -> bool {
292+
let cache_dir = ctx.get_capabilities_cache_dir();
293+
let cache_file = cache_dir.join("overlay_support.json");
294+
295+
if let Ok(content) = tokio::fs::read_to_string(&cache_file).await {
296+
if let Ok(cached) = serde_json::from_str::<OverlayCapabilityCache>(&content) {
297+
tracing::debug!(
298+
"Using cached overlay support result: supported={}",
299+
cached.tmp_overlay_supported
300+
);
301+
return cached.tmp_overlay_supported;
302+
}
303+
}
304+
305+
let supported = Self::run_tmp_overlay_check(ctx, rootfs_path).await;
306+
307+
if let Err(e) = tokio::fs::create_dir_all(&cache_dir).await {
308+
tracing::debug!("Failed to create capabilities cache dir: {}", e);
309+
} else {
310+
let cache_entry = OverlayCapabilityCache {
311+
tmp_overlay_supported: supported,
312+
checked_at: chrono::Utc::now().to_rfc3339(),
313+
};
314+
if let Ok(json) = serde_json::to_string_pretty(&cache_entry) {
315+
if let Err(e) = tokio::fs::write(&cache_file, json).await {
316+
tracing::debug!("Failed to write overlay capability cache: {}", e);
317+
}
318+
}
319+
}
320+
321+
supported
322+
}
323+
324+
async fn run_tmp_overlay_check(ctx: &RuntimeContext<'_>, rootfs_path: &Path) -> bool {
325+
let bwrap_path = match ctx.get_host_tool_path("bwrap") {
326+
Ok(p) => p,
327+
Err(_) => return false,
328+
};
329+
330+
let temp_base = ctx.get_temp_path();
331+
let temp_dir = match tempfile::Builder::new()
332+
.prefix(".repx-tmp-overlay-check-")
333+
.tempdir_in(&temp_base)
334+
{
335+
Ok(t) => t,
336+
Err(e) => {
337+
tracing::debug!("Failed to create temp dir for tmp-overlay check: {}", e);
338+
return false;
339+
}
340+
};
341+
342+
let test_lower = if rootfs_path.join("bin").exists() {
343+
rootfs_path.join("bin")
344+
} else if rootfs_path.join("etc").exists() {
345+
rootfs_path.join("etc")
346+
} else {
347+
rootfs_path.to_path_buf()
348+
};
349+
350+
let test_mount_point = temp_dir.path().join("test");
351+
if tokio::fs::create_dir(&test_mount_point).await.is_err() {
352+
return false;
353+
}
354+
355+
let mut cmd = TokioCommand::new(&bwrap_path);
356+
357+
cmd.arg("--unshare-user")
358+
.arg("--dev-bind")
359+
.arg("/")
360+
.arg("/")
361+
.arg("--overlay-src")
362+
.arg(&test_lower)
363+
.arg("--tmp-overlay")
364+
.arg(&test_mount_point)
365+
.arg("true");
366+
367+
ctx.restrict_command_environment(&mut cmd, &[]);
368+
cmd.stdout(Stdio::null()).stderr(Stdio::null());
369+
370+
match cmd.status().await {
371+
Ok(status) => {
372+
let supported = status.success();
373+
if !supported {
374+
tracing::debug!(
375+
"tmp-overlay check failed for {:?} - kernel may not support userxattr overlay",
376+
test_lower
377+
);
378+
}
379+
supported
380+
}
381+
Err(e) => {
382+
tracing::debug!("tmp-overlay check command failed: {}", e);
383+
false
384+
}
385+
}
386+
}
387+
268388
pub async fn build_command(
269389
ctx: &RuntimeContext<'_>,
270390
rootfs_path: &Path,
@@ -290,12 +410,25 @@ impl BwrapRuntime {
290410
} else {
291411
cmd.arg("--unshare-all")
292412
.arg("--hostname")
293-
.arg("repx-container")
294-
.arg("--overlay-src")
295-
.arg(rootfs_path)
296-
.arg("--tmp-overlay")
297-
.arg("/")
298-
.arg("--dev")
413+
.arg("repx-container");
414+
415+
let overlay_supported = Self::check_tmp_overlay_support(ctx, rootfs_path).await;
416+
417+
if overlay_supported {
418+
cmd.arg("--overlay-src")
419+
.arg(rootfs_path)
420+
.arg("--tmp-overlay")
421+
.arg("/");
422+
} else {
423+
tracing::info!(
424+
"Overlay filesystem not supported on target (kernel may lack userxattr support). \
425+
Using read-only bind mounts for rootfs."
426+
);
427+
428+
Self::configure_readonly_rootfs_mounts(&mut cmd, rootfs_path).await?;
429+
}
430+
431+
cmd.arg("--dev")
299432
.arg("/dev")
300433
.arg("--proc")
301434
.arg("/proc")
@@ -497,4 +630,35 @@ impl BwrapRuntime {
497630

498631
Ok(())
499632
}
633+
634+
async fn configure_readonly_rootfs_mounts(
635+
cmd: &mut TokioCommand,
636+
rootfs_path: &Path,
637+
) -> Result<()> {
638+
let entries = match std::fs::read_dir(rootfs_path) {
639+
Ok(e) => e,
640+
Err(e) => {
641+
return Err(ExecutorError::Io(std::io::Error::new(
642+
std::io::ErrorKind::NotFound,
643+
format!("Failed to read rootfs directory {:?}: {}", rootfs_path, e),
644+
)));
645+
}
646+
};
647+
648+
for entry in entries.flatten() {
649+
let file_name = entry.file_name();
650+
let file_name_str = file_name.to_string_lossy();
651+
652+
if EXCLUDED_ROOTFS_DIRS.contains(&file_name_str.as_ref()) {
653+
continue;
654+
}
655+
656+
let source_path = entry.path();
657+
let target_path = format!("/{}", file_name_str);
658+
659+
cmd.arg("--ro-bind").arg(&source_path).arg(&target_path);
660+
}
661+
662+
Ok(())
663+
}
500664
}

crates/repx-runner/tests/bwrap_tests.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,87 @@
33
mod harness;
44
use harness::TestHarness;
55
use std::fs;
6+
7+
#[test]
8+
fn test_bwrap_overlay_fallback_when_disabled() {
9+
let harness = TestHarness::with_execution_type("bwrap");
10+
harness.stage_lab();
11+
12+
let base_path = &harness.cache_dir;
13+
14+
let capabilities_dir = base_path.join("cache").join("capabilities");
15+
fs::create_dir_all(&capabilities_dir).expect("Failed to create capabilities dir");
16+
17+
let cache_content = r#"{
18+
"tmp_overlay_supported": false,
19+
"checked_at": "2025-01-01T00:00:00Z"
20+
}"#;
21+
fs::write(capabilities_dir.join("overlay_support.json"), cache_content)
22+
.expect("Failed to write overlay cache");
23+
24+
let mut cmd = harness.cmd();
25+
cmd.arg("run").arg("simulation-run");
26+
27+
let output = cmd.output().expect("Failed to execute command");
28+
29+
println!("STDOUT: {}", String::from_utf8_lossy(&output.stdout));
30+
println!("STDERR: {}", String::from_utf8_lossy(&output.stderr));
31+
32+
let stage_e_job_id = harness.get_job_id_by_name("stage-E-total-sum");
33+
let stage_e_path = harness.get_job_output_path(&stage_e_job_id);
34+
35+
assert!(
36+
stage_e_path.join("repx/SUCCESS").exists(),
37+
"Job should succeed with overlay fallback"
38+
);
39+
40+
let total_sum_content = fs::read_to_string(stage_e_path.join("out/total_sum.txt")).unwrap();
41+
let val = total_sum_content.trim();
42+
assert!(
43+
val == "400" || val == "415",
44+
"Expected 400 or 415, got {}",
45+
val
46+
);
47+
48+
let cache_content_after =
49+
fs::read_to_string(capabilities_dir.join("overlay_support.json")).unwrap();
50+
assert!(
51+
cache_content_after.contains("\"tmp_overlay_supported\": false"),
52+
"Cache should still show overlay as unsupported"
53+
);
54+
}
55+
56+
#[test]
57+
fn test_bwrap_overlay_capability_detection() {
58+
let harness = TestHarness::with_execution_type("bwrap");
59+
harness.stage_lab();
60+
61+
let base_path = &harness.cache_dir;
62+
let capabilities_dir = base_path.join("cache").join("capabilities");
63+
64+
let _ = fs::remove_dir_all(&capabilities_dir);
65+
66+
let mut cmd = harness.cmd();
67+
cmd.arg("run").arg("simulation-run");
68+
cmd.assert().success();
69+
70+
let cache_file = capabilities_dir.join("overlay_support.json");
71+
assert!(
72+
cache_file.exists(),
73+
"Overlay capability cache should be created after first run"
74+
);
75+
76+
let cache_content = fs::read_to_string(&cache_file).unwrap();
77+
assert!(
78+
cache_content.contains("tmp_overlay_supported"),
79+
"Cache should contain overlay support status"
80+
);
81+
assert!(
82+
cache_content.contains("checked_at"),
83+
"Cache should contain timestamp"
84+
);
85+
}
86+
687
#[test]
788
fn test_full_run_local_bwrap() {
889
let harness = TestHarness::with_execution_type("bwrap");

nix/checks.nix

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ let
4949
incremental-sync = import ./checks/runtime/incremental-sync-test.nix {
5050
inherit pkgs repx referenceLab;
5151
};
52+
e2e-bwrap-overlay-fallback = import ./checks/runtime/e2e-bwrap-overlay-fallback.nix {
53+
inherit pkgs repx referenceLab;
54+
};
5255
};
5356

5457
libChecks = {

0 commit comments

Comments
 (0)