runnerctl 0.3.0

Manage independent ephemeral GitHub Actions runner pools on one Docker host
// Unix only: the fake docker is a shell script, and one client connects to the socket directly.
#![cfg(unix)]
use assert_cmd::Command;
use serde_json::Value;
use std::{
    fs,
    os::unix::{fs::PermissionsExt, net::UnixStream},
    path::Path,
    process::{Child, Stdio},
    time::{Duration, Instant},
};

struct Manager(Child);
impl Drop for Manager {
    fn drop(&mut self) {
        let _ = self.0.kill();
        let _ = self.0.wait();
    }
}
fn cli(home: &Path) -> Command {
    let mut command = Command::new(assert_cmd::cargo::cargo_bin!("runnerctl"));
    command
        .arg("--home")
        .arg(home)
        .timeout(Duration::from_secs(2));
    command
}
fn wait_for(path: &Path) {
    let deadline = Instant::now() + Duration::from_secs(5);
    while !path.exists() {
        assert!(
            Instant::now() < deadline,
            "{} was not created",
            path.display()
        );
        std::thread::sleep(Duration::from_millis(10));
    }
}
fn start_manager(home: &Path, bin: &Path, marker: &Path) -> Manager {
    let child = std::process::Command::new(assert_cmd::cargo::cargo_bin!("runnerctl"))
        .args(["--home", home.to_str().unwrap(), "manager"])
        .env(
            "PATH",
            format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()),
        )
        .env("RUNNERCTL_TEST_MARKER", marker)
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .unwrap();
    wait_for(&home.join("manager.sock"));
    Manager(child)
}
fn fake_docker(bin: &Path, body: &str) {
    fs::create_dir_all(bin).unwrap();
    let path = bin.join("docker");
    fs::write(&path, format!("#!/bin/sh\nset -eu\n{body}\n")).unwrap();
    fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap();
}

#[test]
fn slow_reconciliation_accepts_stop_and_does_not_provision_from_stale_intent() {
    let dir = tempfile::tempdir().unwrap();
    let home = dir.path().join("state");
    let bin = dir.path().join("bin");
    let marker = dir.path().join("slow-operation");
    fake_docker(
        &bin,
        "if [ \"$1\" = ps ]; then touch \"$RUNNERCTL_TEST_MARKER\"; sleep 3; exit 0; fi\nexit 1",
    );
    cli(&home)
        .args(["init", "--org", "example"])
        .assert()
        .success();
    cli(&home)
        .args([
            "pool", "add", "test", "--org", "example", "--auth", "example", "--labels", "test",
        ])
        .assert()
        .success();
    cli(&home).args(["start", "test"]).assert().success();
    let _manager = start_manager(&home, &bin, &marker);
    wait_for(&marker);
    let started = Instant::now();
    cli(&home)
        .args(["stop", "test", "--wait", "--timeout", "1s"])
        .assert()
        .success();
    assert!(started.elapsed() < Duration::from_secs(2));
    std::thread::sleep(Duration::from_millis(3200));
    let output = cli(&home).args(["--json", "status"]).output().unwrap();
    assert!(output.status.success());
    let status: Value = serde_json::from_slice(&output.stdout).unwrap();
    assert_eq!(status["pools"][0]["intent"], "stopped");
    assert_eq!(status["pools"][0]["active"], 0);
    assert_eq!(status["pools"][0]["error"], Value::Null);
}

#[test]
fn slow_doctor_and_incomplete_client_do_not_block_status() {
    let dir = tempfile::tempdir().unwrap();
    let home = dir.path().join("state");
    let bin = dir.path().join("bin");
    let marker = dir.path().join("slow-doctor");
    fake_docker(
        &bin,
        "touch \"$RUNNERCTL_TEST_MARKER\"\nsleep 3\nprintf 'test-daemon\\n'",
    );
    cli(&home)
        .args(["init", "--org", "example"])
        .assert()
        .success();
    let _manager = start_manager(&home, &bin, &marker);
    let _incomplete_client = UnixStream::connect(home.join("manager.sock")).unwrap();
    let mut doctor = std::process::Command::new(assert_cmd::cargo::cargo_bin!("runnerctl"))
        .args(["--home", home.to_str().unwrap(), "doctor"])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .unwrap();
    wait_for(&marker);
    let started = Instant::now();
    cli(&home).arg("status").assert().success();
    assert!(started.elapsed() < Duration::from_secs(2));
    assert!(doctor.wait().unwrap().success());
}