#![forbid(unsafe_code)]
mod introspect;
mod real_env;
use clap::{Parser, Subcommand};
use real_env::RealEnv;
use sentinela_config::SentinelaConfig;
use sentinela_core::{GitopsEnv, Sentinela, State, TickOutcome};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "sentinela", version, about)]
struct Cli {
#[arg(
long,
env = "SENTINELA_CONFIG",
default_value = "/etc/pleme-gitops/config.yaml"
)]
config: PathBuf,
#[command(subcommand)]
command: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Run,
Status {
#[arg(long)]
gate: bool,
#[arg(long)]
json: bool,
},
Verify,
Probe,
TickOnce,
RollbackTo {
generation: u64,
},
}
fn main() -> std::process::ExitCode {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
let cfg = match load_config(&cli.config) {
Ok(c) => c,
Err(e) => {
tracing::error!(error = %e, path = %cli.config.display(), "sentinela: config load failed");
return std::process::ExitCode::FAILURE;
}
};
match cli.command {
Cmd::Run => run(cfg, &cli.config),
Cmd::Status { gate, json } => status(&cfg, gate, json),
Cmd::Verify => verify(&cfg),
Cmd::Probe => probe(&cfg),
Cmd::TickOnce => tick_once(&cfg),
Cmd::RollbackTo { generation } => rollback_to(&cfg, generation),
}
}
fn rollback_to(cfg: &SentinelaConfig, generation: u64) -> std::process::ExitCode {
let env = RealEnv::new(cfg.clone());
match env.rollback_to(sentinela_core::Generation(generation)) {
Ok(g) => {
println!("running generation {g}");
std::process::ExitCode::SUCCESS
}
Err(e) => {
tracing::error!(error = %e, generation, "rollback failed");
std::process::ExitCode::FAILURE
}
}
}
fn probe(cfg: &SentinelaConfig) -> std::process::ExitCode {
let env = RealEnv::new(cfg.clone());
match env.probe_head() {
Ok(Some(rev)) => {
println!("{rev}");
std::process::ExitCode::SUCCESS
}
Ok(None) => {
tracing::warn!(branch = %cfg.rev_probe.branch, "HEAD unresolvable (empty ls-remote)");
std::process::ExitCode::FAILURE
}
Err(e) => {
tracing::error!(error = %e, "probe failed");
std::process::ExitCode::FAILURE
}
}
}
fn load_config(path: &PathBuf) -> Result<SentinelaConfig, String> {
let raw = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
serde_yaml::from_str(&raw).map_err(|e| e.to_string())
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PreflightFailure {
ToolMissing { tool: String },
FlakeRefUnsupported {
tool: &'static str,
flake_url: String,
},
SuiEmbeddedUnavailable,
}
impl std::fmt::Display for PreflightFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ToolMissing { tool } => write!(f, "required tool not found: {tool}"),
Self::SuiEmbeddedUnavailable => write!(
f,
"rebuild_tool = sui with sui.mode = embedded needs a sentinela built \
with the `sui-driver` feature, and this binary was built without it. \
Select sui.mode = subprocess, or deploy the sui-driver build."
),
Self::FlakeRefUnsupported { tool, flake_url } => write!(
f,
"{tool} cannot resolve the flake ref `{flake_url}` — it accepts \
an absolute filesystem path only, and this daemon builds a \
rev-pinned ref off `flake_url`. Every tick would fail closed \
while the loop reported healthy."
),
}
}
}
fn preflight(cfg: &SentinelaConfig) -> Vec<PreflightFailure> {
let mut failures = Vec::new();
match std::process::Command::new("git").arg("--version").output() {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
failures.push(PreflightFailure::ToolMissing {
tool: "git".to_owned(),
});
}
Err(_) | Ok(_) => {}
}
if !uses_embedded_sui(cfg) {
let rebuild = cfg.rebuild_binary();
if !std::path::Path::new(rebuild).exists() {
failures.push(PreflightFailure::ToolMissing {
tool: rebuild.to_owned(),
});
}
}
failures.extend(flake_ref_failure(cfg));
failures.extend(sui_mode_failure(cfg, cfg!(feature = "sui-driver")));
if matches!(cfg.revision_policy, sentinela_config::RevisionPolicyConfig::Green { .. })
&& matches!(
std::process::Command::new("curl").arg("--version").output(),
Err(ref e) if e.kind() == std::io::ErrorKind::NotFound
)
{
failures.push(PreflightFailure::ToolMissing {
tool: "curl".to_owned(),
});
}
failures
}
fn flake_ref_failure(cfg: &SentinelaConfig) -> Option<PreflightFailure> {
if cfg.flake_ref_is_resolvable() {
return None;
}
Some(PreflightFailure::FlakeRefUnsupported {
tool: cfg.rebuild_tool.binary(),
flake_url: cfg.flake_url.clone(),
})
}
fn uses_embedded_sui(cfg: &SentinelaConfig) -> bool {
cfg.rebuild_tool == sentinela_config::RebuildTool::Sui
&& cfg.sui.mode == sentinela_config::SuiMode::Embedded
}
fn sui_mode_failure(cfg: &SentinelaConfig, built_with_sui_driver: bool) -> Option<PreflightFailure> {
(uses_embedded_sui(cfg) && !built_with_sui_driver).then_some(PreflightFailure::SuiEmbeddedUnavailable)
}
fn run(cfg: SentinelaConfig, config_path: &std::path::Path) -> std::process::ExitCode {
let failures = preflight(&cfg);
if !failures.is_empty() {
let reasons = failures
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join("; ");
tracing::error!(
reasons = %reasons,
rebuild_tool = cfg.rebuild_binary(),
"sentinela: PREFLIGHT FAILED — refusing to start. \
A daemon that cannot probe or rebuild must not report itself healthy; \
every tick would fail closed while every liveness surface read green. \
On NixOS add missing tools to the unit's `path` (systemd does not \
inherit a login PATH)."
);
return std::process::ExitCode::FAILURE;
}
let loop_cfg = cfg.loop_config();
let rollback = cfg.rollback_plan();
log_rollback_plan(&rollback);
let revision_policy = match cfg.revision_policy() {
Ok(p) => p,
Err(e) => {
tracing::error!(error = %e, "sentinela: revision_policy refused — refusing to start");
return std::process::ExitCode::FAILURE;
}
};
if let sentinela_core::RevisionPolicy::Green(g) = &revision_policy {
tracing::info!(required = ?g.required, "revision policy: green (deploy only revisions whose required checks passed)");
}
let env = std::sync::Arc::new(RealEnv::new(cfg));
match kanshou::Server::spawn_sidecar(
"sentinela",
introspect::SentinelaIntrospect::new(std::sync::Arc::clone(&env)),
) {
Some(sock) => tracing::info!(socket = %sock.display(), "introspection live"),
None => tracing::warn!("introspection unavailable — the loop still converges"),
}
let mut sentinela = Sentinela::new(loop_cfg)
.with_rollback(rollback.policy)
.with_revision_policy(revision_policy);
let mut checks_report = ChecksReport::default();
tracing::info!(
poll_seconds = loop_cfg.poll_seconds,
"sentinela: daemon started"
);
let mut prev = health(&env);
log_health(&prev);
let loaded_config = std::fs::canonicalize(config_path).ok();
loop {
let outcome = sentinela.tick(&*env);
log_outcome(&outcome);
checks_report.log(&outcome);
let now = health(&env);
if should_report(&prev, &now) {
log_health(&now);
}
prev = now;
if config_moved(
loaded_config.as_deref(),
std::fs::canonicalize(config_path).ok().as_deref(),
) {
tracing::info!(
config = %config_path.display(),
"sentinela: config changed — exiting so the service manager starts the new one"
);
return std::process::ExitCode::SUCCESS;
}
std::thread::sleep(sentinela.next_delay(&outcome));
}
}
#[derive(Debug, Default)]
struct ChecksReport {
last: Option<String>,
}
impl ChecksReport {
fn news(&mut self, outcome: &TickOutcome) -> Option<String> {
let key = match outcome {
TickOutcome::ChecksRed { rev, failed } => {
["checks-red ", rev.short(), " (", &failed.join(", "), ")"].concat()
}
TickOutcome::ChecksBlind { reason, .. } => ["blind ", reason.as_str()].concat(),
TickOutcome::WaitingForGreen { .. } => return None,
_ => {
self.last = None;
return None;
}
};
if self.last.as_deref() == Some(key.as_str()) {
return None;
}
self.last = Some(key.clone());
Some(key)
}
fn log(&mut self, outcome: &TickOutcome) {
if let Some(line) = self.news(outcome) {
tracing::warn!(
"gitops: {line} — staying on the current generation until a revision passes its required checks"
);
}
}
}
fn config_moved(loaded: Option<&std::path::Path>, now: Option<&std::path::Path>) -> bool {
matches!((loaded, now), (Some(a), Some(b)) if a != b)
}
fn log_rollback_plan(plan: &sentinela_config::RollbackPlan) {
for r in &plan.refused {
tracing::warn!(probe = %r.name, reason = %r.reason, "rollback: probe entry refused (its siblings still load)");
}
match &plan.policy {
Some(p) => tracing::info!(
probes = p.probes.len(),
window_seconds = p.window_ms / 1000,
interval_seconds = p.interval_seconds,
required_consecutive_passes = p.required_consecutive_passes,
"rollback: health-gated rollback ON"
),
None if plan.enabled_without_probes => tracing::error!(
"rollback: enabled, but no probe entry is valid — health-gated rollback is OFF"
),
None => {}
}
}
use introspect::Health;
fn health(env: &RealEnv) -> Health {
introspect::health_of(env)
}
fn should_report(prev: &Health, now: &Health) -> bool {
!matches!(now, Health::Converged { .. }) || !matches!(prev, Health::Converged { .. })
}
fn log_health(h: &Health) {
match h {
Health::NeverDeployed => {
tracing::warn!("gitops: no deploy recorded yet — expected on a freshly-enrolled node")
}
Health::Degraded { streak, last_ok } => tracing::error!(
consecutive_failures = streak,
last_activated = %last_ok,
"gitops: DEGRADED — this node is not tracking the branch"
),
Health::Starved { deferrals, last_ok } => tracing::warn!(
consecutive_deferrals = deferrals,
last_activated = %last_ok,
"gitops: STARVED — each build finishes against a moved HEAD; \
nothing is broken, the branch is moving faster than one build"
),
Health::Converged { last_ok } => {
tracing::info!(last_activated = %last_ok, "gitops: converged")
}
Health::Unreadable { error } => {
tracing::error!(error = %error, "gitops: receipt chain unreadable")
}
}
}
fn log_outcome(outcome: &TickOutcome) {
match outcome {
TickOutcome::Deployed { rev, generation } => {
tracing::info!(rev = rev.short(), generation = %generation, "deployed");
}
TickOutcome::DeployedBehind {
rev,
generation,
newer,
} => {
tracing::info!(
rev = rev.short(),
generation = %generation,
newer = newer.short(),
"deployed an ancestor of HEAD to escape starvation; converging next tick"
);
}
TickOutcome::Unchanged { rev } => tracing::debug!(rev = rev.short(), "unchanged"),
TickOutcome::Deferred { built, newer } => {
tracing::info!(
built = built.short(),
newer = newer.short(),
"deferred (HEAD moved mid-build)"
);
}
TickOutcome::ReprobeInconclusive { built } => {
tracing::warn!(
built = built.short(),
"post-build re-probe empty — not activated (fail-closed)"
);
}
TickOutcome::Unresolvable => tracing::warn!("HEAD unresolvable (fail-closed)"),
TickOutcome::ProbeError { error } => tracing::warn!(%error, "probe error (fail-closed)"),
TickOutcome::BuildFailed { rev, error } => {
tracing::error!(rev = rev.short(), %error, "build failed");
}
TickOutcome::SwitchFailed { rev, error } => {
tracing::error!(rev = rev.short(), %error, "switch failed");
}
TickOutcome::SwitchDeferred { rev, holder } => {
tracing::info!(
rev = rev.short(),
holder = %holder,
"switch deferred — another rebuild holds the machine lock; standing aside"
);
}
TickOutcome::CoolingDown { remaining_ms } => {
tracing::debug!(remaining_ms, "cooling down");
}
TickOutcome::WaitingForGreen { rev } => {
tracing::debug!(rev = rev.short(), "waiting-for-green");
}
TickOutcome::ChecksRed { .. } | TickOutcome::ChecksBlind { .. } => {}
TickOutcome::Settling { rev, remaining_ms } => {
tracing::debug!(rev = rev.short(), remaining_ms, "settling");
}
TickOutcome::Verifying(_)
| TickOutcome::Verified { .. }
| TickOutcome::RolledBack { .. }
| TickOutcome::RollbackDeferred { .. }
| TickOutcome::RollbackFailed { .. }
| TickOutcome::ProbationAbandoned { .. }
| TickOutcome::Quarantined { .. } => log_probation_outcome(outcome),
}
}
fn log_probation_outcome(outcome: &TickOutcome) {
match outcome {
TickOutcome::Verifying(p) => match &p.last_failure {
None => tracing::info!(
rev = p.rev.short(),
passes = p.passes,
required = p.required_passes,
"verifying: probe round passed"
),
Some(f) => tracing::warn!(
rev = p.rev.short(),
probe = %f.probe,
evidence = %f.evidence,
"verifying: probe round FAILED (window still open)"
),
},
TickOutcome::Verified { rev, generation } => {
tracing::info!(rev = rev.short(), generation = %generation, "activation verified");
}
TickOutcome::RolledBack {
rev,
from,
to,
failure,
} => {
tracing::error!(
rev = rev.short(),
from = %from,
to = %to,
probe = %failure.probe,
evidence = %failure.evidence,
"ROLLED BACK — probation failed; rev quarantined until HEAD moves"
);
}
TickOutcome::RollbackDeferred { rev, holder } => {
tracing::warn!(rev = rev.short(), holder = %holder, "rollback due; another rebuild holds the machine lock");
}
TickOutcome::RollbackFailed { rev, error } => {
tracing::error!(rev = rev.short(), %error, "ROLLBACK FAILED — still on the generation under probation; retrying");
}
TickOutcome::ProbationAbandoned {
rev,
expected,
found,
} => {
tracing::warn!(
rev = rev.short(),
expected = %expected,
found = ?found,
"probation abandoned: the system moved to another generation"
);
}
TickOutcome::Quarantined { rev } => {
tracing::warn!(rev = rev.short(), "HEAD is quarantined (rolled back); waiting for HEAD to move");
}
_ => {}
}
}
fn status(cfg: &SentinelaConfig, gate: bool, json: bool) -> std::process::ExitCode {
let env = RealEnv::new(cfg.clone());
let chain = match env.load_chain() {
Ok(c) => c,
Err(e) => {
tracing::error!(error = %e, "status: could not load chain");
return std::process::ExitCode::FAILURE;
}
};
let verified = chain.verify().is_ok();
let beat = env.load_heartbeat();
let status = serde_json::json!({
"hostname": cfg.hostname,
"flake_url": cfg.flake_url,
"branch": cfg.rev_probe.branch,
"receipts": chain.len(),
"consecutive_failures": chain.consecutive_failures(),
"consecutive_deferrals": chain.consecutive_deferrals(),
"chain_verified": verified,
"last_activated_rev": chain.last_activated_rev().map(sentinela_core::Rev::as_str),
"head": chain.head(),
"poll_seconds": cfg.poll_seconds,
"last_tick_at_unix_ms": beat.as_ref().map(|b| b.at_unix_ms),
"last_tick_outcome": beat.as_ref().map(|b| b.outcome.clone()),
"phase": beat.as_ref().map(|b| b.phase),
"rollback_enabled": cfg.rollback.enabled,
"verification": beat.as_ref().and_then(|b| b.verification.as_ref()),
"last_rollback": chain.last_rollback(),
"quarantined_rev": chain.quarantined_rev().map(sentinela_core::Rev::as_str),
"last_failure": last_failure_lines(&chain),
"recent_failures": recent_failures(&chain, env.now_unix_ms(), RECENT_FAILURE_WINDOW_MS),
"converged": convergence_gate(
&beat,
env.now_unix_ms(),
cfg.poll_seconds,
cfg.build_timeout_seconds,
chain.consecutive_failures(),
verified,
)
.map_or_else(|why| serde_json::json!({"ok": false, "why": why}),
|()| serde_json::json!({"ok": true, "why": null})),
"head_rev": beat
.as_ref()
.and_then(|b| b.head_rev.as_ref())
.map(|r| r.as_str().to_owned()),
});
if json {
println!(
"{}",
serde_json::to_string_pretty(&status).unwrap_or_default()
);
} else {
print!("{}", StatusView(&status));
}
if !gate {
return std::process::ExitCode::SUCCESS;
}
let now_ms = env.now_unix_ms();
match convergence_gate(
&beat,
now_ms,
cfg.poll_seconds,
cfg.build_timeout_seconds,
chain.consecutive_failures(),
verified,
) {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(why) => {
tracing::error!(reason = %why, "gitops: NOT CONVERGED");
std::process::ExitCode::FAILURE
}
}
}
fn convergence_gate(
beat: &Option<sentinela_core::Heartbeat>,
now_unix_ms: u64,
poll_seconds: u64,
build_timeout_seconds: u64,
consecutive_failures: usize,
chain_verified: bool,
) -> Result<(), String> {
const STALE_AFTER_POLLS: u64 = 3;
if !chain_verified {
return Err("receipt chain failed verification".to_owned());
}
let Some(beat) = beat else {
return Err("no heartbeat has ever been published — liveness unknown".to_owned());
};
let silent_ms = now_unix_ms.saturating_sub(beat.at_unix_ms);
let budget_ms = STALE_AFTER_POLLS * poll_seconds.max(1) * 1000;
let build_budget_ms = beat.budget_seconds.unwrap_or(build_timeout_seconds).max(1) * 1000;
match beat.phase {
sentinela_core::Phase::InFlight => {
if silent_ms > build_budget_ms {
return Err(format!(
"tick has been `{}` for {}s, past its {}s budget \
— a tick this long is a hang, not progress",
beat.outcome,
silent_ms / 1000,
build_budget_ms / 1000
));
}
}
sentinela_core::Phase::Resolved => {
if silent_ms > budget_ms {
return Err(format!(
"no tick for {}s (budget {}s) — the loop is stopped, not idle",
silent_ms / 1000,
budget_ms / 1000
));
}
}
}
if consecutive_failures > 0 {
return Err(format!("{consecutive_failures} consecutive failed ticks"));
}
Ok(())
}
fn verify(cfg: &SentinelaConfig) -> std::process::ExitCode {
let env = RealEnv::new(cfg.clone());
match env.load_chain().map(|c| c.verify()) {
Ok(Ok(())) => {
println!("ok: receipt chain verified");
std::process::ExitCode::SUCCESS
}
Ok(Err(e)) => {
tracing::error!(error = %e, "chain verification FAILED");
std::process::ExitCode::FAILURE
}
Err(e) => {
tracing::error!(error = %e, "could not load chain");
std::process::ExitCode::FAILURE
}
}
}
fn tick_once(cfg: &SentinelaConfig) -> std::process::ExitCode {
let env = RealEnv::new(cfg.clone());
let rollback = cfg.rollback_plan();
log_rollback_plan(&rollback);
let mut sentinela = Sentinela::new(cfg.loop_config()).with_rollback(rollback.policy);
let outcome = sentinela.tick(&env);
log_outcome(&outcome);
if matches!(sentinela.state(), State::CoolingDown { .. }) {
tracing::info!("entered cooldown after a failure");
}
std::process::ExitCode::SUCCESS
}
#[cfg(test)]
mod checks_report_tests {
use super::{ChecksReport, TickOutcome};
use sentinela_core::Rev;
fn rev(n: u8) -> Rev {
Rev::parse(&format!("{n:02x}").repeat(20)).unwrap()
}
#[test]
fn a_red_head_is_said_once_per_revision_and_waiting_is_quiet() {
let mut r = ChecksReport::default();
let red = |n| TickOutcome::ChecksRed { rev: rev(n), failed: vec!["promotion-gate".to_owned()] };
assert!(r.news(&red(2)).is_some());
assert!(r.news(&red(2)).is_none(), "the same red HEAD again is not news");
assert!(r.news(&TickOutcome::WaitingForGreen { rev: rev(3) }).is_none());
assert!(r.news(&red(3)).is_some(), "a new red HEAD is");
let blind = TickOutcome::ChecksBlind { head: rev(3), reason: "GitHub answered 403".to_owned() };
assert!(r.news(&blind).unwrap().starts_with("blind "));
assert!(r.news(&blind).is_none());
let _ = r.news(&TickOutcome::Unchanged { rev: rev(3) });
assert!(r.news(&blind).is_some());
}
}
#[cfg(test)]
mod config_reload_tests {
use super::config_moved;
use std::path::Path;
#[test]
fn a_different_resolved_config_is_a_change() {
let a = Path::new("/nix/store/aaa-pleme-gitops-config.yaml");
let b = Path::new("/nix/store/bbb-pleme-gitops-config.yaml");
assert!(config_moved(Some(a), Some(b)));
assert!(!config_moved(Some(a), Some(a)));
}
#[test]
fn an_unreadable_path_is_not_a_change() {
let a = Path::new("/nix/store/aaa-pleme-gitops-config.yaml");
assert!(!config_moved(Some(a), None), "a momentary miss mid-switch");
assert!(!config_moved(None, Some(a)), "nothing resolved at startup");
}
}
#[cfg(test)]
mod preflight_tests {
use super::{PreflightFailure, flake_ref_failure, sui_mode_failure};
use sentinela_config::{RebuildTool, SentinelaConfig, SuiConfig, SuiMode};
fn cfg_with(tool: RebuildTool, flake_url: &str) -> SentinelaConfig {
SentinelaConfig {
flake_url: flake_url.to_owned(),
hostname: "rio".to_owned(),
rebuild_tool: tool,
..SentinelaConfig::default()
}
}
#[test]
fn sui_resolves_the_rev_pinned_remote_ref_the_loop_builds() {
assert_eq!(
flake_ref_failure(&cfg_with(RebuildTool::Sui, "github:pleme-io/nix")),
None
);
}
fn sui_cfg(mode: SuiMode) -> SentinelaConfig {
SentinelaConfig {
sui: SuiConfig {
mode,
..SuiConfig::default()
},
..cfg_with(RebuildTool::Sui, "github:pleme-io/nix")
}
}
#[test]
fn embedded_sui_without_the_driver_build_is_refused_at_startup() {
assert_eq!(
sui_mode_failure(&sui_cfg(SuiMode::Embedded), false),
Some(PreflightFailure::SuiEmbeddedUnavailable)
);
assert!(
PreflightFailure::SuiEmbeddedUnavailable
.to_string()
.contains("sui-driver")
);
}
#[test]
fn embedded_sui_with_the_driver_build_and_subprocess_sui_both_start() {
assert_eq!(sui_mode_failure(&sui_cfg(SuiMode::Embedded), true), None);
assert_eq!(sui_mode_failure(&sui_cfg(SuiMode::Subprocess), false), None);
}
#[test]
fn the_sui_mode_never_refuses_a_nix_tool() {
for tool in [RebuildTool::DarwinRebuild, RebuildTool::NixosRebuild] {
let cfg = SentinelaConfig {
rebuild_tool: tool,
..sui_cfg(SuiMode::Embedded)
};
assert_eq!(sui_mode_failure(&cfg, false), None, "{tool:?}");
}
}
#[test]
fn subprocess_sui_runs_the_configured_binary_and_the_nix_tools_their_own() {
let mut cfg = sui_cfg(SuiMode::Subprocess);
cfg.sui.binary = "/nix/store/x-sui-0.1.219/bin/sui".to_owned();
assert_eq!(cfg.rebuild_binary(), "/nix/store/x-sui-0.1.219/bin/sui");
cfg.rebuild_tool = RebuildTool::NixosRebuild;
assert_eq!(cfg.rebuild_binary(), RebuildTool::NixosRebuild.binary());
}
#[test]
fn the_nix_tools_are_never_refused_for_a_remote_flake_url() {
for tool in [RebuildTool::DarwinRebuild, RebuildTool::NixosRebuild] {
assert_eq!(
flake_ref_failure(&cfg_with(tool, "github:pleme-io/nix")),
None,
"{tool:?} resolves remote refs natively"
);
}
}
#[test]
fn sui_against_an_absolute_path_passes_the_flake_ref_check() {
assert_eq!(
flake_ref_failure(&cfg_with(RebuildTool::Sui, "/srv/nix-checkouts")),
None,
"an absolute path is exactly what sui's parser accepts"
);
}
#[test]
fn the_refusal_names_the_tool_and_the_url() {
let msg = PreflightFailure::FlakeRefUnsupported {
tool: "/run/current-system/sw/bin/sui",
flake_url: "github:pleme-io/nix".to_owned(),
}
.to_string();
assert!(msg.contains("sui"), "{msg}");
assert!(msg.contains("github:pleme-io/nix"), "{msg}");
}
}
#[cfg(test)]
mod health_report_tests {
use super::{should_report, Health};
fn converged() -> Health {
Health::Converged {
last_ok: "28638ed".to_owned(),
}
}
#[test]
fn degraded_reports_on_every_tick() {
let d = Health::Degraded {
streak: 9,
last_ok: "28638ed".to_owned(),
};
assert!(should_report(&d, &d), "a persistently degraded loop stays loud");
}
#[test]
fn starved_reports_on_every_tick() {
let s = Health::Starved {
deferrals: 2,
last_ok: "28638ed".to_owned(),
};
assert!(should_report(&s, &s));
}
#[test]
fn converged_stays_quiet() {
assert!(!should_report(&converged(), &converged()));
}
#[test]
fn recovery_is_announced_once() {
let broken = Health::Degraded {
streak: 9,
last_ok: "28638ed".to_owned(),
};
assert!(should_report(&broken, &converged()), "the recovery tick speaks");
assert!(
!should_report(&converged(), &converged()),
"the tick after recovery is silent again"
);
}
#[test]
fn never_deployed_is_not_converged() {
assert!(should_report(&Health::NeverDeployed, &Health::NeverDeployed));
}
#[test]
fn unreadable_chain_reports() {
let u = Health::Unreadable {
error: "permission denied".to_owned(),
};
assert!(should_report(&u, &u));
}
}
#[cfg(test)]
mod gate_tests {
use super::convergence_gate;
use sentinela_core::Heartbeat;
const POLL: u64 = 60;
const NOW_MS: u64 = 1_785_724_816_000;
#[test]
fn a_build_longer_than_the_stale_budget_is_never_reported_stopped() {
let budget_ms = 3 * POLL * 1000;
let ancient = NOW_MS - (29 * 60 * 1000);
assert!(
ancient < NOW_MS - budget_ms,
"the fixture must exceed the budget"
);
assert!(
convergence_gate(&in_flight_beat_at(ancient), NOW_MS, POLL, BUILD_BUDGET, 0, true).is_ok(),
"a tick still inside its build has not missed a cycle — it is doing the work"
);
let err = convergence_gate(&beat_at(ancient), NOW_MS, POLL, BUILD_BUDGET, 0, true)
.expect_err("a finished tick that old means the loop stopped");
assert!(err.contains("stopped"), "got: {err}");
}
const BUILD_BUDGET: u64 = 2700;
fn beat_at(ms: u64) -> Option<Heartbeat> {
Some(Heartbeat {
at_unix_ms: ms,
outcome: "unchanged".to_owned(),
phase: sentinela_core::Phase::Resolved,
head_rev: None,
poll_seconds: POLL,
budget_seconds: None,
in_flight: None,
verification: None,
})
}
fn in_flight_beat_at(ms: u64) -> Option<Heartbeat> {
Some(Heartbeat {
at_unix_ms: ms,
outcome: "building".to_owned(),
phase: sentinela_core::Phase::InFlight,
head_rev: None,
poll_seconds: POLL,
budget_seconds: None,
in_flight: None,
verification: None,
})
}
#[test]
fn an_in_flight_tick_that_published_a_budget_is_judged_by_it() {
let planning = |budget: Option<u64>| {
Some(Heartbeat {
at_unix_ms: 0,
outcome: "planning".to_owned(),
phase: sentinela_core::Phase::InFlight,
head_rev: None,
poll_seconds: POLL,
budget_seconds: budget,
in_flight: None,
verification: None,
})
};
assert!(convergence_gate(&planning(Some(600)), 500_000, POLL, BUILD_BUDGET, 0, true).is_ok());
let late = convergence_gate(&planning(Some(600)), 700_000, POLL, BUILD_BUDGET, 0, true);
assert!(late.as_ref().is_err_and(|e| e.contains("`planning`")), "{late:?}");
assert!(convergence_gate(&planning(None), 700_000, POLL, BUILD_BUDGET, 0, true).is_ok());
}
#[test]
fn a_long_build_is_not_a_stopped_loop() {
let silent_ms = 722_000;
assert!(
silent_ms > 3 * POLL * 1000,
"the scenario must exceed the budget, or this proves nothing"
);
assert_eq!(
convergence_gate(&in_flight_beat_at(NOW_MS - silent_ms), NOW_MS, POLL, BUILD_BUDGET, 0, true),
Ok(()),
"a build in flight is work, not silence"
);
assert!(
convergence_gate(&beat_at(NOW_MS - silent_ms), NOW_MS, POLL, BUILD_BUDGET, 0, true).is_err(),
"a resolved tick that old IS a stopped loop"
);
}
#[test]
fn an_in_flight_pulse_does_not_mask_a_real_failure_streak() {
assert!(
convergence_gate(&in_flight_beat_at(NOW_MS - 722_000), NOW_MS, POLL, BUILD_BUDGET, 3, true).is_err(),
"in-flight must not suppress the failure-streak verdict"
);
}
#[test]
fn no_heartbeat_is_a_refusal_not_a_pass() {
let err = convergence_gate(&None, NOW_MS, POLL, BUILD_BUDGET, 0, true)
.expect_err("absent liveness evidence must never gate green");
assert!(err.contains("no heartbeat"), "{err}");
}
#[test]
fn cids_16_hour_silence_fails_the_gate() {
let err = convergence_gate(&beat_at(1_785_664_639_000), NOW_MS, POLL, BUILD_BUDGET, 0, true)
.expect_err("a loop silent for 16.7h against a 60s poll is stopped");
assert!(err.contains("60177s"), "{err}");
assert!(err.contains("stopped, not idle"), "{err}");
}
#[test]
fn a_fresh_tick_on_a_verified_chain_passes() {
convergence_gate(&beat_at(NOW_MS - 30_000), NOW_MS, POLL, BUILD_BUDGET, 0, true)
.expect("a loop that ticked 30s ago on a 60s poll is alive");
}
#[test]
fn the_staleness_boundary_is_three_poll_intervals() {
let budget_ms = 3 * POLL * 1000;
convergence_gate(&beat_at(NOW_MS - budget_ms), NOW_MS, POLL, BUILD_BUDGET, 0, true)
.expect("exactly at the budget is not yet stale");
convergence_gate(&beat_at(NOW_MS - budget_ms - 1), NOW_MS, POLL, BUILD_BUDGET, 0, true)
.expect_err("one millisecond past the budget is stale");
}
#[test]
fn a_failure_streak_fails_even_with_a_fresh_pulse() {
let err = convergence_gate(&beat_at(NOW_MS - 1000), NOW_MS, POLL, BUILD_BUDGET, 4136, true)
.expect_err("the ryn outage shape must not gate green");
assert!(err.contains("4136"), "{err}");
}
#[test]
fn an_unverifiable_chain_fails_before_anything_else_is_considered() {
let err = convergence_gate(&beat_at(NOW_MS - 1000), NOW_MS, POLL, BUILD_BUDGET, 0, false)
.expect_err("tamper-evidence outranks liveness");
assert!(err.contains("verification"), "{err}");
}
}
struct StatusView<'a>(&'a serde_json::Value);
fn paint(s: &str, code: &str) -> String {
use std::io::IsTerminal as _;
if std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() {
["\x1b[", code, "m", s, "\x1b[0m"].concat()
} else {
s.to_owned()
}
}
fn human_age(then_ms: u64, now_ms: u64) -> String {
let secs = now_ms.saturating_sub(then_ms) / 1000;
match secs {
0..=59 => [&secs.to_string(), "s"].concat(),
60..=3599 => [&(secs / 60).to_string(), "m", &(secs % 60).to_string(), "s"].concat(),
3600..=86399 => [&(secs / 3600).to_string(), "h", &((secs % 3600) / 60).to_string(), "m"].concat(),
_ => [&(secs / 86400).to_string(), "d", &((secs % 86400) / 3600).to_string(), "h"].concat(),
}
}
fn short(rev: &str) -> &str {
rev.get(..8).unwrap_or(rev)
}
fn next_step(
last_tick_at_unix_ms: Option<u64>,
now_ms: u64,
poll_seconds: u64,
consecutive_failures: u64,
) -> &'static str {
let stopped = match last_tick_at_unix_ms {
None => true,
Some(t) => now_ms.saturating_sub(t) > poll_seconds.max(1) * 3 * 1000,
};
if stopped {
"sudo launchctl kickstart -k system/org.nixos.pleme-gitops"
} else if consecutive_failures > 0 {
"log show --predicate 'process == \"sentinela\"' --last 30m"
} else {
"sentinela probe"
}
}
fn write_probation(
f: &mut std::fmt::Formatter<'_>,
v: &serde_json::Value,
now_ms: u64,
) -> std::fmt::Result {
if let Some(p) = v.get("verification").filter(|p| !p.is_null()) {
let u = |k: &str| p.get(k).and_then(serde_json::Value::as_u64).unwrap_or(0);
let rev = p.get("rev").and_then(serde_json::Value::as_str).unwrap_or("—");
let left = u("deadline_unix_ms").saturating_sub(now_ms) / 1000;
writeln!(f)?;
writeln!(
f,
" {:<11} {} gen {} · {}/{} passes · {}s left · rollback target gen {}",
"verifying",
paint(short(rev), "33;1"),
u("generation"),
u("passes"),
u("required_passes"),
left,
u("previous_generation"),
)?;
if let Some(fl) = p.get("last_failure").filter(|x| !x.is_null()) {
let probe = fl.get("probe").and_then(serde_json::Value::as_str).unwrap_or("?");
writeln!(f, " {}", paint(&["probe `", probe, "` failing:"].concat(), "31"))?;
for l in fl
.get("evidence")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.lines()
.rev()
.take(5)
.collect::<Vec<_>>()
.into_iter()
.rev()
{
writeln!(f, " {}", paint(l, "31"))?;
}
}
}
if let Some(q) = v.get("quarantined_rev").and_then(serde_json::Value::as_str) {
writeln!(
f,
" {:<11} {} rolled back — not re-attempted until HEAD moves",
"quarantine",
paint(short(q), "31;1")
)?;
}
Ok(())
}
impl std::fmt::Display for StatusView<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let v = self.0;
let s = |k: &str| v.get(k).and_then(serde_json::Value::as_str).unwrap_or("—").to_owned();
let n = |k: &str| v.get(k).and_then(serde_json::Value::as_u64);
let ok = v.pointer("/converged/ok").and_then(serde_json::Value::as_bool).unwrap_or(false);
let why = v.pointer("/converged/why").and_then(serde_json::Value::as_str);
let (glyph, word, colour) = if ok {
("●", "CONVERGED", "32;1")
} else {
("✗", "NOT CONVERGED", "31;1")
};
writeln!(f)?;
writeln!(f, " {} {:<44}{}", paint(glyph, colour), paint(word, colour), paint(&s("hostname"), "1"))?;
if let Some(w) = why {
writeln!(f, " {:<11} {}", "why", paint(w, "31"))?;
}
writeln!(f)?;
writeln!(f, " {:<11} {} · {}", "tracking", s("flake_url"), s("branch"))?;
let deployed = v.get("last_activated_rev").and_then(serde_json::Value::as_str);
let head = v.get("head_rev").and_then(serde_json::Value::as_str);
writeln!(f, " {:<11} {}", "deployed", deployed.map_or("— never activated".to_owned(), |r| short(r).to_owned()))?;
match (deployed, head) {
(Some(d), Some(h)) if d == h => writeln!(f, " {:<11} {} {}", "head", short(h), paint("(in sync)", "32"))?,
(_, Some(h)) => writeln!(f, " {:<11} {} {}", "head", short(h), paint("← BEHIND", "33;1"))?,
(_, None) => writeln!(f, " {:<11} {}", "head", paint("— the last tick resolved no branch head", "33"))?,
}
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX));
let tick = match n("last_tick_at_unix_ms") {
Some(t) => [
&human_age(t, now_ms), " ago · ", &s("last_tick_outcome"), " · ", &s("phase"),
].concat(),
None => paint("no heartbeat has ever been published", "31").to_string(),
};
writeln!(f, " {:<11} {} {}", "last tick", tick,
paint(&["(poll ", &n("poll_seconds").unwrap_or(0).to_string(), "s)"].concat(), "2"))?;
if let Some(lines) = v.get("last_failure").and_then(serde_json::Value::as_array) {
if !lines.is_empty() {
writeln!(f)?;
writeln!(f, " {:<11} {}", "failure", paint("the last tick failed at", "31;1"))?;
for l in lines {
if let Some(t) = l.as_str() {
writeln!(f, " {}", paint(t, "31"))?;
}
}
}
}
write_recent_failures(f, v, now_ms)?;
write_probation(f, v, now_ms)?;
let verified = v.get("chain_verified").and_then(serde_json::Value::as_bool).unwrap_or(false);
writeln!(f, " {:<11} {} · chain {}", "receipts", n("receipts").unwrap_or(0),
if verified { paint("verified", "32") } else { paint("UNVERIFIED", "31;1") })?;
let fails = n("consecutive_failures").unwrap_or(0);
let defers = n("consecutive_deferrals").unwrap_or(0);
let streaks = [
&if fails == 0 { paint("0 failures", "32") } else { paint(&[&fails.to_string(), " failures"].concat(), "31;1") },
" · ",
&if defers == 0 { paint("0 deferrals", "32") } else { paint(&[&defers.to_string(), " deferrals"].concat(), "33;1") },
].concat();
writeln!(f, " {:<11} {}", "streaks", streaks)?;
if !ok {
let next = next_step(
n("last_tick_at_unix_ms"),
now_ms,
n("poll_seconds").unwrap_or(60),
fails,
);
writeln!(f)?;
writeln!(f, " {:<11} {}", "next", paint(next, "36"))?;
}
writeln!(f)
}
}
#[cfg(test)]
mod next_step_tests {
use super::next_step;
const NOW: u64 = 1_000_000_000;
const FRESH: u64 = NOW - 10 * 1000;
const STALE: u64 = NOW - 12 * 3600 * 1000;
#[test]
fn a_stopped_loop_is_told_to_restart() {
assert!(next_step(Some(STALE), NOW, 60, 0).starts_with("sudo launchctl kickstart"));
assert!(next_step(None, NOW, 60, 0).starts_with("sudo launchctl kickstart"));
}
#[test]
fn a_live_failing_loop_is_pointed_at_the_logs() {
assert!(next_step(Some(FRESH), NOW, 60, 3).contains("log show"));
}
#[test]
fn a_live_healthy_but_behind_loop_is_pointed_at_the_probe() {
assert_eq!(next_step(Some(FRESH), NOW, 60, 0), "sentinela probe");
}
#[test]
fn no_hint_is_a_linux_only_command() {
let all = [
next_step(Some(STALE), NOW, 60, 0),
next_step(None, NOW, 60, 0),
next_step(Some(FRESH), NOW, 60, 3),
next_step(Some(FRESH), NOW, 60, 0),
];
assert_eq!(all.len(), 4, "all four branches must be exercised");
for h in all {
assert!(!h.contains("journalctl"), "Linux-only hint on a Darwin daemon: {h}");
assert!(!h.contains("systemctl"), "Linux-only hint on a Darwin daemon: {h}");
assert!(!h.is_empty(), "an empty hint is worse than none");
}
}
#[test]
fn the_stopped_threshold_scales_with_the_poll_interval() {
let age_10min = NOW - 600 * 1000;
assert!(next_step(Some(age_10min), NOW, 60, 0).starts_with("sudo launchctl"));
assert_eq!(next_step(Some(age_10min), NOW, 600, 0), "sentinela probe");
}
}
fn last_failure_lines(chain: &sentinela_core::ReceiptChain) -> Option<Vec<String>> {
let mut seen_success = false;
for r in chain.entries().iter().rev() {
match &r.outcome {
sentinela_core::Outcome::Activated { .. } => seen_success = true,
sentinela_core::Outcome::Failed { error } if !seen_success => {
let mut lines = distill_failure(error);
if let Some(cause) = failure_cause(error) {
lines.retain(|l| *l != cause);
lines.insert(0, cause);
}
return Some(lines);
}
sentinela_core::Outcome::RolledBack {
from,
to,
probe,
evidence,
} if !seen_success => {
let mut lines = vec![[
"rolled back generation ",
&from.to_string(),
" → ",
&to.to_string(),
": probe `",
probe,
"` failed",
]
.concat()];
lines.extend(distill_failure(evidence));
return Some(lines);
}
_ => {}
}
}
None
}
fn distill_failure(raw: &str) -> Vec<String> {
fn is_cascade(l: &str) -> bool {
let t = l.trim();
t.is_empty()
|| t.starts_with("Reason:")
|| t.starts_with("Output paths:")
|| t.starts_with("/nix/store/")
|| t.contains("Build failed due to failed dependency")
|| t.contains("Cannot build '")
|| t.starts_with("building '")
|| t.starts_with("unpacking ")
|| t.starts_with("these ")
|| t.starts_with("copying ")
|| t.starts_with("warning:")
}
let mut out: Vec<String> = Vec::new();
for line in raw.lines() {
if is_cascade(line) {
continue;
}
let t = line.trim().to_owned();
if out.last().map(String::as_str) == Some(t.as_str()) {
continue;
}
out.push(t);
}
out.dedup();
const MAX_LINES: usize = 6;
if out.len() > MAX_LINES {
let dropped = out.len() - MAX_LINES;
out.truncate(MAX_LINES);
let mut note = String::from("… ");
note.push_str(&dropped.to_string());
note.push_str(" more line(s) in the receipt");
out.push(note);
}
out
}
const RECENT_FAILURE_WINDOW_MS: u64 = 24 * 3600 * 1000;
fn is_cascade_error(t: &str) -> bool {
t.contains("Cannot build '")
|| t.contains("Build failed due to failed dependency")
|| t.contains("dependency failed")
|| t.contains("dependencies of derivation")
}
fn failure_cause(raw: &str) -> Option<String> {
let last_error = raw
.lines()
.rev()
.map(str::trim)
.find(|t| t.len() > "error: ".len() && t.starts_with("error: ") && !is_cascade_error(t));
last_error
.or_else(|| {
raw.lines().map(str::trim).find(|t| {
!t.is_empty()
&& !t.ends_with("building the system configuration...")
&& !t.starts_with('…')
&& !t.starts_with("warning:")
&& !t.starts_with("disabling binary cache")
&& !t.starts_with("unpacking ")
&& !t.starts_with("copying ")
&& !t.starts_with("building '")
&& !t.starts_with("these ")
&& !t.starts_with("/nix/store/")
&& !is_cascade_error(t)
})
})
.map(elide_store_hashes)
}
fn elide_store_hashes(s: &str) -> String {
const PREFIX: &str = "/nix/store/";
const HASH_LEN: usize = 32;
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find(PREFIX) {
let after = &rest[i + PREFIX.len()..];
let bytes = after.as_bytes();
let hashed = bytes.len() > HASH_LEN
&& bytes[HASH_LEN] == b'-'
&& bytes[..HASH_LEN].iter().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
out.push_str(&rest[..i + PREFIX.len()]);
if hashed {
out.push('…');
rest = &after[HASH_LEN..];
} else {
rest = after;
}
}
out.push_str(rest);
out
}
fn receipt_failure_cause(outcome: &sentinela_core::Outcome) -> Option<String> {
match outcome {
sentinela_core::Outcome::Failed { error } => Some(
failure_cause(error).unwrap_or_else(|| "no cause line in the receipt".to_owned()),
),
sentinela_core::Outcome::RolledBack { from, to, probe, .. } => Some(
[
"rolled back generation ",
&from.to_string(),
" → ",
&to.to_string(),
": probe `",
probe,
"` failed",
]
.concat(),
),
_ => None,
}
}
fn recent_failures(
chain: &sentinela_core::ReceiptChain,
now_ms: u64,
window_ms: u64,
) -> serde_json::Value {
let since = now_ms.saturating_sub(window_ms);
let mut causes: Vec<(String, u64, u64, String)> = Vec::new();
let mut failed_ticks = 0_u64;
let mut last_failure_at = 0_u64;
let mut last_activation_at = 0_u64;
for r in chain.entries().iter().filter(|r| r.at_unix_ms >= since) {
if matches!(r.outcome, sentinela_core::Outcome::Activated { .. }) {
last_activation_at = r.at_unix_ms;
continue;
}
let Some(cause) = receipt_failure_cause(&r.outcome) else {
continue;
};
failed_ticks += 1;
last_failure_at = r.at_unix_ms;
let rev = r.rev.as_str().to_owned();
match causes.iter_mut().find(|c| c.0 == cause) {
Some(c) => {
c.1 += 1;
c.2 = r.at_unix_ms;
c.3 = rev;
}
None => causes.push((cause, 1, r.at_unix_ms, rev)),
}
}
causes.sort_by(|a, b| b.2.cmp(&a.2));
let recovered_at = (failed_ticks > 0 && last_activation_at > last_failure_at)
.then_some(last_activation_at);
serde_json::json!({
"window_hours": window_ms / 3_600_000,
"failed_ticks": failed_ticks,
"recovered_at_unix_ms": recovered_at,
"causes": causes
.iter()
.map(|(cause, count, at, rev)| serde_json::json!({
"cause": cause,
"count": count,
"last_at_unix_ms": at,
"last_rev": rev,
}))
.collect::<Vec<_>>(),
})
}
fn clip_middle(s: &str, max_chars: usize) -> String {
let total = s.chars().count();
if total <= max_chars {
return s.to_owned();
}
let head = max_chars * 2 / 5;
let tail = max_chars.saturating_sub(head + 3);
let front: String = s.chars().take(head).collect();
let back: String = s.chars().skip(total - tail).collect();
[front.as_str(), " … ", back.as_str()].concat()
}
fn write_recent_failures(
f: &mut std::fmt::Formatter<'_>,
v: &serde_json::Value,
now_ms: u64,
) -> std::fmt::Result {
const SHOWN: usize = 3;
const CAUSE_CHARS: usize = 110;
let Some(r) = v.get("recent_failures").filter(|r| !r.is_null()) else {
return Ok(());
};
let ticks = r.get("failed_ticks").and_then(serde_json::Value::as_u64).unwrap_or(0);
if ticks == 0 {
return Ok(());
}
let hours = r.get("window_hours").and_then(serde_json::Value::as_u64).unwrap_or(24);
let state = match r.get("recovered_at_unix_ms").and_then(serde_json::Value::as_u64) {
Some(t) => paint(&["recovered ", &human_age(t, now_ms), " ago"].concat(), "32"),
None => paint("still failing", "31;1"),
};
writeln!(f)?;
writeln!(
f,
" {:<11} {} · {}",
"recent",
paint(
&[&ticks.to_string(), " failed tick(s) in the last ", &hours.to_string(), "h"].concat(),
"33;1"
),
state
)?;
let causes = r.get("causes").and_then(serde_json::Value::as_array).map_or(&[][..], Vec::as_slice);
for c in causes.iter().take(SHOWN) {
let u = |k: &str| c.get(k).and_then(serde_json::Value::as_u64).unwrap_or(0);
let cause = c.get("cause").and_then(serde_json::Value::as_str).unwrap_or("?");
let clipped = clip_middle(cause, CAUSE_CHARS);
writeln!(
f,
" ×{:<5} {:>9} ago {} {}",
u("count"),
human_age(u("last_at_unix_ms"), now_ms),
short(c.get("last_rev").and_then(serde_json::Value::as_str).unwrap_or("—")),
paint(&clipped, "33")
)?;
}
if causes.len() > SHOWN {
writeln!(
f,
" {}",
paint(
&["… ", &(causes.len() - SHOWN).to_string(), " more cause(s) in `sentinela status --json`"].concat(),
"2"
)
)?;
}
Ok(())
}
#[cfg(test)]
mod recent_failure_tests {
use super::{StatusView, clip_middle, failure_cause, last_failure_lines, recent_failures};
#[test]
fn a_clipped_cause_keeps_the_reason_at_its_end() {
let raw = "error: opening Git repository \"/var/root/.cache/nix/tarball-cache\": could not open '/private/var/root/.cache/nix/tarball-cache/config': Too many open files";
let out = clip_middle(raw, 110);
assert!(out.chars().count() <= 110, "{out}");
assert!(out.starts_with("error: opening Git repository"), "{out}");
assert!(out.ends_with("Too many open files"), "{out}");
assert_eq!(clip_middle("short", 110), "short");
}
use sentinela_core::{Generation, Outcome, ReceiptChain, Rev};
const HOUR: u64 = 3600 * 1000;
const NOW: u64 = 100 * HOUR;
const WINDOW: u64 = 24 * HOUR;
fn rev(c: char) -> Rev {
Rev::parse(&c.to_string().repeat(40)).unwrap()
}
const IROHA_EVAL: &str = "build failed: building the system configuration...\n\
error:\n … while evaluating the attribute 'optionalValue.value'\n\
\n at /nix/store/0123456789abcdfghijklmnpqrsvwxyz-source/lib/modules.nix:1118:7:\n\
\n … while calling the 'throw' builtin\n\
\n (stack trace truncated; use '--show-trace' to show the full, detailed trace)\n\
\n error: iroha gate (kata-fleet-pleme): FAILED — 45/46 passed";
const RESTART_GATE: &str = "switch failed: building the system configuration...\n\
engenho-restart-gate: HELD. This generation changes engenho-daemon's unit\n\
(its binary, its config or its PATH), and switching restarts every engenho";
const BUILDER: &str = "build failed: building the system configuration...\n\
error: builder for '/nix/store/0123456789abcdfghijklmnpqrsvwxyz-foo.drv' failed with exit code 1\n\
error: 1 dependencies of derivation '/nix/store/0123456789abcdfghijklmnpqrsvwxyz-bar.drv' failed to build\n\
error: Cannot build '/nix/store/0123456789abcdfghijklmnpqrsvwxyz-system.drv'.\n\
Reason: 1 dependency failed.";
#[test]
fn an_eval_failure_is_named_by_its_final_message_not_its_trace() {
assert_eq!(
failure_cause(IROHA_EVAL).as_deref(),
Some("error: iroha gate (kata-fleet-pleme): FAILED — 45/46 passed")
);
}
const FD_CRASH: &str = "build failed: building the system configuration...\n\
unpacking 'github:pleme-io/nix/0d73d7a864abeefba1df27a73d0c23df91648c82' into the Git cache...\n\
warning: error: unable to download 'http://rio:5000/nix-cache-info': Timeout was reached (28)\n\
disabling binary cache 'http://rio:5000' for 60 seconds\n\
warning: unable to download 'http://rio:5000/nix-cache-info': Timeout was reached (28)\n\
copying path '/nix/store/mn8gk4r87grls81sp5bjgfwcanrfqhlj-source' from 'https://cache.nixos.org'...\n\
*** bit out of range 0 - FD_SETSIZE on fd_set ***: terminated\n\
Command 'nix build' died with SIGABRT";
#[test]
fn a_crashed_nix_is_named_by_the_crash_not_the_substituter_noise() {
assert_eq!(
failure_cause(FD_CRASH).as_deref(),
Some("*** bit out of range 0 - FD_SETSIZE on fd_set ***: terminated")
);
}
#[test]
fn a_held_switch_is_named_by_the_gate_line() {
assert_eq!(
failure_cause(RESTART_GATE).as_deref(),
Some("engenho-restart-gate: HELD. This generation changes engenho-daemon's unit")
);
}
#[test]
fn a_build_failure_is_named_by_the_builder_not_its_cascade() {
assert_eq!(
failure_cause(BUILDER).as_deref(),
Some("error: builder for '/nix/store/…-foo.drv' failed with exit code 1")
);
}
#[test]
fn the_failure_block_leads_with_the_cause() {
let mut c = ReceiptChain::new();
c.append(c.next_receipt(rev('a'), Outcome::Failed { error: IROHA_EVAL.into() }, 1))
.unwrap();
let lines = last_failure_lines(&c).expect("a failure to report");
assert_eq!(lines[0], "error: iroha gate (kata-fleet-pleme): FAILED — 45/46 passed");
}
fn week_like_chain() -> ReceiptChain {
let mut c = ReceiptChain::new();
let mut push = |r: char, o: Outcome, at: u64| {
let next = c.next_receipt(rev(r), o, at);
c.append(next).unwrap();
};
push('a', Outcome::Failed { error: IROHA_EVAL.into() }, NOW - 30 * HOUR);
push('b', Outcome::Failed { error: RESTART_GATE.into() }, NOW - 5 * HOUR);
push('b', Outcome::Failed { error: RESTART_GATE.into() }, NOW - 4 * HOUR);
push('c', Outcome::Failed { error: IROHA_EVAL.into() }, NOW - 3 * HOUR);
push('d', Outcome::Activated { generation: Generation(7) }, NOW - 2 * HOUR);
c
}
#[test]
fn a_recovered_failure_stays_visible_inside_the_window() {
let r = recent_failures(&week_like_chain(), NOW, WINDOW);
assert_eq!(r["failed_ticks"], 3, "the 30h-old failure is outside the window: {r}");
assert_eq!(r["recovered_at_unix_ms"], NOW - 2 * HOUR);
let causes = r["causes"].as_array().unwrap();
assert_eq!(causes.len(), 2, "{r}");
assert_eq!(causes[0]["cause"], "error: iroha gate (kata-fleet-pleme): FAILED — 45/46 passed");
assert_eq!(causes[0]["count"], 1);
assert_eq!(causes[1]["count"], 2);
assert!(last_failure_lines(&week_like_chain()).is_none(), "the current-failure block stays recovery-aware");
}
#[test]
fn a_failure_after_the_last_activation_is_still_failing() {
let mut c = week_like_chain();
let next = c.next_receipt(rev('e'), Outcome::Failed { error: BUILDER.into() }, NOW - HOUR);
c.append(next).unwrap();
let r = recent_failures(&c, NOW, WINDOW);
assert!(r["recovered_at_unix_ms"].is_null(), "{r}");
assert_eq!(r["failed_ticks"], 4);
}
#[test]
fn a_clean_window_reports_nothing_and_the_panel_stays_quiet() {
let mut c = ReceiptChain::new();
let next = c.next_receipt(rev('a'), Outcome::Activated { generation: Generation(1) }, NOW - HOUR);
c.append(next).unwrap();
let r = recent_failures(&c, NOW, WINDOW);
assert_eq!(r["failed_ticks"], 0);
let v = serde_json::json!({"hostname": "plo", "converged": {"ok": true, "why": null}, "recent_failures": r});
assert!(!StatusView(&v).to_string().contains("recent"));
}
#[test]
fn the_panel_names_each_recent_cause_even_when_converged() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX));
let mut c = ReceiptChain::new();
let mut push = |r: char, o: Outcome, at: u64| {
let next = c.next_receipt(rev(r), o, at);
c.append(next).unwrap();
};
push('b', Outcome::Failed { error: RESTART_GATE.into() }, now - 5 * HOUR);
push('c', Outcome::Failed { error: IROHA_EVAL.into() }, now - 3 * HOUR);
push('d', Outcome::Activated { generation: Generation(7) }, now - 2 * HOUR);
let v = serde_json::json!({
"hostname": "plo",
"converged": {"ok": true, "why": null},
"recent_failures": recent_failures(&c, now, WINDOW),
});
let out = StatusView(&v).to_string();
assert!(out.contains("CONVERGED"), "{out}");
assert!(out.contains("2 failed tick(s) in the last 24h"), "{out}");
assert!(out.contains("recovered 2h0m ago"), "{out}");
assert!(out.contains("iroha gate (kata-fleet-pleme): FAILED"), "{out}");
assert!(out.contains("engenho-restart-gate: HELD"), "{out}");
assert!(out.contains("cccccccc"), "{out}");
}
}
#[cfg(test)]
mod distill_tests {
use super::distill_failure;
#[test]
fn the_root_cause_survives_the_cascade() {
let raw = "\
building '/nix/store/aaa-rust_thing.drv'...
build failed: bylq-rust_serde_derive_internals-0.29.1.drv
/nix/store/412niara-rust_async-trait-0.1.89.drv
error: Cannot build '/nix/store/397p-home-manager-generation.drv'.
Reason: 1 dependency failed.
Output paths:
/nix/store/9m25-home-manager-generation
error: Build failed due to failed dependency
";
let out = distill_failure(raw);
assert!(
out.iter().any(|l| l.contains("serde_derive_internals")),
"the cause must survive: {out:?}",
);
assert!(
!out.iter().any(|l| l.contains("Cannot build")),
"the cascade must not: {out:?}",
);
assert!(
!out.iter().any(|l| l.contains("home-manager-generation")),
"nor its output paths: {out:?}",
);
}
#[test]
fn repeated_causes_collapse_to_one() {
let raw = " build failed: X.drv\n build failed: X.drv\n build failed: X.drv\n";
assert_eq!(distill_failure(raw), vec!["build failed: X.drv".to_owned()]);
}
#[test]
fn an_unknown_error_shape_is_kept_rather_than_swallowed() {
let raw = "error: attribute 'darwinConfigurations.cid' missing\n";
let out = distill_failure(raw);
assert_eq!(out.len(), 1);
assert!(out[0].contains("attribute"), "{out:?}");
}
#[test]
fn a_long_failure_is_bounded_and_says_it_was() {
let raw: String = (0..40)
.map(|i| {
let mut l = String::from("error: distinct problem ");
l.push_str(&i.to_string());
l.push('\n');
l
})
.collect();
let out = distill_failure(&raw);
assert_eq!(out.len(), 7, "6 lines + the elision note: {out:?}");
assert!(out.last().expect("note").contains("more line(s)"), "{out:?}");
}
}
#[cfg(test)]
mod hang_gate_tests {
use super::convergence_gate;
use sentinela_core::Heartbeat;
const NOW_MS: u64 = 1_785_724_816_000;
const POLL: u64 = 60;
const BUILD_BUDGET: u64 = 2700;
fn in_flight_at(ms: u64) -> Option<Heartbeat> {
Some(Heartbeat {
at_unix_ms: ms,
outcome: "building".to_owned(),
phase: sentinela_core::Phase::InFlight,
head_rev: None,
poll_seconds: POLL,
budget_seconds: None,
in_flight: None,
verification: None,
})
}
#[test]
fn a_build_past_its_own_budget_is_a_hang_not_progress() {
let stuck = NOW_MS - (4 * 3600 + 39 * 60) * 1000;
let err = convergence_gate(&in_flight_at(stuck), NOW_MS, POLL, BUILD_BUDGET, 0, true)
.expect_err("a 4h39m build is a hang, and must not gate green");
assert!(err.contains("hang"), "the reason must say so: {err}");
assert!(err.contains("2700"), "and name the budget: {err}");
}
#[test]
fn a_build_inside_its_budget_is_still_work() {
let running = NOW_MS - 29 * 60 * 1000; assert!(
29 * 60 > 3 * POLL,
"the fixture must exceed the POLL budget or it proves nothing",
);
assert_eq!(
convergence_gate(&in_flight_at(running), NOW_MS, POLL, BUILD_BUDGET, 0, true),
Ok(()),
"29m < 45m budget — converging normally",
);
}
#[test]
fn the_budget_is_the_operators_not_a_hardcoded_one() {
let running = NOW_MS - 29 * 60 * 1000;
assert!(
convergence_gate(&in_flight_at(running), NOW_MS, POLL, 600, 0, true).is_err(),
"29m against a 10m ceiling is past it",
);
}
}
#[cfg(test)]
mod rollback_status_tests {
use super::{StatusView, last_failure_lines};
use sentinela_core::{Generation, Outcome, ReceiptChain, Rev};
fn rev(c: char) -> Rev {
Rev::parse(&c.to_string().repeat(40)).unwrap()
}
#[test]
fn the_panel_shows_a_probation_its_failing_probe_and_a_quarantine() {
let v = serde_json::json!({
"hostname": "cid",
"converged": {"ok": false, "why": "1 consecutive failed ticks"},
"verification": {
"rev": "a".repeat(40), "generation": 42, "previous_generation": 41,
"deadline_unix_ms": 0, "passes": 0, "required_passes": 2,
"last_failure": {"probe": "tailscale", "evidence": "exit 1 (expected 0): tailscaled not running"}
},
"quarantined_rev": "b".repeat(40),
});
let out = StatusView(&v).to_string();
assert!(out.contains("verifying"), "{out}");
assert!(out.contains("0/2 passes"), "{out}");
assert!(out.contains("rollback target gen 41"), "{out}");
assert!(out.contains("probe `tailscale` failing"), "{out}");
assert!(out.contains("tailscaled not running"), "{out}");
assert!(out.contains("bbbbbbbb rolled back"), "{out}");
}
#[test]
fn a_rollback_is_reported_as_the_last_failure() {
let mut c = ReceiptChain::new();
c.append(c.next_receipt(rev('a'), Outcome::Activated { generation: Generation(42) }, 1))
.unwrap();
c.append(c.next_receipt(
rev('a'),
Outcome::RolledBack {
from: Generation(42),
to: Generation(41),
probe: "tailscale".into(),
evidence: "exit 1 (expected 0): no route".into(),
},
2,
))
.unwrap();
let lines = last_failure_lines(&c).expect("a rollback is a failure to report");
assert_eq!(lines[0], "rolled back generation 42 → 41: probe `tailscale` failed");
assert!(lines.iter().any(|l| l.contains("no route")), "{lines:?}");
}
}