runner-manager 0.4.21

Local-first autoscaling manager for ephemeral GitHub Actions self-hosted runners, with a CLI and a Ratatui TUI.
// owner: f2-cli-policy-commands

mod support;

use runner_manager_domain::attempt::AttemptState;
use runner_manager_domain::model::{AttemptId, ScaleTarget};
use runner_manager_domain::store::{SqliteStore, Store};
use runner_manager_testkit::fixtures::attempt;
use support::{
    FIXTURE_APP_SLUG, FIXTURE_CLIENT_ID, FakeGithub, fixture_token, run, runner_manager,
    runner_manager_against,
};

fn signed_in(data_dir: &std::path::Path) {
    let github = FakeGithub::start();
    github
        .with_device_code()
        .with_approval()
        .with_no_installations();
    let outcome = run({
        let mut command = runner_manager_against(data_dir, &github);
        command.args(["auth", "login"]);
        command
    });
    assert_eq!(outcome.code, 0, "{}", outcome.both());
}

fn database(data_dir: &std::path::Path) -> SqliteStore {
    SqliteStore::open(data_dir.join("config").join("runner-manager.sqlite3")).unwrap()
}

fn add_with_label(scope: &str, target: &str, label: &str) -> (String, Vec<String>) {
    let data_dir = tempfile::tempdir().unwrap();
    signed_in(data_dir.path());
    let github = FakeGithub::start();
    github.with_installation(77, "octo", "Organization", "selected", &["octo/one"]);
    let outcome = run({
        let mut command = runner_manager_against(data_dir.path(), &github);
        command.args([
            scope,
            "add",
            target,
            "--host-label",
            label,
            "--max-capacity",
            "2",
        ]);
        command
    });
    assert_eq!(outcome.code, 0, "{scope} add: {}", outcome.both());
    let policy = database(data_dir.path()).policies().unwrap().remove(0);
    assert_eq!(policy.requested_host_label.as_str(), label);
    (outcome.stdout, github.seen())
}

#[test]
fn repository_and_organization_add_share_the_gateway_path_and_emit_label_specific_routing() {
    for (scope, target) in [("repo", "octo/one"), ("org", "octo")] {
        let mut emitted = Vec::new();
        for label in ["home", "office"] {
            let (output, requests) = add_with_label(scope, target, label);
            let routing = output
                .lines()
                .find(|line| line.starts_with("Routing labels: "))
                .unwrap_or_else(|| panic!("{scope} add omitted routing output:\n{output}"));
            assert!(routing.contains(&format!("rm-{label}-")), "{routing}");
            emitted.push(routing.to_string());
            assert_eq!(
                requests,
                [
                    "GET /user/installations",
                    "GET /user/installations/77/repositories"
                ],
                "{scope} add must make exactly the two discovery reads and no GitHub write"
            );
        }
        assert_ne!(
            emitted[0], emitted[1],
            "the same {scope} target added in isolated stores under distinct host labels"
        );
    }
}

