use anyhow::Result;
use serde::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";
const STALE_AFTER_POLLS: u64 = 3;
#[must_use]
pub fn classify(
deployed: Option<&str>,
head: Option<&str>,
tick_age_secs: Option<u64>,
poll_seconds: Option<u64>,
failures: Option<u64>,
) -> (&'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"
)),
);
}
};
if age > STALE_AFTER_POLLS * poll {
return (
STOPPED,
Some(format!(
"no tick for {age}s against a {poll}s poll — the loop is stopped, not idle"
)),
);
}
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),
_ => (
UNKNOWN,
Some("the reconciler published no branch HEAD; cannot prove convergence".to_owned()),
),
}
}
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_json(&state_dir.join("heartbeat.json"));
let tick_at_ms = beat.as_ref().and_then(|v| v["at_unix_ms"].as_u64());
let outcome = beat
.as_ref()
.and_then(|v| v["outcome"].as_str())
.map(str::to_owned);
let head_rev = beat
.as_ref()
.and_then(|v| v["head_rev"].as_str())
.map(str::to_owned);
let age = tick_at_ms.map(|ms| now_epoch.saturating_sub(ms / 1000));
let (deployed_rev, failures) = read_chain_tail(&state_dir.join("receipts.json"));
let poll = beat.as_ref().and_then(|v| v["poll_seconds"].as_u64());
let (verdict, reason) = classify(
deployed_rev.as_deref(),
head_rev.as_deref(),
age,
poll,
failures,
);
NodeConvergence {
node,
engine: "sentinela",
verdict,
reason,
deployed_rev,
head_rev,
last_tick_age_secs: age,
last_tick_outcome: outcome,
consecutive_failures: failures,
}
}
fn read_json(path: &Path) -> Option<serde_json::Value> {
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
}
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_rev: Option<String> = None;
for line in raw.lines().rev() {
let t = line.trim();
if let Some(k) = t.strip_prefix("kind:") {
if k.trim() == "activated" {
activated = pending_rev.clone();
break;
}
streak += 1;
} else if let Some(r) = t.strip_prefix("rev:") {
pending_rev = Some(r.trim().to_owned());
}
}
(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";
#[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));
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));
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));
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)).0,
UNKNOWN
);
let (v, why) = classify(Some(REV), Some(REV), Some(30), None, Some(0));
assert_eq!(v, UNKNOWN);
assert!(why.unwrap().contains("no poll interval"));
assert_eq!(
classify(Some(REV), None, Some(30), Some(POLL), Some(0)).0,
UNKNOWN
);
}
#[test]
fn alive_at_head_and_not_failing_is_converged() {
let (v, why) = classify(Some(REV), Some(REV), Some(30), Some(POLL), Some(0));
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 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)).0,
CONVERGED
);
assert_eq!(
classify(Some(REV), Some(REV), Some(budget + 1), Some(POLL), Some(0)).0,
STOPPED
);
}
}