use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Serialize, PartialEq, Eq)]
pub struct NodeConvergence {
pub node: String,
pub engine: &'static str,
pub verdict: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deployed_rev: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub head_rev: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_tick_age_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_tick_outcome: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub consecutive_failures: Option<u64>,
}
pub const CONVERGED: &str = "converged";
pub const BEHIND: &str = "behind";
pub const STOPPED: &str = "stopped";
pub const FAILING: &str = "failing";
pub const UNKNOWN: &str = "unknown";
pub const NOT_ENROLLED: &str = "notEnrolled";
pub const INEFFECTIVE: &str = "ineffective";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TickPhase {
#[default]
Resolved,
InFlight,
Unrecognised,
}
impl TickPhase {
fn from_wire(raw: Option<&str>) -> Self {
match raw {
None | Some("resolved") => Self::Resolved,
Some("in_flight") => Self::InFlight,
Some(_) => Self::Unrecognised,
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct Heartbeat {
at_unix_ms: Option<u64>,
outcome: Option<String>,
phase: Option<String>,
head_rev: Option<String>,
poll_seconds: Option<u64>,
}
const STALE_AFTER_POLLS: u64 = 3;
const IN_FLIGHT_OUTCOMES: &[&str] = &["building", "applying", "fetching", "switching"];
const IN_FLIGHT_BUDGET_SECS: u64 = 45 * 60;
#[must_use]
pub fn classify(
deployed: Option<&str>,
head: Option<&str>,
tick_age_secs: Option<u64>,
poll_seconds: Option<u64>,
failures: Option<u64>,
last_outcome: Option<&str>,
phase: TickPhase,
) -> (&'static str, Option<String>) {
let (age, poll) = match (tick_age_secs, poll_seconds) {
(Some(a), Some(p)) => (a, p),
(None, _) => {
return (
UNKNOWN,
Some("no heartbeat published — liveness cannot be determined".to_owned()),
);
}
(Some(a), None) => {
return (
UNKNOWN,
Some(format!(
"heartbeat is {a}s old but the reconciler published no poll interval, \
so staleness cannot be judged"
)),
);
}
};
let in_flight = phase == TickPhase::InFlight
|| last_outcome.is_some_and(|o| IN_FLIGHT_OUTCOMES.contains(&o));
let budget = if in_flight {
IN_FLIGHT_BUDGET_SECS
} else {
STALE_AFTER_POLLS * poll
};
if age > budget {
return (
STOPPED,
Some(if in_flight {
format!(
"tick has been `{}` for {age}s, past the {IN_FLIGHT_BUDGET_SECS}s build budget — a build this long is a hang, not progress",
last_outcome.unwrap_or("?")
)
} else {
format!("no tick for {age}s against a {poll}s poll — the loop is stopped, not idle")
}),
);
}
if in_flight {
return (
UNKNOWN,
Some(format!(
"tick in progress (`{}`, {age}s) — the outcome is not known yet",
last_outcome.unwrap_or("?")
)),
);
}
if phase == TickPhase::Resolved && head.is_none() {
let verb = last_outcome.unwrap_or("?");
let history = match deployed {
None => "and has never activated a rev".to_owned(),
Some(d) => format!("having last activated {}", short(d)),
};
let streak = match failures.filter(|n| *n > 0) {
Some(n) => format!(" · {n} consecutive failed receipts"),
None => String::new(),
};
return (
INEFFECTIVE,
Some(format!(
"tick finished `{verb}` without resolving a branch HEAD: the loop is alive \
({age}s ago against a {poll}s poll) but did no convergence work, {history}\
{streak}. Evidence covers the LAST tick only — the pulse carries no \
ineffective-since stamp, so how long this has run cannot be read from it"
)),
);
}
if let Some(n) = failures.filter(|n| *n > 0) {
return (FAILING, Some(format!("{n} consecutive failed ticks")));
}
match (deployed, head) {
(Some(d), Some(h)) if d != h => (
BEHIND,
Some(format!(
"deployed {} but branch HEAD is {}",
short(d),
short(h)
)),
),
(Some(_), Some(_)) => (CONVERGED, None),
(None, Some(h)) => (
UNKNOWN,
Some(format!(
"the reconciler observed branch HEAD {} but the receipt chain records no \
activation; this node has never deployed",
short(h)
)),
),
_ => (
UNKNOWN,
Some(format!(
"the reconciler published no branch HEAD, and this reader does not recognise \
the phase it published, so whether the tick even finished cannot be \
determined (outcome `{}`)",
last_outcome.unwrap_or("?")
)),
),
}
}
fn short(rev: &str) -> String {
rev.get(..7).unwrap_or(rev).to_owned()
}
pub fn local(state_dir: &Path, node: String, now_epoch: u64) -> NodeConvergence {
if !state_dir.is_dir() {
return NodeConvergence {
node,
engine: "none",
verdict: NOT_ENROLLED,
reason: Some("no reconciler state directory on this host".to_owned()),
deployed_rev: None,
head_rev: None,
last_tick_age_secs: None,
last_tick_outcome: None,
consecutive_failures: None,
};
}
let beat = read_heartbeat(&state_dir.join("heartbeat.json"));
let age = beat
.at_unix_ms
.map(|ms| now_epoch.saturating_sub(ms / 1000));
let phase = TickPhase::from_wire(beat.phase.as_deref());
let (deployed_rev, failures) = [
state_dir.join("receipts.yaml"),
state_dir.join("receipts.json"),
]
.iter()
.map(|p| read_chain_tail(p))
.find(|(rev, fails)| rev.is_some() || fails.is_some_and(|n| n > 0))
.unwrap_or((None, None));
let (verdict, reason) = classify(
deployed_rev.as_deref(),
beat.head_rev.as_deref(),
age,
beat.poll_seconds,
failures,
beat.outcome.as_deref(),
phase,
);
let head_rev = beat.head_rev;
let outcome = beat.outcome;
NodeConvergence {
node,
engine: "sentinela",
verdict,
reason,
deployed_rev,
head_rev,
last_tick_age_secs: age,
last_tick_outcome: outcome,
consecutive_failures: failures,
}
}
fn read_heartbeat(path: &Path) -> Heartbeat {
std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn read_chain_tail(path: &Path) -> (Option<String>, Option<u64>) {
const TAIL: u64 = 64 * 1024;
let Ok(meta) = std::fs::metadata(path) else {
return (None, None);
};
let raw = if meta.len() > TAIL {
use std::io::{Read as _, Seek as _, SeekFrom};
let Ok(mut f) = std::fs::File::open(path) else {
return (None, None);
};
if f.seek(SeekFrom::End(-(TAIL as i64))).is_err() {
return (None, None);
}
let mut buf = Vec::new();
if f.read_to_end(&mut buf).is_err() {
return (None, None);
}
String::from_utf8_lossy(&buf).into_owned()
} else {
match std::fs::read_to_string(path) {
Ok(s) => s,
Err(_) => return (None, None),
}
};
let mut streak = 0u64;
let mut activated: Option<String> = None;
let mut pending_kind: Option<String> = None;
for line in raw.lines().rev() {
let t = line.trim();
if let Some(k) = t.strip_prefix("kind:") {
let kind = k.trim().to_owned();
if kind != "activated" {
streak += 1;
}
pending_kind = Some(kind);
} else if let Some(r) = t.strip_prefix("rev:") {
if pending_kind.as_deref() == Some("activated") {
activated = Some(r.trim().to_owned());
break;
}
pending_kind = None;
}
}
(activated, Some(streak))
}
pub const DEFAULT_STATE_DIR: &str = "/var/log/pleme-gitops";
fn default_state_dir() -> PathBuf {
PathBuf::from(DEFAULT_STATE_DIR)
}
pub fn convergence(json: bool) -> Result<()> {
let node = super::utils::run_command_output(std::process::Command::new("hostname").arg("-s"))
.unwrap_or_else(|_| "unknown".to_owned());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let doc = local(&default_state_dir(), node, now);
if json {
println!("{}", serde_json::to_string_pretty(&doc)?);
return Ok(());
}
println!("node : {}", doc.node);
println!("engine : {}", doc.engine);
println!("verdict : {}", doc.verdict);
if let Some(r) = &doc.reason {
println!("reason : {r}");
}
if let Some(d) = &doc.deployed_rev {
println!("deployed : {}", short(d));
}
if let Some(h) = &doc.head_rev {
println!("branch : {}", short(h));
}
if let Some(a) = doc.last_tick_age_secs {
println!(
"last tick : {a}s ago ({})",
doc.last_tick_outcome.as_deref().unwrap_or("?")
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const POLL: u64 = 60;
const REV: &str = "7176c2181d217e1beec7aa3e5244f620ac26dca7";
const HEAD: &str = "588cf40f6bc7b603943741a2abd074cfaf2142cd";
const DONE: TickPhase = TickPhase::Resolved;
#[test]
fn a_stopped_loop_is_stopped_even_with_a_clean_chain() {
let (v, why) = classify(
Some(REV),
Some(REV),
Some(60177),
Some(POLL),
Some(0),
Some("converged"),
DONE,
);
assert_eq!(v, STOPPED);
assert!(why.unwrap().contains("stopped, not idle"));
}
#[test]
fn a_live_loop_off_head_is_behind() {
let (v, why) = classify(
Some(REV),
Some(HEAD),
Some(30),
Some(POLL),
Some(0),
Some("converged"),
DONE,
);
assert_eq!(v, BEHIND);
let why = why.unwrap();
assert!(why.contains("7176c21") && why.contains("588cf40"), "{why}");
}
#[test]
fn failures_outrank_a_matching_rev() {
let (v, why) = classify(
Some(REV),
Some(REV),
Some(30),
Some(POLL),
Some(4136),
Some("converged"),
DONE,
);
assert_eq!(v, FAILING);
assert!(why.unwrap().contains("4136"));
}
#[test]
fn absent_evidence_is_unknown_never_converged() {
assert_eq!(
classify(
Some(REV),
Some(REV),
None,
Some(POLL),
Some(0),
Some("converged"),
DONE
)
.0,
UNKNOWN
);
let (v, why) = classify(
Some(REV),
Some(REV),
Some(30),
None,
Some(0),
Some("converged"),
DONE,
);
assert_eq!(v, UNKNOWN);
assert!(why.unwrap().contains("no poll interval"));
}
#[test]
fn alive_at_head_and_not_failing_is_converged() {
let (v, why) = classify(
Some(REV),
Some(REV),
Some(30),
Some(POLL),
Some(0),
Some("converged"),
DONE,
);
assert_eq!(v, CONVERGED);
assert!(why.is_none(), "a converged node needs no excuse");
}
#[test]
fn a_host_with_no_state_dir_is_not_enrolled_not_broken() {
let doc = local(
Path::new("/nonexistent/pleme-gitops"),
"laptop".to_owned(),
0,
);
assert_eq!(doc.verdict, NOT_ENROLLED);
assert_eq!(doc.engine, "none");
}
#[test]
fn a_build_running_past_three_polls_is_not_a_stopped_loop() {
let (v, why) = classify(
Some(REV),
Some(HEAD),
Some(273),
Some(60),
Some(0),
Some("building"),
DONE,
);
assert_ne!(v, STOPPED, "a running build is not a stopped loop: {why:?}");
}
#[test]
fn a_tick_in_flight_is_unknown_never_converged() {
let (v, why) = classify(
Some(REV),
Some(REV),
Some(273),
Some(60),
Some(0),
Some("building"),
DONE,
);
assert_eq!(v, UNKNOWN, "in-flight must not claim a result: {why:?}");
assert!(why.expect("reason").contains("in progress"));
}
#[test]
fn a_build_past_the_build_budget_is_stopped() {
let (v, why) = classify(
Some(REV),
Some(HEAD),
Some(IN_FLIGHT_BUDGET_SECS + 1),
Some(60),
Some(0),
Some("building"),
DONE,
);
assert_eq!(v, STOPPED, "a hung build must still be caught");
assert!(why.expect("reason").contains("hang"));
}
#[test]
fn a_terminal_outcome_still_goes_stale_at_three_polls() {
let (v, _) = classify(
Some(REV),
Some(HEAD),
Some(STALE_AFTER_POLLS * POLL + 1),
Some(POLL),
Some(0),
Some("converged"),
DONE,
);
assert_eq!(v, STOPPED, "a finished tick that never recurred is stopped");
}
#[test]
fn an_unrecognised_outcome_gets_the_strict_budget() {
let (v, _) = classify(
Some(REV),
Some(HEAD),
Some(STALE_AFTER_POLLS * POLL + 1),
Some(POLL),
Some(0),
Some("frobnicating"),
DONE,
);
assert_eq!(v, STOPPED);
}
#[test]
fn the_staleness_boundary_is_three_poll_intervals() {
let budget = STALE_AFTER_POLLS * POLL;
assert_eq!(
classify(
Some(REV),
Some(REV),
Some(budget),
Some(POLL),
Some(0),
Some("converged"),
DONE
)
.0,
CONVERGED
);
assert_eq!(
classify(
Some(REV),
Some(REV),
Some(budget + 1),
Some(POLL),
Some(0),
Some("converged"),
DONE
)
.0,
STOPPED
);
}
fn read_back(name: &str, heartbeat: &str, receipts: Option<&str>, now: u64) -> NodeConvergence {
let dir = std::env::temp_dir().join(format!("fleet-convergence-test-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp state dir");
std::fs::write(dir.join("heartbeat.json"), heartbeat).expect("write heartbeat");
if let Some(r) = receipts {
std::fs::write(dir.join("receipts.json"), r).expect("write receipts");
}
let doc = local(&dir, "rio".to_owned(), now);
let _ = std::fs::remove_dir_all(&dir);
doc
}
fn read_back_chain_at(
name: &str,
heartbeat: &str,
chain_file: &str,
chain: &str,
now: u64,
) -> NodeConvergence {
let dir = std::env::temp_dir().join(format!("fleet-convergence-test-{name}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp state dir");
std::fs::write(dir.join("heartbeat.json"), heartbeat).expect("write heartbeat");
std::fs::write(dir.join(chain_file), chain).expect("write chain");
let doc = local(&dir, "rio".to_owned(), now);
let _ = std::fs::remove_dir_all(&dir);
doc
}
fn rio_heartbeat(now: u64) -> String {
format!(
"{{\"at_unix_ms\":{},\"outcome\":\"probeError\",\"phase\":\"resolved\",\
\"head_rev\":null,\"poll_seconds\":60}}",
(now - 12) * 1000
)
}
#[test]
fn rios_pulsing_loop_that_never_resolved_a_head_is_not_converged() {
let now = 1_754_400_000;
let doc = read_back("rio-live", &rio_heartbeat(now), None, now);
assert_ne!(
doc.verdict, CONVERGED,
"a loop that never resolved a head must never read as converged: {doc:?}"
);
assert_eq!(doc.verdict, INEFFECTIVE, "{doc:?}");
assert_eq!(doc.last_tick_age_secs, Some(12));
assert_ne!(doc.verdict, STOPPED, "the daemon was alive and pulsing");
assert_eq!(doc.last_tick_outcome.as_deref(), Some("probeError"));
assert_eq!(doc.head_rev, None, "sentinela published head_rev: null");
let why = doc.reason.expect("an ineffective verdict owes a reason");
assert!(why.contains("probeError"), "{why}");
assert!(why.contains("no convergence work"), "{why}");
assert!(why.contains("LAST tick only"), "{why}");
}
#[test]
fn never_activated_reads_differently_from_regressed() {
let virgin = classify(
None,
None,
Some(12),
Some(POLL),
Some(0),
Some("probeError"),
DONE,
);
assert_eq!(virgin.0, INEFFECTIVE);
let why = virgin.1.expect("reason");
assert!(why.contains("never activated a rev"), "{why}");
let regressed = classify(
Some(REV),
None,
Some(12),
Some(POLL),
Some(0),
Some("probeError"),
DONE,
);
assert_eq!(regressed.0, INEFFECTIVE);
let why = regressed.1.expect("reason");
assert!(why.contains("last activated 7176c21"), "{why}");
assert!(
!why.contains("never activated"),
"a node that HAS deployed must not be described as never having: {why}"
);
}
#[test]
fn an_ineffective_verdict_still_carries_the_failure_streak() {
let (v, why) = classify(
Some(REV),
None,
Some(12),
Some(POLL),
Some(7),
Some("probeError"),
DONE,
);
assert_eq!(v, INEFFECTIVE);
assert!(
why.expect("reason")
.contains("7 consecutive failed receipts"),
"the streak must survive the precedence choice"
);
}
#[test]
fn alive_and_effective_alive_and_ineffective_and_dead_are_three_verdicts() {
let working = classify(
Some(REV),
Some(REV),
Some(30),
Some(POLL),
Some(0),
Some("unchanged"),
DONE,
)
.0;
let pulsing = classify(
Some(REV),
None,
Some(30),
Some(POLL),
Some(0),
Some("probeError"),
DONE,
)
.0;
let dead = classify(
Some(REV),
Some(REV),
Some(60177),
Some(POLL),
Some(0),
Some("unchanged"),
DONE,
)
.0;
assert_eq!((working, pulsing, dead), (CONVERGED, INEFFECTIVE, STOPPED));
}
#[test]
fn the_healthy_steady_state_is_untouched_by_the_ineffective_rule() {
for outcome in ["unchanged", "deployed", "deployedBehind"] {
let (v, _) = classify(
Some(REV),
Some(REV),
Some(30),
Some(POLL),
Some(0),
Some(outcome),
DONE,
);
assert_eq!(v, CONVERGED, "`{outcome}` resolved a head and is at it");
}
}
#[test]
fn an_in_flight_tick_is_unknown_not_ineffective() {
let (v, why) = classify(
Some(REV),
None,
Some(30),
Some(POLL),
Some(0),
Some("building"),
TickPhase::InFlight,
);
assert_eq!(v, UNKNOWN, "a running tick has not failed at anything");
assert!(why.expect("reason").contains("in progress"));
}
#[test]
fn an_unrecognised_phase_never_claims_ineffective() {
let (v, why) = classify(
Some(REV),
None,
Some(30),
Some(POLL),
Some(0),
Some("someNewVerb"),
TickPhase::from_wire(Some("draining")),
);
assert_eq!(v, UNKNOWN, "an unreadable phase is not proof of failure");
assert!(why
.expect("reason")
.contains("does not recognise the phase"));
}
#[test]
fn a_silent_ineffective_loop_is_stopped_not_ineffective() {
let (v, _) = classify(
Some(REV),
None,
Some(STALE_AFTER_POLLS * POLL + 1),
Some(POLL),
Some(0),
Some("probeError"),
DONE,
);
assert_eq!(v, STOPPED);
}
#[test]
fn a_working_loop_that_has_never_deployed_is_unknown_not_ineffective() {
let (v, why) = classify(
None,
Some(HEAD),
Some(30),
Some(POLL),
Some(0),
Some("unchanged"),
DONE,
);
assert_eq!(v, UNKNOWN);
let why = why.expect("reason");
assert!(why.contains("never deployed"), "{why}");
assert!(
why.contains("588cf40"),
"the head it DID observe belongs in the reason: {why}"
);
}
#[test]
fn a_heartbeat_from_an_older_sentinela_still_reads() {
let now = 1_754_400_000;
let doc = read_back(
"rio-legacy",
&format!(
"{{\"at_unix_ms\":{},\"outcome\":\"unchanged\",\"poll_seconds\":60}}",
(now - 20) * 1000
),
None,
now,
);
assert_eq!(doc.last_tick_age_secs, Some(20));
assert_eq!(doc.last_tick_outcome.as_deref(), Some("unchanged"));
assert_eq!(doc.verdict, INEFFECTIVE, "{doc:?}");
}
#[test]
fn an_unparseable_heartbeat_is_unknown_not_a_panic() {
let now = 1_754_400_000;
let doc = read_back("rio-corrupt", "{not json at all", None, now);
assert_eq!(doc.verdict, UNKNOWN);
assert!(doc
.reason
.expect("reason")
.contains("no heartbeat published"));
}
#[test]
fn the_phase_wire_mapping_is_total() {
assert_eq!(TickPhase::from_wire(None), TickPhase::Resolved);
assert_eq!(TickPhase::from_wire(Some("resolved")), TickPhase::Resolved);
assert_eq!(TickPhase::from_wire(Some("in_flight")), TickPhase::InFlight);
assert_eq!(
TickPhase::from_wire(Some("something_new")),
TickPhase::Unrecognised
);
}
fn converged_pulse(now: u64) -> String {
format!(
"{{\"at_unix_ms\":{},\"outcome\":\"unchanged\",\"phase\":\"resolved\",\
\"head_rev\":\"cd136f04e14ea67bae9b53491099c63b88a1d3f6\",\
\"poll_seconds\":60}}",
(now - 20) * 1000
)
}
const CHAIN_YAML: &str = "- seq: 0\n rev: \
cd136f04e14ea67bae9b53491099c63b88a1d3f6\n outcome:\n kind: \
activated\n at_unix_ms: 1785645747419\n prev_hash: null\n";
#[test]
fn a_chain_at_the_legacy_json_path_is_still_read() {
let now = 1_754_400_000;
let doc = read_back_chain_at(
"legacy-chain",
&converged_pulse(now),
"receipts.json",
CHAIN_YAML,
now,
);
assert!(
doc.deployed_rev.is_some(),
"a node that has not yet migrated must not read as chainless: \
absence of a chain is not evidence of health. got {doc:?}"
);
}
#[test]
fn a_chain_at_the_canonical_yaml_path_is_read() {
let now = 1_754_400_000;
let doc = read_back_chain_at(
"canonical-chain",
&converged_pulse(now),
"receipts.yaml",
CHAIN_YAML,
now,
);
assert!(
doc.deployed_rev.is_some(),
"post-migration nodes write receipts.yaml; got {doc:?}"
);
}
const GOLDEN_PULSE: &str = concat!(
r#"{"at_unix_ms":1785645747419,"outcome":"unchanged","phase":"resolved","#,
r#""head_rev":"cd136f04e14ea67bae9b53491099c63b88a1d3f6","poll_seconds":60}"#
);
#[test]
fn the_golden_pulse_populates_every_field_this_reader_uses() {
let hb: Heartbeat = serde_json::from_str(GOLDEN_PULSE).expect("golden pulse parses");
assert_eq!(hb.at_unix_ms, Some(1_785_645_747_419), "staleness");
assert_eq!(hb.outcome.as_deref(), Some("unchanged"), "verdict");
assert_eq!(
hb.phase.as_deref(),
Some("resolved"),
"finished-vs-in-flight"
);
assert_eq!(
hb.head_rev.as_deref(),
Some("cd136f04e14ea67bae9b53491099c63b88a1d3f6"),
"the ineffective test — a finished tick with no head did no work"
);
assert_eq!(hb.poll_seconds, Some(60), "the staleness budget");
}
#[test]
fn a_pulse_missing_every_optional_field_still_parses() {
let hb: Heartbeat = serde_json::from_str("{}").expect("an empty object must parse");
assert!(hb.at_unix_ms.is_none() && hb.outcome.is_none());
}
}