#[test]
fn scripted_policy_flow_uses_read_only_github_and_preserves_each_requested_label() {
    let data_dir = tempfile::tempdir().unwrap();
    signed_in(data_dir.path());
    let github = FakeGithub::start();
    github.with_installation(
        77,
        "octo",
        "Organization",
        "selected",
        &["octo/one", "octo/two"],
    );

    for (repository, label, maximum) in [
        ("octo/one", "home", None),
        ("octo/two", "office", Some("2")),
    ] {
        let outcome = run({
            let mut command = runner_manager_against(data_dir.path(), &github);
            command.args(["repo", "add", repository, "--host-label", label]);
            if let Some(maximum) = maximum {
                command.args(["--max-capacity", maximum]);
            }
            command
        });
        assert_eq!(outcome.code, 0, "{}", outcome.both());
        if maximum.is_some() {
            assert!(outcome.stdout.contains("Routing labels: rm-office-"));
        } else {
            assert!(outcome.stdout.contains("Monitor-only"));
            assert!(outcome.stdout.contains(
                "`Administration: Read and write` is NOT a narrow self-hosted-runner permission."
            ));
            assert!(outcome.stdout.contains(
                "The same grant also permits DELETING, RENAMING and TRANSFERRING the repository, and"
            ));
            assert!(
                outcome
                    .stdout
                    .contains("adding and removing collaborators.")
            );
        }
    }
    assert_eq!(
        github.seen(),
        [
            "GET /user/installations",
            "GET /user/installations/77/repositories",
            "GET /user/installations",
            "GET /user/installations/77/repositories",
        ],
        "add must spend exactly two read requests per discovery and make no GitHub writes"
    );

    let promote = run({
        let mut command = runner_manager(data_dir.path());
        command.args(["repo", "set-capacity", "octo/one", "--max-capacity", "2"]);
        command
    });
    assert_eq!(promote.code, 0, "{}", promote.both());
    assert!(promote.stdout.contains("Routing labels: rm-home-"));

    let store = database(data_dir.path());
    let policies = store.policies().unwrap();
    let first = policies
        .iter()
        .find(|policy| policy.target == ScaleTarget::repository("octo/one").unwrap())
        .unwrap();
    let second = policies
        .iter()
        .find(|policy| policy.target == ScaleTarget::repository("octo/two").unwrap())
        .unwrap();
    assert_eq!(first.requested_host_label.as_str(), "home");
    assert_eq!(second.requested_host_label.as_str(), "office");
    assert!(
        first
            .routing_labels()
            .unwrap()
            .host_label()
            .as_str()
            .contains("home")
    );
    assert!(
        second
            .routing_labels()
            .unwrap()
            .host_label()
            .as_str()
            .contains("office")
    );

    for enabled in ["true", "false"] {
        let outcome = run({
            let mut command = runner_manager(data_dir.path());
            command.args(["repo", "set-scale", "octo/one", "--enabled", enabled]);
            command
        });
        assert_eq!(outcome.code, 0, "explicit {enabled}: {}", outcome.both());
    }

    let mut second = store
        .policies()
        .unwrap()
        .into_iter()
        .find(|policy| policy.target == ScaleTarget::repository("octo/two").unwrap())
        .unwrap();
    let expected = second.revision();
    second.repair_required().unwrap();
    store.update_policy(&second, expected).unwrap();
    let repair = run({
        let mut command = runner_manager(data_dir.path());
        command.args(["repo", "list"]);
        command
    });
    assert_eq!(repair.code, 0, "{}", repair.both());
    assert!(repair.stdout.contains("repair_required"));
    assert!(
        repair
            .stdout
            .contains("repair: runner-manager repo remove octo/two --purge")
    );
    assert!(
        github
            .seen()
            .iter()
            .all(|request| request.starts_with("GET ")),
        "reporting repair_required must not try a remote deletion"
    );
    let busy = attempt()
        .policy_id(second.id)
        .state(AttemptState::Busy)
        .github_runner_id(73)
        .process_id(4242)
        .build();
    store.record_attempt(&busy).unwrap();
    let refusal = run({
        let mut command = runner_manager(data_dir.path());
        command.args(["repo", "remove", "octo/two", "--purge"]);
        command
    });
    assert_eq!(refusal.code, 11, "{}", refusal.both());
    assert!(refusal.both().contains("1 active runner"));
    assert!(store.policy(second.id).unwrap().is_some());

    let first_id = first.id;
    drop(policies);
    let diagnostic = attempt()
        .id(AttemptId::new_random())
        .policy_id(first_id)
        .state(AttemptState::Finished)
        .build();
    store.record_attempt(&diagnostic).unwrap();
    let remove = run({
        let mut command = runner_manager(data_dir.path());
        command.args(["repo", "remove", "octo/one"]);
        command
    });
    assert_eq!(remove.code, 0, "{}", remove.both());
    assert!(remove.stdout.contains("diagnostics were preserved"));
    assert!(store.policy(first_id).unwrap().is_none());
    assert_eq!(store.attempts_for_policy(first_id).unwrap().len(), 1);

    store.remove_attempt(busy.id).unwrap();
    let purge = run({
        let mut command = runner_manager(data_dir.path());
        command.args(["repo", "remove", "octo/two", "--purge"]);
        command
    });
    assert_eq!(purge.code, 0, "{}", purge.both());
    assert!(purge.stdout.contains("purged its historical diagnostics"));
    assert!(store.policy(second.id).unwrap().is_none());
}

