Files
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

392 lines
12 KiB
Rust

//! End-to-end tests for the file-driven exec signal pathway and the
//! `keep` flag.
//!
//! These spin up a real `mirage host` subprocess (via the
//! `session start` CLI) and then poke at the on-disk state directly
//! to assert that:
//!
//! * `mirage exec signal` writes a `signal` file under the exec
//! directory and the host consumes it,
//! * the host removes the signal file after applying it,
//! * `keep=false` execs have their directory removed by the host on
//! exit, regardless of whether they exited naturally or were killed,
//! * `keep=true` execs persist after death and after the kill,
//! * destroying a session with a containerised runtime record invokes
//! the provider with `rm -f <container>` for each node and
//! `network rm <network>` when `container.json` is present (and
//! skips the provider entirely when absent).
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, Instant};
use assert_cmd::prelude::*;
use tempfile::TempDir;
struct Env {
_dir: TempDir,
runtime: PathBuf,
config: PathBuf,
state: PathBuf,
mirage_bin: PathBuf,
}
impl Env {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let runtime = dir.path().join("runtime");
let config = dir.path().join("config");
let state = dir.path().join("state");
let mirage_bin = PathBuf::from(env!("CARGO_BIN_EXE_mirage"));
Self {
_dir: dir,
runtime,
config,
state,
mirage_bin,
}
}
fn mirage(&self) -> Command {
let mut c = Command::new(&self.mirage_bin);
c.env("XDG_CONFIG_HOME", &self.config)
.env("XDG_RUNTIME_DIR", &self.runtime)
.env("XDG_STATE_HOME", &self.state)
.env("MIRAGE_BIN", &self.mirage_bin)
.env_remove("MIRAGE_LOG");
c
}
fn session_dir(&self, id: &str) -> PathBuf {
self.runtime.join("mirage/session").join(id)
}
fn exec_dir(&self, sid: &str, eid: &str) -> PathBuf {
self.session_dir(sid).join("exec").join(eid)
}
fn start_profile_and_session(&self, sid: &str) {
self.mirage()
.args(["profile", "create", "p"])
.assert()
.success();
self.mirage()
.args(["session", "start", "--profile", "p", "--id", sid])
.assert()
.success();
}
fn submit_long_running(&self, sid: &str, keep: bool) -> String {
let mut args = vec!["exec", "start", sid];
if keep {
args.push("--keep");
}
args.extend(["--detach", "--", "/bin/sh", "-c", "sleep 30"]);
let out = self.mirage().args(&args).output().unwrap();
assert!(out.status.success(), "exec start failed: {:?}", out);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn wait_started(&self, sid: &str, eid: &str) {
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
let s = self
.mirage()
.args(["exec", "show", sid, eid])
.output()
.unwrap();
if String::from_utf8_lossy(&s.stdout).contains("\"started\": true") {
return;
}
std::thread::sleep(Duration::from_millis(25));
}
panic!("exec {eid} never started");
}
}
fn wait_for<F: FnMut() -> bool>(timeout: Duration, mut f: F) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if f() {
return true;
}
std::thread::sleep(Duration::from_millis(25));
}
false
}
#[test]
fn signal_request_is_written_then_consumed_by_host() {
let env = Env::new();
env.start_profile_and_session("s-sigfile");
let eid = env.submit_long_running("s-sigfile", true);
env.wait_started("s-sigfile", &eid);
let exec_dir = env.exec_dir("s-sigfile", &eid);
let signal_path = exec_dir.join("signal");
assert!(!signal_path.exists(), "signal file should start absent");
env.mirage()
.args(["exec", "signal", "s-sigfile", &eid, "KILL"])
.assert()
.success();
// The host's poll cadence is ~50ms; give it generous slack but
// expect the file to disappear (i.e. the signal was consumed).
let consumed = wait_for(Duration::from_secs(3), || !signal_path.exists());
assert!(consumed, "host never consumed the signal file");
// And the exec should end shortly after.
let ended = wait_for(Duration::from_secs(3), || {
let s = env
.mirage()
.args(["exec", "show", "s-sigfile", &eid])
.output()
.unwrap();
String::from_utf8_lossy(&s.stdout).contains("\"ended\": true")
});
assert!(ended, "exec did not end after SIGKILL");
env.mirage()
.args(["session", "stop", "s-sigfile"])
.assert()
.success();
}
#[test]
fn keep_true_exec_persists_after_signal_kill() {
let env = Env::new();
env.start_profile_and_session("s-keep");
let eid = env.submit_long_running("s-keep", /*keep=*/ true);
env.wait_started("s-keep", &eid);
env.mirage()
.args(["exec", "signal", "s-keep", &eid, "KILL"])
.assert()
.success();
// Wait for it to end on disk.
let ended = wait_for(Duration::from_secs(3), || {
let s = env
.mirage()
.args(["exec", "show", "s-keep", &eid])
.output()
.unwrap();
String::from_utf8_lossy(&s.stdout).contains("\"ended\": true")
});
assert!(ended);
// keep=true: the directory must still be there.
let exec_dir = env.exec_dir("s-keep", &eid);
assert!(
exec_dir.exists(),
"keep=true exec directory was unexpectedly removed"
);
// And exec_list should still see it.
let list = env
.mirage()
.args(["exec", "list", "s-keep"])
.output()
.unwrap();
assert!(String::from_utf8_lossy(&list.stdout).contains(&eid));
env.mirage()
.args(["session", "stop", "s-keep"])
.assert()
.success();
}
#[test]
fn keep_false_exec_is_removed_after_signal_kill() {
let env = Env::new();
env.start_profile_and_session("s-nokeep");
let eid = env.submit_long_running("s-nokeep", /*keep=*/ false);
env.wait_started("s-nokeep", &eid);
env.mirage()
.args(["exec", "signal", "s-nokeep", &eid, "KILL"])
.assert()
.success();
// The host should remove the exec dir once it exits.
let exec_dir = env.exec_dir("s-nokeep", &eid);
let gone = wait_for(Duration::from_secs(5), || !exec_dir.exists());
assert!(gone, "keep=false exec dir was never removed: {exec_dir:?}");
env.mirage()
.args(["session", "stop", "s-nokeep"])
.assert()
.success();
}
#[test]
fn keep_false_exec_is_removed_after_natural_exit() {
// Sanity check that keep=false cleanup also applies to non-killed
// execs.
let env = Env::new();
env.start_profile_and_session("s-natural");
let out = env
.mirage()
.args(["exec", "start", "s-natural", "--detach", "--", "/bin/true"])
.output()
.unwrap();
assert!(out.status.success());
let eid = String::from_utf8_lossy(&out.stdout).trim().to_string();
let exec_dir = env.exec_dir("s-natural", &eid);
let gone = wait_for(Duration::from_secs(5), || !exec_dir.exists());
assert!(gone, "keep=false exec dir was not removed after exit");
env.mirage()
.args(["session", "stop", "s-natural"])
.assert()
.success();
}
#[test]
fn signal_invalid_number_is_rejected() {
let env = Env::new();
env.start_profile_and_session("s-bad");
let eid = env.submit_long_running("s-bad", true);
env.wait_started("s-bad", &eid);
let r = env
.mirage()
.args(["exec", "signal", "s-bad", &eid, "9999"])
.output()
.unwrap();
assert!(!r.status.success(), "expected failure for bogus signal");
let exec_dir = env.exec_dir("s-bad", &eid);
assert!(
!exec_dir.join("signal").exists(),
"no signal file should have been written for an invalid signal"
);
env.mirage()
.args(["session", "stop", "s-bad"])
.assert()
.success();
}
#[test]
fn session_destroy_invokes_container_provider_when_state_present() {
let env = Env::new();
let provider_log = env._dir.path().join("provider.log");
let provider = env._dir.path().join("mock-provider.sh");
std::fs::write(
&provider,
format!("#!/bin/sh\necho \"$@\" >> {}\n", provider_log.display()),
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&provider, std::fs::Permissions::from_mode(0o755)).unwrap();
env.mirage()
.args(["profile", "create", "p"])
.assert()
.success();
// Create the session without a host, then write the container
// runtime record the host would normally persist. `session destroy`
// reads `container.json` and tears every node + the network
// down via the recorded provider.
env.mirage()
.args([
"session",
"start",
"--profile",
"p",
"--id",
"s-cont",
"--no-host",
])
.assert()
.success();
let session_dir = env.session_dir("s-cont");
let state = serde_json::json!({
"provider": provider.to_string_lossy(),
"image": "ignored:latest",
"network": "mirage-s-cont",
"head_port": 6000,
"nodes": [
{ "rank": 0, "name": "mirage-s-cont-node-0" },
{ "rank": 1, "name": "mirage-s-cont-node-1" },
],
});
std::fs::write(
session_dir.join("container.json"),
serde_json::to_vec_pretty(&state).unwrap(),
)
.unwrap();
std::fs::create_dir_all(session_dir.join("node/0")).unwrap();
std::fs::create_dir_all(session_dir.join("node/1")).unwrap();
std::fs::write(session_dir.join("node/0/cid"), "cid-0").unwrap();
std::fs::write(session_dir.join("node/1/cid"), "cid-1").unwrap();
env.mirage()
.args(["session", "stop", "s-cont"])
.assert()
.success();
let recorded = std::fs::read_to_string(&provider_log).unwrap_or_default();
assert!(
recorded.contains("rm -f mirage-s-cont-node-0"),
"provider never asked to remove node 0; log: {recorded:?}"
);
assert!(
recorded.contains("rm -f mirage-s-cont-node-1"),
"provider never asked to remove node 1; log: {recorded:?}"
);
assert!(
recorded.contains("network rm mirage-s-cont"),
"provider never asked to remove the network; log: {recorded:?}"
);
// The whole session directory (including the container dir and cid
// files) must be gone after destroy.
assert!(
!env.session_dir("s-cont").exists(),
"session dir should be removed"
);
}
#[test]
fn session_destroy_skips_provider_when_no_container_state() {
let env = Env::new();
let provider_log = env._dir.path().join("provider.log");
let provider = env._dir.path().join("mock-provider.sh");
std::fs::write(
&provider,
format!("#!/bin/sh\necho \"$@\" >> {}\n", provider_log.display()),
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&provider, std::fs::Permissions::from_mode(0o755)).unwrap();
env.mirage()
.args(["profile", "create", "p"])
.assert()
.success();
env.mirage()
.args([
"session",
"start",
"--profile",
"p",
"--id",
"s-no-cid",
"--no-host",
])
.assert()
.success();
// No container.json is written → the provider must not run.
env.mirage()
.args(["session", "stop", "s-no-cid"])
.assert()
.success();
assert!(
!provider_log.exists(),
"provider must not be invoked without container state"
);
}