mod support;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use runner_manager_domain::attempt::AttemptState;
use runner_manager_domain::model::{AttemptId, PolicyId};
use runner_manager_domain::store::{SqliteStore, Store};
use runner_manager_testkit::fixtures;
use serde_json::Value;
use support::{Outcome, files_under, run, runner_manager};
fn command(data_dir: &Path, arguments: &[&str]) -> Outcome {
run({
let mut command = runner_manager(data_dir);
command.args(arguments);
command
})
}
fn ok(data_dir: &Path, arguments: &[&str]) -> String {
let outcome = command(data_dir, arguments);
assert_eq!(
outcome.code,
0,
"`{}` must succeed; stderr: {}",
arguments.join(" "),
outcome.stderr
);
outcome.stdout
}
fn status_json(data_dir: &Path) -> Value {
serde_json::from_str(&ok(data_dir, &["status", "--json"]))
.expect("`status --json` must emit parseable JSON")
}
fn database(data_dir: &Path) -> SqliteStore {
SqliteStore::open(data_dir.join("config").join("runner-manager.sqlite3"))
.expect("the commands above must have created the database")
}
fn field(text: &str, label: &str) -> String {
text.lines()
.find(|line| line.trim_start().starts_with(label))
.unwrap_or_else(|| panic!("`host show` must print a {label:?} row:\n{text}"))
.trim_start()
.trim_start_matches(label)
.trim()
.to_string()
}
fn a_repository(data_dir: &Path, slug: &str) -> PolicyId {
ok(data_dir, &["host", "set-capacity", "2"]);
let store = database(data_dir);
let host = store
.hosts()
.expect("the host table must be readable")
.pop()
.expect("`host set-capacity` creates this machine's row");
let id = PolicyId::new_random();
store
.insert_policy(
&fixtures::policy()
.id(id)
.host(host.id)
.autoscale("home-win", 2)
.repository(slug)
.active()
.build(),
)
.expect("a fresh policy id");
id
}
fn snapshot(root: &Path) -> BTreeMap<PathBuf, Option<Vec<u8>>> {
let mut found = BTreeMap::new();
let mut pending = vec![root.to_path_buf()];
while let Some(directory) = pending.pop() {
found.insert(directory.clone(), None);
let Ok(entries) = fs::read_dir(&directory) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.is_dir() => pending.push(path),
Ok(_) => {
let bytes = fs::read(&path).unwrap_or_default();
found.insert(path, Some(bytes));
}
Err(_) => {
found.insert(path, Some(Vec::new()));
}
}
}
}
found
}
fn assert_unchanged(before: &BTreeMap<PathBuf, Option<Vec<u8>>>, root: &Path, case: &str) {
let after = snapshot(root);
let appeared: Vec<_> = after
.keys()
.filter(|key| !before.contains_key(*key))
.collect();
let vanished: Vec<_> = before
.keys()
.filter(|key| !after.contains_key(*key))
.collect();
assert!(
appeared.is_empty(),
"{case}: a refusal created {appeared:?}; a path this product rejects must not \
have had its leaf built first"
);
assert!(
vanished.is_empty(),
"{case}: a refusal removed {vanished:?}; `03-migration-rollout.md` step 8 says \
no existing directory is moved or deleted"
);
assert!(
before == &after,
"{case}: a refusal rewrote a file inside the tree"
);
}
struct Adversarial {
case: &'static str,
path: String,
}
fn plant_link(inside: &Path) -> Option<PathBuf> {
let target = inside.join("link-target");
fs::create_dir_all(&target).expect("a temporary directory is creatable");
let link = inside.join("linked-root");
#[cfg(windows)]
{
let created = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(&link)
.arg(&target)
.output()
.ok()?;
if !created.status.success() {
return None;
}
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(&target, &link).ok()?;
}
link.exists().then_some(link)
}
fn adversarial_cases(scratch: &Path) -> Vec<Adversarial> {
let existing_file = scratch.join("a-file-not-a-directory");
fs::write(&existing_file, b"this is a file").expect("a temporary file is writable");
let mut cases = vec![
Adversarial {
case: "the filesystem root",
path: if cfg!(windows) { "C:\\" } else { "/" }.to_owned(),
},
Adversarial {
case: "a traversal component",
path: scratch
.join("here")
.join("..")
.join("there")
.to_string_lossy()
.into_owned(),
},
Adversarial {
case: "a UNC share",
path: if cfg!(windows) {
"\\\\nas\\builds".to_owned()
} else {
"//nas/builds".to_owned()
},
},
Adversarial {
case: "a relative path",
path: "build/runners".to_owned(),
},
Adversarial {
case: "an existing file",
path: existing_file.to_string_lossy().into_owned(),
},
];
if cfg!(windows) {
cases.push(Adversarial {
case: "the device namespace",
path: "\\\\?\\C:\\rman".to_owned(),
});
}
cases
}
#[test]
fn every_adversarial_root_is_refused_by_both_commands_and_changes_nothing() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let scratch = tempfile::tempdir().expect("a temporary directory");
a_repository(data_dir.path(), "octo/repo");
let mut cases = adversarial_cases(scratch.path());
match plant_link(scratch.path()) {
Some(link) => cases.push(Adversarial {
case: "a link-shaped root",
path: link.to_string_lossy().into_owned(),
}),
None => eprintln!(
"SKIP: this account may not create a link, so the link-shaped root case did not run"
),
}
for case in cases {
for arguments in [
vec!["host", "set-runtime-root", "--path", case.path.as_str()],
vec![
"repo",
"set-workspace",
"octo/repo",
"--mode",
"persistent",
"--path",
case.path.as_str(),
],
] {
let before_data = snapshot(data_dir.path());
let before_scratch = snapshot(scratch.path());
let outcome = command(data_dir.path(), &arguments);
assert_eq!(
outcome.code,
9,
"{} must refuse {} as an invalid argument; stdout: {} stderr: {}",
arguments.join(" "),
case.case,
outcome.stdout,
outcome.stderr
);
assert!(
outcome.stderr.contains("try: runner-manager "),
"{}: `05-user-workflows.md`'s sixth principle asks a path refusal to print \
the exact command that fixes it: {}",
case.case,
outcome.stderr
);
assert_unchanged(&before_data, data_dir.path(), case.case);
assert_unchanged(&before_scratch, scratch.path(), case.case);
}
let shown = ok(data_dir.path(), &["host", "show"]);
assert_eq!(
field(&shown, "runner root source"),
"platform-default",
"{}: a refused path must not become the configured one",
case.case
);
let document = status_json(data_dir.path());
assert_eq!(
document["policies"][0]["workspace_mode"],
Value::from("ephemeral"),
"{}: a refused path must not switch a repository into persistent mode",
case.case
);
}
}
#[test]
fn a_repository_root_may_not_be_carved_out_of_the_host_runner_root() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let roots = tempfile::tempdir().expect("a temporary directory");
a_repository(data_dir.path(), "octo/repo");
let host_root = roots.path().join("runners");
ok(
data_dir.path(),
&[
"host",
"set-runtime-root",
"--path",
host_root.to_str().expect("a UTF-8 temporary path"),
],
);
for (case, candidate) in [
("inside the host runner root", host_root.join("octo-repo")),
("the host runner root itself", host_root.clone()),
(
"an ancestor of the host runner root",
roots.path().to_path_buf(),
),
] {
let before = snapshot(roots.path());
let outcome = command(
data_dir.path(),
&[
"repo",
"set-workspace",
"octo/repo",
"--mode",
"persistent",
"--path",
candidate.to_str().expect("a UTF-8 temporary path"),
],
);
assert_eq!(
outcome.code, 9,
"a repository workspace {case} must be refused; stderr: {}",
outcome.stderr
);
assert_unchanged(&before, roots.path(), case);
}
assert!(
host_root.is_dir(),
"the configured host runner root must survive a refused repository root"
);
let shown = ok(data_dir.path(), &["host", "show"]);
assert_eq!(
field(&shown, "runner root"),
host_root.display().to_string()
);
}
#[test]
fn this_platform_keeps_its_default_root_and_leases_a_repository_slot() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
let roots = tempfile::tempdir().expect("a temporary directory");
let policy = a_repository(data_dir.path(), "octo/repo");
let shown = ok(data_dir.path(), &["host", "show"]);
let default_root = field(&shown, "runner root");
assert_eq!(field(&shown, "runner root source"), "platform-default");
if cfg!(windows) {
let mut characters = default_root.chars();
let drive = characters.next().expect("a drive letter");
assert!(
drive.is_ascii_uppercase()
&& characters.as_str().eq_ignore_ascii_case(":\\rman")
&& default_root.len() == 7,
"the Windows default must be `<system drive>:\\rman` -- short, at the drive \
root, and not the long application path it replaces -- got {default_root:?}"
);
} else {
assert_eq!(
Path::new(&default_root),
data_dir.path().join("runtime"),
"macOS and Linux keep the existing application runtime directory as their \
default; changing it would move every existing host's attempts"
);
}
let workspace = roots.path().join("ci-cache");
let workspace_argument = workspace.to_str().expect("a UTF-8 temporary path");
let enabled = ok(
data_dir.path(),
&[
"repo",
"set-workspace",
"octo/repo",
"--mode",
"persistent",
"--path",
workspace_argument,
],
);
assert!(
enabled.contains("_work"),
"enabling persistence states what is retained: {enabled}"
);
assert!(
workspace.is_dir(),
"the validated leaf is created once the checks pass"
);
let store = database(data_dir.path());
for slot in [1_u16, 2] {
store
.record_attempt(
&fixtures::attempt()
.id(AttemptId::new_random())
.policy_id(policy)
.state(AttemptState::Busy)
.persistent_slot(slot)
.runtime_path(
workspace
.join(format!("s{slot}"))
.to_str()
.expect("a UTF-8 temporary path")
.to_owned(),
)
.build(),
)
.expect("two distinct slots are leasable on this platform");
}
drop(store);
let document = status_json(data_dir.path());
assert_eq!(
document["policies"][0]["workspace_mode"],
Value::from("persistent")
);
assert_eq!(
document["policies"][0]["workspace_root"],
Value::from(workspace_argument)
);
let slots = document["policies"][0]["workspace_slots"]
.as_array()
.expect("a persistent policy reports its leases");
let mut leased: Vec<i64> = slots
.iter()
.map(|lease| lease["slot"].as_i64().expect("a slot number"))
.collect();
leased.sort_unstable();
assert_eq!(
leased,
vec![1, 2],
"two live attempts hold two distinct slots on this platform"
);
let listed = ok(data_dir.path(), &["repo", "list"]);
assert!(
listed.contains("workspace: persistent")
&& listed.contains(&format!("root={workspace_argument}"))
&& listed.contains("leases=2")
&& listed.contains("cleanup-blocked=0"),
"the human rendering must agree with the document about the mode, the root and the two leases: {listed}"
);
}
#[test]
fn an_abnormal_exit_writes_no_crash_report() {
let data_dir = tempfile::tempdir().expect("a temporary directory");
a_repository(data_dir.path(), "octo/repo");
let before: Vec<PathBuf> = files_under(data_dir.path());
let database_path = data_dir
.path()
.join("config")
.join("runner-manager.sqlite3");
fs::write(&database_path, b"this is not a database").expect("the fixture is writable");
for arguments in [
vec!["status"],
vec!["status", "--json"],
vec!["host", "show"],
vec!["repo", "list"],
] {
let outcome = command(data_dir.path(), &arguments);
assert_ne!(
outcome.code,
0,
"`{}` must fail closed on an unreadable journal; stdout: {}",
arguments.join(" "),
outcome.stdout
);
assert!(
!outcome.both().contains("panicked at"),
"`{}` must report unreadable local state rather than unwind: {}",
arguments.join(" "),
outcome.both()
);
}
let after: Vec<PathBuf> = files_under(data_dir.path());
let appeared: Vec<_> = after
.iter()
.filter(|path| !before.contains(path) && *path != &database_path)
.collect();
assert!(
appeared.is_empty(),
"an abnormal exit wrote {appeared:?}; this product has no crash-report artifact, \
and adding one is a security decision because it would become a new place for a \
credential to reach"
);
for path in &after {
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_ascii_lowercase();
assert!(
!(name.ends_with(".dmp")
|| name.ends_with(".crash")
|| name.ends_with(".stackdump")
|| name.contains("core.")),
"a crash artifact appeared at {}",
path.display()
);
}
}