#[test]
fn explicit_boole_reach_repo_and_org_runtime_and_failures_redact_the_credential() {
    let empty = tempfile::tempdir().unwrap();
    for (scope, target, enabled) in [("repo", "octo/missing", "false"), ("org", "octo", "true")] {
        let outcome = run({
            let mut command = runner_manager(empty.path());
            command.args([scope, "set-scale", target, "--enabled", enabled]);
            command
        });
        assert_eq!(
            outcome.code,
            10,
            "clap must accept {scope} {enabled}: {}",
            outcome.both()
        );
    }

    let data_dir = tempfile::tempdir().unwrap();
    signed_in(data_dir.path());
    let missing = FakeGithub::start();
    missing.with_installation(88, "elsewhere", "User", "selected", &["elsewhere/repo"]);
    let absent = run({
        let mut command = runner_manager_against(data_dir.path(), &missing);
        command.args(["repo", "add", "octo/missing", "--host-label", "home"]);
        command
    });
    assert_eq!(absent.code, 10, "{}", absent.both());
    assert!(database(data_dir.path()).policies().unwrap().is_empty());

    let unavailable = run({
        let mut command = runner_manager(data_dir.path());
        command
            .env("RUNNER_MANAGER_GITHUB_BASE_URL", "http://127.0.0.1:9/")
            .env("RUNNER_MANAGER_GITHUB_CLIENT_ID", FIXTURE_CLIENT_ID)
            .env("RUNNER_MANAGER_GITHUB_APP_SLUG", FIXTURE_APP_SLUG)
            .args(["repo", "add", "octo/missing", "--host-label", "home"]);
        command
    });
    assert_eq!(unavailable.code, 7, "{}", unavailable.both());
    assert!(!unavailable.both().contains(&fixture_token()));
    assert!(database(data_dir.path()).policies().unwrap().is_empty());
}

/// `add-label` and `remove-label`, for both scopes, through the real binary:
/// the stored label set moves, a repeat is a no-op, and the derived host label
/// cannot be removed.
#[test]
fn add_label_and_remove_label_change_the_stored_routing_labels_for_both_scopes() {
    for (scope, target) in [("repo", "octo/one"), ("org", "octo")] {
        let data_dir = tempfile::tempdir().unwrap();
        signed_in(data_dir.path());
        let github = FakeGithub::start();
        github.with_installation(77, "octo", "Organization", "selected", &["octo/one"]);
        let added = run({
            let mut command = runner_manager_against(data_dir.path(), &github);
            command.args([
                scope,
                "add",
                target,
                "--host-label",
                "home",
                "--max-capacity",
                "2",
            ]);
            command
        });
        assert_eq!(added.code, 0, "{scope} add: {}", added.both());

        let stored = || -> Vec<String> {
            let policy = database(data_dir.path()).policies().unwrap().remove(0);
            policy
                .routing_labels()
                .expect("an autoscale policy")
                .iter()
                .map(|label| label.as_str().to_string())
                .collect()
        };
        let label = |change: &str, label: &str| {
            run({
                let mut command = runner_manager(data_dir.path());
                command.args([scope, change, target, "--label", label]);
                command
            })
        };

        let add = label("add-label", "gpu");
        assert_eq!(add.code, 0, "{scope} add-label: {}", add.both());
        assert!(add.stdout.contains("now answers: gpu"), "{}", add.stdout);
        let labels = stored();
        assert!(labels.iter().any(|label| label == "gpu"), "{labels:?}");

        let again = label("add-label", "gpu");
        assert_eq!(again.code, 0, "{scope} add-label again: {}", again.both());
        assert!(
            again.stdout.contains("No label changed"),
            "{}",
            again.stdout
        );

        let host_label = stored()
            .into_iter()
            .find(|label| label.starts_with("rm-home-"))
            .expect("the derived host label is stored");
        let refused = label("remove-label", &host_label);
        assert_eq!(
            refused.code,
            9,
            "{scope} remove-label must refuse the host label: {}",
            refused.both()
        );
        let labels = stored();
        assert!(labels.contains(&host_label), "{labels:?}");

        let remove = label("remove-label", "gpu");
        assert_eq!(remove.code, 0, "{scope} remove-label: {}", remove.both());
        assert!(
            remove.stdout.contains("no longer answers: gpu"),
            "{}",
            remove.stdout
        );
        let labels = stored();
        assert!(!labels.iter().any(|label| label == "gpu"), "{labels:?}");
    }
}