mod common;
use std::path::{Path, PathBuf};
use std::process::Command;
use common::{FixtureRelease, build_release, substitute_payload};
fn host_target() -> (&'static str, &'static str) {
match (std::env::consts::OS, std::env::consts::ARCH) {
("windows", "x86_64") => ("x86_64-pc-windows-msvc", "runner-manager.exe"),
("macos", "aarch64") => ("aarch64-apple-darwin", "runner-manager"),
("macos", "x86_64") => ("x86_64-apple-darwin", "runner-manager"),
("linux", "x86_64") => ("x86_64-unknown-linux-gnu", "runner-manager"),
("linux", "aarch64") => ("aarch64-unknown-linux-gnu", "runner-manager"),
(os, arch) => panic!(
"this suite has no published archive for {os}/{arch}. If the project starts \
publishing one, add it here and to `host_target` in `cli/update.rs`."
),
}
}
const RUNNING: &str = env!("CARGO_PKG_VERSION");
fn one_release_newer() -> String {
let mut parts = RUNNING.split('.');
let major: u64 = parts.next().expect("a major").parse().expect("a number");
let minor: u64 = parts.next().expect("a minor").parse().expect("a number");
let patch: u64 = parts.next().expect("a patch").parse().expect("a number");
format!("{major}.{minor}.{}", patch + 1)
}
fn spawn_past_etxtbsy(command: &mut Command) -> std::process::Output {
const ATTEMPTS: u32 = 50;
const BACKOFF: std::time::Duration = std::time::Duration::from_millis(20);
for _ in 0..ATTEMPTS {
match command.output() {
Ok(output) => return output,
Err(error) if error.raw_os_error() == Some(TEXT_FILE_BUSY) => {
std::thread::sleep(BACKOFF);
}
Err(error) => panic!("the copied binary must run: {error:?}"),
}
}
panic!(
"the copied binary was still reported busy after {:?}; that is far longer than a \
concurrent copy in this suite can hold a write descriptor, so it is a real failure \
rather than the race this retry exists for",
ATTEMPTS * BACKOFF
);
}
#[cfg(target_os = "linux")]
const TEXT_FILE_BUSY: i32 = 26;
#[cfg(not(target_os = "linux"))]
const TEXT_FILE_BUSY: i32 = 0;
struct Installed {
_root: tempfile::TempDir,
binary: PathBuf,
data: PathBuf,
}
impl Installed {
fn new() -> Self {
let root = tempfile::tempdir().expect("a temporary directory");
let bin = root.path().join("bin");
std::fs::create_dir_all(&bin).expect("the install directory");
let binary = bin.join(format!("runner-manager{}", std::env::consts::EXE_SUFFIX));
std::fs::copy(env!("CARGO_BIN_EXE_runner-manager"), &binary)
.expect("copying the built binary into place");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755))
.expect("making the copy executable");
}
let data = root.path().join("data");
std::fs::create_dir_all(&data).expect("the data directory");
Self {
_root: root,
binary,
data,
}
}
fn update(&self, assets: &Path, arguments: &[&str]) -> Outcome {
let mut command = Command::new(&self.binary);
for variable in [
"RUNNER_MANAGER_DATA_DIR",
"RUNNER_MANAGER_GITHUB_BASE_URL",
"RUST_LOG",
] {
command.env_remove(variable);
}
command
.env("RUNNER_MANAGER_UPDATE_BASE_URL", assets)
.arg("--data-dir")
.arg(&self.data)
.arg("update")
.args(arguments);
let output = spawn_past_etxtbsy(&mut command);
Outcome {
code: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n"),
stderr: String::from_utf8_lossy(&output.stderr).replace("\r\n", "\n"),
}
}
fn bytes(&self) -> Vec<u8> {
std::fs::read(&self.binary).expect("the installed binary must be readable")
}
}
struct Outcome {
code: i32,
stdout: String,
stderr: String,
}
impl Outcome {
fn both(&self) -> String {
format!("{}{}", self.stdout, self.stderr)
}
}
fn payload_of(release: &FixtureRelease) -> Vec<u8> {
let (target, binary) = host_target();
std::fs::read(release.staged(target).join(binary)).expect("the fixture payload")
}
#[test]
fn a_newer_release_replaces_the_running_binary() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let installed = Installed::new();
let before = installed.bytes();
let outcome = installed.update(&release.assets, &[]);
assert_eq!(
outcome.code,
0,
"update must succeed; output was:\n{}",
outcome.both()
);
assert!(
outcome.stdout.contains(&format!(
"Installed runner-manager {} to",
one_release_newer()
)),
"update must name the version it installed; output was:\n{}",
outcome.both()
);
assert!(
outcome.stdout.contains("SHA-256 OK:"),
"update must report the digest it checked; output was:\n{}",
outcome.both()
);
let after = installed.bytes();
assert_ne!(before, after, "the binary on disk must have been replaced");
assert_eq!(
after,
payload_of(&release),
"the installed file must be the binary the release archive carried"
);
}
#[test]
fn the_same_version_is_not_reinstalled() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), RUNNING);
let installed = Installed::new();
let before = installed.bytes();
let outcome = installed.update(&release.assets, &[]);
assert_eq!(
outcome.code,
0,
"being up to date is not a failure; output was:\n{}",
outcome.both()
);
assert!(
outcome
.stdout
.contains("is the newest release. Nothing to do."),
"output was:\n{}",
outcome.both()
);
assert_eq!(installed.bytes(), before, "nothing may be written");
}
#[test]
fn a_substituted_archive_is_refused_and_nothing_is_installed() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let (target, _) = host_target();
substitute_payload(&release, target);
let installed = Installed::new();
let before = installed.bytes();
let outcome = installed.update(&release.assets, &[]);
assert_eq!(
outcome.code,
20,
"a digest mismatch is `unusable_response`; output was:\n{}",
outcome.both()
);
assert!(
outcome.stderr.contains("CHECKSUM MISMATCH"),
"the refusal must say what happened; output was:\n{}",
outcome.both()
);
assert_eq!(
installed.bytes(),
before,
"a refused update must leave the binary that was working in place"
);
}
#[test]
fn check_reports_the_new_version_and_changes_nothing() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let installed = Installed::new();
let before = installed.bytes();
let outcome = installed.update(&release.assets, &["--check"]);
assert_eq!(outcome.code, 0, "output was:\n{}", outcome.both());
assert!(
outcome.stdout.contains(&format!(
"published {}",
one_release_newer()
)),
"output was:\n{}",
outcome.both()
);
assert!(
outcome
.stdout
.contains("Nothing has been changed, because --check was given."),
"output was:\n{}",
outcome.both()
);
assert!(
outcome
.stdout
.contains("Install it with: runner-manager update"),
"an updatable copy must be told the command that does it; output was:\n{}",
outcome.both()
);
assert_eq!(installed.bytes(), before, "nothing may be written");
}
#[test]
fn the_report_names_the_channel_and_the_file_it_would_replace() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let installed = Installed::new();
let outcome = installed.update(&release.assets, &["--check"]);
assert!(
outcome
.stdout
.contains("installed by release archive ("),
"output was:\n{}",
outcome.both()
);
assert!(
outcome
.stdout
.contains(&format!("installed {RUNNING}")),
"output was:\n{}",
outcome.both()
);
}
#[test]
fn a_build_in_a_checkout_is_refused() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let data = tempfile::tempdir().expect("a temporary directory");
let output = Command::new(env!("CARGO_BIN_EXE_runner-manager"))
.env_remove("RUNNER_MANAGER_DATA_DIR")
.env("RUNNER_MANAGER_UPDATE_BASE_URL", &release.assets)
.arg("--data-dir")
.arg(data.path())
.arg("update")
.output()
.expect("the binary must run");
let stderr = String::from_utf8_lossy(&output.stderr).replace("\r\n", "\n");
assert_eq!(
output.status.code(),
Some(22),
"a checkout build is `update_unsupported`; stderr was:\n{stderr}"
);
assert!(
stderr.contains("build in a checkout"),
"stderr was:\n{stderr}"
);
assert!(
stderr.contains("cargo build --release"),
"the refusal must name what to run instead; stderr was:\n{stderr}"
);
}
#[test]
fn check_says_when_this_copy_could_not_be_updated() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let data = tempfile::tempdir().expect("a temporary directory");
let output = Command::new(env!("CARGO_BIN_EXE_runner-manager"))
.env_remove("RUNNER_MANAGER_DATA_DIR")
.env("RUNNER_MANAGER_UPDATE_BASE_URL", &release.assets)
.arg("--data-dir")
.arg(data.path())
.arg("update")
.arg("--check")
.output()
.expect("the binary must run");
let stdout = String::from_utf8_lossy(&output.stdout).replace("\r\n", "\n");
assert_eq!(output.status.code(), Some(0), "stdout was:\n{stdout}");
assert!(
stdout.contains("`runner-manager update` would refuse here:"),
"stdout was:\n{stdout}"
);
assert!(
!stdout.contains("Install it with: runner-manager update"),
"a dry run must not recommend a command that refuses; stdout was:\n{stdout}"
);
}
#[test]
fn an_http_asset_source_that_is_not_this_machine_is_refused() {
let data = tempfile::tempdir().expect("a temporary directory");
let output = Command::new(env!("CARGO_BIN_EXE_runner-manager"))
.env_remove("RUNNER_MANAGER_DATA_DIR")
.env(
"RUNNER_MANAGER_UPDATE_BASE_URL",
"https://releases.example.com/download",
)
.arg("--data-dir")
.arg(data.path())
.arg("update")
.arg("--check")
.output()
.expect("the binary must run");
let stderr = String::from_utf8_lossy(&output.stderr).replace("\r\n", "\n");
assert_eq!(
output.status.code(),
Some(9),
"an untrusted origin is `invalid_argument`; stderr was:\n{stderr}"
);
assert!(
stderr.contains("which is not this machine"),
"stderr was:\n{stderr}"
);
}
#[test]
fn a_release_without_this_platform_says_so() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let (target, _) = host_target();
let sums = std::fs::read_to_string(release.sums()).expect("the fixture SHA256SUMS");
let kept: Vec<&str> = sums.lines().filter(|line| !line.contains(target)).collect();
std::fs::write(release.sums(), format!("{}\n", kept.join("\n"))).expect("the trimmed sums");
let installed = Installed::new();
let outcome = installed.update(&release.assets, &["--check"]);
assert_eq!(
outcome.code,
15,
"a missing platform is `unsupported_host`; output was:\n{}",
outcome.both()
);
assert!(
outcome.stderr.contains("publishes no archive for"),
"output was:\n{}",
outcome.both()
);
}
fn write_install_record(data: &Path, source_binary: Option<&Path>) {
let config = data.join("config");
std::fs::create_dir_all(&config).expect("the config directory");
let source_line = match source_binary {
Some(path) => format!("source_binary = {}\n", toml_path(path)),
None => String::new(),
};
let record = format!(
"schema_version = 1\n\
service_name = \"com.example.runner-manager\"\n\
manager = \"launchd\"\n\
start_mode = \"boot\"\n\
account = \"root\"\n\
binary = {binary}\n\
{source_line}\
arguments = [\"daemon\", \"run\"]\n\
restart_delay_secs = 5\n\
restart_reset_secs = 60\n\
log_file = {log}\n\
starts_on_demand = false\n\
definition_path = \"/Library/LaunchDaemons/com.example.runner-manager.plist\"\n\
installed_at = \"2026-09-01T00:00:00Z\"\n\
installed_by_version = \"0.0.1\"\n\
\n\
[directories]\n\
config = {config_dir}\n\
state = {state}\n\
runtime = {runtime}\n\
logs = {logs}\n",
binary = toml_path(&data.join("state").join("bin").join("runner-manager")),
log = toml_path(&data.join("logs").join("runner-manager")),
config_dir = toml_path(&config),
state = toml_path(&data.join("state")),
runtime = toml_path(&data.join("runtime")),
logs = toml_path(&data.join("logs")),
);
std::fs::write(config.join("service.toml"), record).expect("the install record");
}
fn toml_path(path: &Path) -> String {
format!("'{}'", path.display())
}
#[test]
fn a_service_running_a_copy_is_told_it_will_hand_over_by_itself() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let installed = Installed::new();
write_install_record(&installed.data, Some(&installed.binary));
let outcome = installed.update(&release.assets, &[]);
assert_eq!(outcome.code, 0, "output was:\n{}", outcome.both());
assert!(
outcome
.stdout
.contains("The service runs its own copy of this binary."),
"output was:\n{}",
outcome.both()
);
assert!(
outcome.stdout.contains("Nothing else is needed."),
"output was:\n{}",
outcome.both()
);
}
#[test]
fn a_registration_with_no_recorded_source_is_told_to_reinstall() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let installed = Installed::new();
write_install_record(&installed.data, None);
let outcome = installed.update(&release.assets, &[]);
assert_eq!(outcome.code, 0, "output was:\n{}", outcome.both());
assert!(
outcome.stdout.contains("names a binary directly"),
"output was:\n{}",
outcome.both()
);
assert!(
outcome.stdout.contains("runner-manager service install"),
"the report must name the command that fixes it; output was:\n{}",
outcome.both()
);
}
#[test]
fn a_service_installed_from_another_binary_is_named_rather_than_assumed() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let installed = Installed::new();
let elsewhere = installed.data.join("another").join("runner-manager");
write_install_record(&installed.data, Some(&elsewhere));
let outcome = installed.update(&release.assets, &[]);
assert_eq!(outcome.code, 0, "output was:\n{}", outcome.both());
assert!(
outcome.stdout.contains("The service was installed from"),
"output was:\n{}",
outcome.both()
);
assert!(
outcome.stdout.contains(&elsewhere.display().to_string()),
"the report must name the other binary; output was:\n{}",
outcome.both()
);
}
#[test]
fn a_host_with_no_service_is_told_nothing_about_one() {
let fixtures = tempfile::tempdir().expect("a temporary directory");
let release = build_release(fixtures.path(), &one_release_newer());
let installed = Installed::new();
let outcome = installed.update(&release.assets, &[]);
assert_eq!(outcome.code, 0, "output was:\n{}", outcome.both());
assert!(
!outcome.stdout.contains("The service"),
"output was:\n{}",
outcome.both()
);
}