Skip to content

Commit 5caca6b

Browse files
committed
oci: test checkpoint of a running container
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent 3a0cb6a commit 5caca6b

1 file changed

Lines changed: 248 additions & 13 deletions

File tree

crates/sandlock-oci/tests/integration.rs

Lines changed: 248 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -269,19 +269,7 @@ async fn oci_restore_resumes_vdso_free_program() {
269269
let counter_s = counter.to_str().unwrap().to_string();
270270
let image = tmp.join("image");
271271

272-
fs::write(&src, counter_source(&counter_s)).unwrap();
273-
274-
let cc = if which("cc") { "cc" } else if which("gcc") { "gcc" } else {
275-
eprintln!("skipping: no C compiler (cc/gcc) available");
276-
let _ = fs::remove_dir_all(&tmp);
277-
return;
278-
};
279-
let build = Command::new(cc)
280-
.args(["-static", "-nostdlib", "-no-pie", "-O0", "-o"])
281-
.arg(&bin).arg(&src)
282-
.output().unwrap();
283-
if !build.status.success() {
284-
eprintln!("skipping: build failed: {}", String::from_utf8_lossy(&build.stderr));
272+
if !build_counter(&bin, &src, &counter_s) {
285273
let _ = fs::remove_dir_all(&tmp);
286274
return;
287275
}
@@ -383,6 +371,253 @@ async fn oci_restore_resumes_vdso_free_program() {
383371
);
384372
}
385373

374+
/// Build the freestanding, vDSO-free counter program (shared with the restore
375+
/// test) into `bin`, writing its counter to the in-sandbox path `out_path`.
376+
/// Returns false (and prints a skip reason) when no C compiler is available or
377+
/// the build fails, so callers can early-return on unsupported hosts.
378+
fn build_counter(bin: &Path, src: &Path, out_path: &str) -> bool {
379+
fs::write(src, counter_source(out_path)).unwrap();
380+
let cc = if which("cc") {
381+
"cc"
382+
} else if which("gcc") {
383+
"gcc"
384+
} else {
385+
eprintln!("skipping: no C compiler (cc/gcc) available");
386+
return false;
387+
};
388+
let build = Command::new(cc)
389+
.args(["-static", "-nostdlib", "-no-pie", "-O0", "-o"])
390+
.arg(bin)
391+
.arg(src)
392+
.output()
393+
.unwrap();
394+
if !build.status.success() {
395+
eprintln!(
396+
"skipping: build failed: {}",
397+
String::from_utf8_lossy(&build.stderr)
398+
);
399+
return false;
400+
}
401+
true
402+
}
403+
404+
/// End-to-end proof that `sandlock-oci checkpoint` works on a RUNNING container
405+
/// created + started from an OCI bundle.
406+
///
407+
/// Before the supervisor fix, `supervisor_main` stopped serving the control
408+
/// socket once the child started (it only `wait()`ed), so a `checkpoint` of a
409+
/// running container could not be reached and timed out. This test creates +
410+
/// starts a sandbox running the vDSO-free counter, waits for it to advance
411+
/// (proving it is genuinely RUNNING), then checkpoints it and asserts the
412+
/// checkpoint image (`meta.json`) was written. As a bonus it then `restore`s the
413+
/// image into a second container and proves the restored counter advances,
414+
/// exercising a full OCI checkpoint -> restore round-trip of a running program.
415+
#[tokio::test(flavor = "multi_thread")]
416+
async fn oci_checkpoint_of_running_container() {
417+
if cfg!(not(target_arch = "x86_64")) {
418+
eprintln!("skipping: injection-based checkpoint/restore is x86_64-only");
419+
return;
420+
}
421+
if sandlock_core::landlock_abi_version().is_err() {
422+
eprintln!("skipping: Landlock unavailable on this host");
423+
return;
424+
}
425+
426+
let tmp = std::env::temp_dir().join(format!("sandlock-oci-ckpt-{}", std::process::id()));
427+
fs::create_dir_all(&tmp).unwrap();
428+
let src = tmp.join("counter.c");
429+
let bin = tmp.join("counter");
430+
431+
// The container chroots to `rootfs`, so the counter's in-sandbox path
432+
// `/out.cnt` resolves to `rootfs/out.cnt` on the host.
433+
if !build_counter(&bin, &src, "/out.cnt") {
434+
let _ = fs::remove_dir_all(&tmp);
435+
return;
436+
}
437+
438+
// Build the OCI bundle: the freestanding binary lives inside rootfs and the
439+
// spec runs it via its in-chroot path.
440+
let bundle = tmp.join("bundle");
441+
let rootfs = bundle.join("rootfs");
442+
fs::create_dir_all(&rootfs).unwrap();
443+
let bin_in_rootfs = rootfs.join("counter");
444+
fs::copy(&bin, &bin_in_rootfs).unwrap();
445+
{
446+
use std::os::unix::fs::PermissionsExt;
447+
fs::set_permissions(&bin_in_rootfs, fs::Permissions::from_mode(0o755)).unwrap();
448+
}
449+
create_bundle(&bundle, &["/counter"]);
450+
451+
let host_counter = rootfs.join("out.cnt");
452+
let host_counter_s = host_counter.to_str().unwrap().to_string();
453+
let read_counter = |path: &str| -> Option<u64> {
454+
fs::read_to_string(path)
455+
.ok()
456+
.and_then(|s| s.trim().parse::<u64>().ok())
457+
};
458+
459+
let root = tempdir().unwrap();
460+
let root_s = root.path().to_str().unwrap().to_string();
461+
let id = "oci-ckpt-running";
462+
let image = tmp.join("image");
463+
464+
// ── create (daemonizes a supervisor that inherits stdio; redirect + status) ─
465+
let create_log = tmp.join("create.log");
466+
let create_status = Command::new(oci_bin())
467+
.args(["--root", &root_s, "create", id, "-b", bundle.to_str().unwrap()])
468+
.stdout(std::process::Stdio::from(fs::File::create(&create_log).unwrap()))
469+
.stderr(std::process::Stdio::from(
470+
fs::OpenOptions::new().append(true).open(&create_log).unwrap(),
471+
))
472+
.status()
473+
.expect("failed to run sandlock-oci create");
474+
let create_out = fs::read_to_string(&create_log).unwrap_or_default();
475+
assert!(
476+
create_status.success(),
477+
"create CLI failed (exit {:?}): {}",
478+
create_status.code(),
479+
create_out
480+
);
481+
482+
// ── start (releases the parked child to execve) ─────────────────────────────
483+
let start_out = Command::new(oci_bin())
484+
.args(["--root", &root_s, "start", id])
485+
.output()
486+
.expect("failed to run sandlock-oci start");
487+
if !start_out.status.success() {
488+
let _ = Command::new(oci_bin())
489+
.args(["--root", &root_s, "delete", id, "--force"])
490+
.output();
491+
let _ = fs::remove_dir_all(&tmp);
492+
panic!(
493+
"start CLI failed: {}",
494+
String::from_utf8_lossy(&start_out.stderr)
495+
);
496+
}
497+
498+
// Poll until the running container's counter advances (proves it is RUNNING).
499+
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
500+
let mut baseline = 0u64;
501+
let mut running = false;
502+
while std::time::Instant::now() < deadline {
503+
if let Some(v) = read_counter(&host_counter_s) {
504+
if v > 2 {
505+
baseline = v;
506+
running = true;
507+
break;
508+
}
509+
}
510+
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
511+
}
512+
513+
// ── checkpoint the RUNNING container ───────────────────────────────────────
514+
let ckpt_out = if running {
515+
Some(
516+
Command::new(oci_bin())
517+
.args(["--root", &root_s, "checkpoint", id, "--image-path", image.to_str().unwrap()])
518+
.output()
519+
.expect("failed to run sandlock-oci checkpoint"),
520+
)
521+
} else {
522+
None
523+
};
524+
let ckpt_ok = ckpt_out.as_ref().map(|o| o.status.success()).unwrap_or(false);
525+
let meta_exists = image.join("meta.json").exists();
526+
527+
// ── bonus: restore the checkpoint into a second container ───────────────────
528+
let id2 = "oci-ckpt-restored";
529+
let mut restored_advanced = None::<bool>;
530+
let mut restore_diag = String::new();
531+
if ckpt_ok && meta_exists {
532+
// Stop the original so only a restored process can advance the file, and
533+
// drop a low sentinel to prove the restored process (not a leftover) writes.
534+
let _ = Command::new(oci_bin())
535+
.args(["--root", &root_s, "delete", id, "--force"])
536+
.output();
537+
fs::write(&host_counter, b"0\n").unwrap();
538+
539+
let restore_log = tmp.join("restore.log");
540+
let restore_status = Command::new(oci_bin())
541+
.args(["--root", &root_s, "restore", id2, "--image-path", image.to_str().unwrap()])
542+
.stdout(std::process::Stdio::from(fs::File::create(&restore_log).unwrap()))
543+
.stderr(std::process::Stdio::from(
544+
fs::OpenOptions::new().append(true).open(&restore_log).unwrap(),
545+
))
546+
.status()
547+
.expect("failed to run sandlock-oci restore");
548+
if restore_status.success() {
549+
let rdl = std::time::Instant::now() + std::time::Duration::from_secs(5);
550+
let mut adv = false;
551+
while std::time::Instant::now() < rdl {
552+
if let Some(v) = read_counter(&host_counter_s) {
553+
if v > baseline {
554+
adv = true;
555+
break;
556+
}
557+
}
558+
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
559+
}
560+
restored_advanced = Some(adv);
561+
restore_diag = format!(
562+
"restore_status ok; last_counter={:?}; log: {}",
563+
read_counter(&host_counter_s),
564+
fs::read_to_string(&restore_log).unwrap_or_default()
565+
);
566+
} else {
567+
restored_advanced = Some(false);
568+
restore_diag = format!(
569+
"restore_status FAILED; log: {}",
570+
fs::read_to_string(&restore_log).unwrap_or_default()
571+
);
572+
}
573+
}
574+
575+
// ── clean up before asserting so a failure never leaks a process ────────────
576+
let _ = Command::new(oci_bin())
577+
.args(["--root", &root_s, "delete", id, "--force"])
578+
.output();
579+
let _ = Command::new(oci_bin())
580+
.args(["--root", &root_s, "delete", id2, "--force"])
581+
.output();
582+
let _ = fs::remove_dir_all(&tmp);
583+
584+
assert!(
585+
running,
586+
"container counter never advanced; create_out: {create_out}"
587+
);
588+
let ckpt_stderr = ckpt_out
589+
.as_ref()
590+
.map(|o| String::from_utf8_lossy(&o.stderr).to_string())
591+
.unwrap_or_default();
592+
assert!(
593+
ckpt_ok,
594+
"checkpoint of a RUNNING container must succeed; stderr: {ckpt_stderr}"
595+
);
596+
assert!(
597+
meta_exists,
598+
"checkpoint must write meta.json to the image dir"
599+
);
600+
// Bonus (non-fatal): a full OCI checkpoint -> restore round-trip. The
601+
// checkpoint-of-running assertions above are the required deliverable. The
602+
// restore engine reopens fds/mappings by their recorded HOST path, which
603+
// collides with the virtual-chroot path rewriting of a bundle-based
604+
// container (the binary at `<rootfs>/counter` gets re-confined under the
605+
// restored chroot and Landlock denies it with EACCES). The standalone
606+
// `oci_restore_resumes_vdso_free_program` test covers restore on its own,
607+
// chroot-free; restore of a *chrooted* checkpoint is a separate limitation
608+
// outside the scope of the serve-while-running fix, so we only report it.
609+
if let Some(adv) = restored_advanced {
610+
if adv {
611+
eprintln!("bonus: full OCI checkpoint -> restore round-trip advanced the counter");
612+
} else {
613+
eprintln!(
614+
"note: bonus round-trip restore of a chrooted checkpoint did not advance \
615+
(restore-under-chroot limitation, orthogonal to this fix): {restore_diag}"
616+
);
617+
}
618+
}
619+
}
620+
386621
/// Minimal PATH lookup so the test does not depend on extra crates.
387622
fn which(prog: &str) -> bool {
388623
std::env::var_os("PATH").map_or(false, |paths| {

0 commit comments

Comments
 (0)