is_running collapsed every non-active state into false, so a container that exhausted its bounded restarts read as plain "down" -- indistinguishable from one an operator stopped deliberately. Bounding the restarts made that gap sharper: a slow-failing agent used to grind on visibly, now it can stop quietly. Adds UnitState + unit_state() beside is_running rather than widening it. is_running has ~8 call sites and nearly all are reconcile/power logic asking "is it up? if not, start it" -- a question with two answers. Only the view builder needs more, and it gets both facts from one systemctl call, since is-active prints the state when not passed --quiet. Surfaces as a flat failed flag on ContainerView and AgentStatusRow, matching the shape those types already document: independent, orthogonally-observed facts rather than a state machine. serde(default) keeps it order-independent with the frontend half. No behaviour change: nothing acts on the flag, per the ruling.
221 lines
7.8 KiB
Rust
221 lines
7.8 KiB
Rust
//! Unit tests for the lifecycle module (moved verbatim from the old
|
|
//! single-file `lifecycle.rs` `#[cfg(test)]` block).
|
|
|
|
use super::*;
|
|
|
|
/// Regression test: `setup_proposed` must seed both agent.nix and flake.nix
|
|
/// in the initial commit. Before commit 5b5a93e flake.nix was missing from
|
|
/// the scaffold, requiring manual creation (seen with the damocles agent).
|
|
#[tokio::test]
|
|
async fn setup_proposed_seeds_flake_nix() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let proposed = dir.path().join("proposed");
|
|
setup_proposed(&proposed, "test-agent")
|
|
.await
|
|
.expect("setup_proposed");
|
|
|
|
// Both files must exist on disk.
|
|
assert!(proposed.join("agent.nix").exists(), "agent.nix missing");
|
|
assert!(proposed.join("flake.nix").exists(), "flake.nix missing");
|
|
|
|
// flake.nix must export nixosModules.default (the meta-flake contract).
|
|
let flake = std::fs::read_to_string(proposed.join("flake.nix")).unwrap();
|
|
assert!(
|
|
flake.contains("nixosModules.default"),
|
|
"flake.nix does not export nixosModules.default"
|
|
);
|
|
|
|
// Both files must be tracked in the initial git commit.
|
|
let out = git_command()
|
|
.current_dir(&proposed)
|
|
.args(["show", "--name-only", "--format=", "HEAD"])
|
|
.output()
|
|
.await
|
|
.expect("git show");
|
|
let tracked = String::from_utf8_lossy(&out.stdout);
|
|
assert!(tracked.contains("agent.nix"), "agent.nix not committed");
|
|
assert!(tracked.contains("flake.nix"), "flake.nix not committed");
|
|
}
|
|
|
|
#[test]
|
|
fn bridge_gateway_ip_extracts_verbatim_address() {
|
|
// HIVE_NETWORK_SUBNET carries the bridge IP verbatim, not the
|
|
// canonical network — the gateway is the address before the `/`.
|
|
assert_eq!(
|
|
bridge_gateway_ip("10.42.0.1/24").as_deref(),
|
|
Some("10.42.0.1")
|
|
);
|
|
// Non-`.1` operator override: the gateway is wherever the bridge is.
|
|
assert_eq!(
|
|
bridge_gateway_ip("10.42.0.254/24").as_deref(),
|
|
Some("10.42.0.254")
|
|
);
|
|
assert_eq!(
|
|
bridge_gateway_ip("172.30.0.1/16").as_deref(),
|
|
Some("172.30.0.1")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bridge_gateway_ip_rejects_bad_input() {
|
|
assert!(bridge_gateway_ip("notanip/24").is_none());
|
|
assert!(bridge_gateway_ip("10.42.0.1").is_none()); // no prefix
|
|
assert!(bridge_gateway_ip("10.42.0.1/33").is_none()); // prefix > 32
|
|
assert!(bridge_gateway_ip("10.42.0.999/24").is_none()); // octet > 255
|
|
assert!(bridge_gateway_ip("10.42.0/24").is_none()); // 3 octets
|
|
}
|
|
|
|
/// `setup_proposed` is idempotent: calling it on an existing repo is a
|
|
/// no-op (the fresh guard skips all writes).
|
|
#[tokio::test]
|
|
async fn setup_proposed_idempotent() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let proposed = dir.path().join("proposed");
|
|
setup_proposed(&proposed, "test-agent")
|
|
.await
|
|
.expect("first call");
|
|
// Second call must not error even though .git already exists.
|
|
setup_proposed(&proposed, "test-agent")
|
|
.await
|
|
.expect("second call");
|
|
// Still one commit.
|
|
let out = git_command()
|
|
.current_dir(&proposed)
|
|
.args(["rev-list", "--count", "HEAD"])
|
|
.output()
|
|
.await
|
|
.expect("git rev-list");
|
|
let count = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
|
assert_eq!(
|
|
count, "1",
|
|
"expected exactly one commit after idempotent call"
|
|
);
|
|
}
|
|
|
|
/// Build a two-commit repo and return `(dir, repo_path, first, second)`.
|
|
async fn two_commit_repo() -> (tempfile::TempDir, std::path::PathBuf, String, String) {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let repo = dir.path().join("proposed");
|
|
setup_proposed(&repo, "test-agent")
|
|
.await
|
|
.expect("setup_proposed");
|
|
let first = git_rev_parse(&repo, "HEAD").await.expect("rev-parse first");
|
|
|
|
std::fs::write(repo.join("second.txt"), "second").expect("write second.txt");
|
|
git(&repo, &["add", "second.txt"]).await.expect("git add");
|
|
git(
|
|
&repo,
|
|
&[
|
|
"-c",
|
|
"user.name=test",
|
|
"-c",
|
|
"user.email=test@example.com",
|
|
"commit",
|
|
"-m",
|
|
"second",
|
|
],
|
|
)
|
|
.await
|
|
.expect("git commit");
|
|
let second = git_rev_parse(&repo, "HEAD")
|
|
.await
|
|
.expect("rev-parse second");
|
|
|
|
assert_ne!(first, second, "second commit did not advance HEAD");
|
|
(dir, repo, first, second)
|
|
}
|
|
|
|
/// `git_is_ancestor` answers reachability in both directions. This is the
|
|
/// check that decides whether moving a branch is a real fast-forward or a
|
|
/// rewind that discards commits.
|
|
#[tokio::test]
|
|
async fn is_ancestor_distinguishes_direction() {
|
|
let (_dir, repo, first, second) = two_commit_repo().await;
|
|
|
|
assert!(
|
|
git_is_ancestor(&repo, &first, &second)
|
|
.await
|
|
.expect("ancestor check forward"),
|
|
"first must be an ancestor of second"
|
|
);
|
|
assert!(
|
|
!git_is_ancestor(&repo, &second, &first)
|
|
.await
|
|
.expect("ancestor check reverse"),
|
|
"second must NOT be an ancestor of first"
|
|
);
|
|
}
|
|
|
|
/// Regression test for the deploy path that ate a committed agent config:
|
|
/// `git_update_ref_cas` must refuse to move a ref whose current value is not
|
|
/// the expected one, and must leave the ref untouched when it refuses. A bare
|
|
/// `update-ref` accepts that move and silently drops everything in between.
|
|
#[tokio::test]
|
|
async fn update_ref_cas_refuses_stale_expectation() {
|
|
let (_dir, repo, first, second) = two_commit_repo().await;
|
|
|
|
// Park a ref at `first`, then CAS it forward with the correct old value.
|
|
git_update_ref(&repo, "refs/heads/cas", &first)
|
|
.await
|
|
.expect("plant ref");
|
|
git_update_ref_cas(&repo, "refs/heads/cas", &second, &first)
|
|
.await
|
|
.expect("CAS with the correct old value must succeed");
|
|
assert_eq!(
|
|
git_rev_parse(&repo, "refs/heads/cas").await.unwrap(),
|
|
second
|
|
);
|
|
|
|
// Now retry with the SAME (now stale) expectation, as a racing deploy
|
|
// holding a pre-merge sha would: it must fail rather than rewind.
|
|
assert!(
|
|
git_update_ref_cas(&repo, "refs/heads/cas", &first, &first)
|
|
.await
|
|
.is_err(),
|
|
"CAS accepted a stale old value"
|
|
);
|
|
assert_eq!(
|
|
git_rev_parse(&repo, "refs/heads/cas").await.unwrap(),
|
|
second,
|
|
"ref moved even though the CAS failed"
|
|
);
|
|
}
|
|
|
|
/// `systemctl is-active` prints the state with a trailing newline, which is
|
|
/// the whole reason this parser trims rather than comparing raw.
|
|
#[test]
|
|
fn unit_state_reads_what_is_active_prints() {
|
|
assert_eq!(UnitState::from_is_active("active\n"), UnitState::Active);
|
|
assert_eq!(UnitState::from_is_active("failed\n"), UnitState::Failed);
|
|
assert_eq!(UnitState::from_is_active("inactive\n"), UnitState::Other);
|
|
// No trailing newline, in case the capture ever changes shape.
|
|
assert_eq!(UnitState::from_is_active("failed"), UnitState::Failed);
|
|
}
|
|
|
|
/// The asymmetry that matters: an unrecognised state must fall to `Other`,
|
|
/// never to `Failed`.
|
|
///
|
|
/// `Failed` is what the dashboard will render as *this agent gave up*, so a
|
|
/// wrong `Failed` invents an incident, while a wrong `Other` merely fails to
|
|
/// distinguish one from a deliberate stop — the behaviour we have today.
|
|
/// Guessing in the safe direction is the property, not an implementation
|
|
/// detail of the `match`.
|
|
#[test]
|
|
fn an_unknown_state_is_never_reported_as_failed() {
|
|
for s in [
|
|
"activating",
|
|
"deactivating",
|
|
"reloading",
|
|
"maintenance",
|
|
"unknown",
|
|
"",
|
|
"Failed", // capitalised: systemd prints lowercase, so this is not a state
|
|
"failed*", // `is-active` never emits this; a substring match would take it
|
|
] {
|
|
assert_eq!(
|
|
UnitState::from_is_active(s),
|
|
UnitState::Other,
|
|
"{s:?} must not be read as a give-up"
|
|
);
|
|
}
|
|
}
|