use std::sync::Arc;
use kanshou::{Introspect, Query, QueryError, QueryResult};
use sentinela_core::{EnvError, GitopsEnv, ReceiptChain};
pub trait ChainView: Send + Sync {
fn load_chain(&self) -> Result<ReceiptChain, EnvError>;
fn now_unix_ms(&self) -> u64;
}
impl<E: GitopsEnv + Send + Sync> ChainView for E {
fn load_chain(&self) -> Result<ReceiptChain, EnvError> {
GitopsEnv::load_chain(self)
}
fn now_unix_ms(&self) -> u64 {
GitopsEnv::now_unix_ms(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Health {
NeverDeployed,
Degraded { streak: usize, last_ok: String },
Deferring { deferrals: usize, last_ok: String },
Starved { deferrals: usize, last_ok: String },
Converged { last_ok: String },
Unreadable { error: String },
}
impl Health {
#[must_use]
pub fn tag(&self) -> &'static str {
match self {
Self::NeverDeployed => "never-deployed",
Self::Degraded { .. } => "degraded",
Self::Deferring { .. } => "deferring",
Self::Starved { .. } => "starved",
Self::Converged { .. } => "converged",
Self::Unreadable { .. } => "unreadable",
}
}
}
pub fn health_of(env: &dyn ChainView, starved_after: usize) -> Health {
let chain = match env.load_chain() {
Ok(c) => c,
Err(e) => {
return Health::Unreadable {
error: e.to_string(),
};
}
};
if chain.is_empty() {
return Health::NeverDeployed;
}
let last_ok = chain
.last_activated_rev()
.map_or_else(|| "never".to_owned(), |r| r.short().to_owned());
let streak = chain.consecutive_failures();
if streak > 0 {
return Health::Degraded { streak, last_ok };
}
let deferrals = chain.consecutive_deferrals();
if deferrals >= starved_after.max(1) {
return Health::Starved { deferrals, last_ok };
}
if deferrals > 0 {
return Health::Deferring { deferrals, last_ok };
}
Health::Converged { last_ok }
}
pub struct SentinelaIntrospect<E: ChainView> {
env: Arc<E>,
starved_after: usize,
}
impl<E: ChainView> SentinelaIntrospect<E> {
#[must_use]
pub fn new(env: Arc<E>, starved_after: usize) -> Arc<Self> {
Arc::new(Self { env, starved_after })
}
fn health_json(&self) -> serde_json::Value {
let h = health_of(&*self.env, self.starved_after);
let mut v = serde_json::json!({ "state": h.tag() });
let obj = v.as_object_mut().expect("json object");
match &h {
Health::Degraded { streak, last_ok } => {
obj.insert("consecutive_failures".into(), (*streak).into());
obj.insert("last_activated".into(), last_ok.clone().into());
}
Health::Deferring { deferrals, last_ok } | Health::Starved { deferrals, last_ok } => {
obj.insert("consecutive_deferrals".into(), (*deferrals).into());
obj.insert("last_activated".into(), last_ok.clone().into());
}
Health::Converged { last_ok } => {
obj.insert("last_activated".into(), last_ok.clone().into());
}
Health::Unreadable { error } => {
obj.insert("error".into(), error.clone().into());
}
Health::NeverDeployed => {}
}
obj.insert(
"answered_at_unix_ms".into(),
self.env.now_unix_ms().into(),
);
v
}
fn head_json(&self) -> serde_json::Value {
let now = self.env.now_unix_ms();
let chain = match self.env.load_chain() {
Ok(c) => c,
Err(e) => {
return serde_json::json!({
"status": "unreadable",
"error": e.to_string(),
"answered_at_unix_ms": now,
});
}
};
match chain.head() {
None => serde_json::json!({
"status": "empty",
"answered_at_unix_ms": now,
}),
Some(r) => serde_json::json!({
"status": "found",
"seq": r.seq,
"rev": r.rev.short(),
"outcome": r.outcome,
"activated": r.is_activated(),
"at_unix_ms": r.at_unix_ms,
"age_ms": now.saturating_sub(r.at_unix_ms),
"answered_at_unix_ms": now,
}),
}
}
}
impl<E: ChainView + 'static> Introspect for SentinelaIntrospect<E> {
fn query(&self, q: &Query) -> QueryResult {
let path: Vec<&str> = q.path.iter().map(String::as_str).collect();
let now = self.env.now_unix_ms();
match path.as_slice() {
["health"] => Ok(self.health_json()),
["chain", "head"] => Ok(self.head_json()),
["chain", "len"] => Ok(serde_json::json!({
"len": self.env.load_chain().map(|c| c.len()).unwrap_or(0),
"readable": self.env.load_chain().is_ok(),
"answered_at_unix_ms": now,
})),
["chain", "verify"] => Ok(match self.env.load_chain() {
Ok(c) => match c.verify() {
Ok(()) => serde_json::json!({
"ok": true, "answered_at_unix_ms": now }),
Err(e) => serde_json::json!({
"ok": false, "error": e.to_string(), "answered_at_unix_ms": now }),
},
Err(e) => serde_json::json!({
"ok": false, "error": e.to_string(), "answered_at_unix_ms": now }),
}),
_ => Err(QueryError::UnknownField {
field: q.path.join("."),
}),
}
}
fn schema(&self) -> &'static [&'static str] {
&["health", "chain.head", "chain.len", "chain.verify"]
}
}
#[cfg(test)]
mod tests {
use super::*;
use sentinela_core::{Generation, Outcome, Rev};
use std::sync::Mutex;
struct Fake {
chain: Mutex<Result<ReceiptChain, String>>,
now: Mutex<u64>,
}
impl Fake {
fn with(chain: ReceiptChain, now: u64) -> Arc<Self> {
Arc::new(Self {
chain: Mutex::new(Ok(chain)),
now: Mutex::new(now),
})
}
fn unreadable() -> Arc<Self> {
Arc::new(Self {
chain: Mutex::new(Err("permission denied".into())),
now: Mutex::new(1_000),
})
}
}
impl ChainView for Fake {
fn load_chain(&self) -> Result<ReceiptChain, EnvError> {
self.chain
.lock()
.expect("lock")
.clone()
.map_err(EnvError::ReceiptIo)
}
fn now_unix_ms(&self) -> u64 {
*self.now.lock().expect("lock")
}
}
fn rev(s: &str) -> Rev {
Rev::parse(s).expect("rev")
}
const R1: &str = "1111111111111111111111111111111111111111";
const R2: &str = "2222222222222222222222222222222222222222";
const STARVED: usize = sentinela_config::DEFAULT_STARVED_AFTER_DEFERRALS;
fn deferred_chain(deferrals: usize) -> ReceiptChain {
let mut c = activated_chain(1_000);
for i in 0..deferrals {
let r = c.next_receipt(
rev(R2),
Outcome::Deferred { newer: rev(R1) },
2_000 + i as u64,
);
c.append(r).expect("append");
}
c
}
#[test]
fn one_deferral_is_deferring_and_the_threshold_is_starved() {
let at = |n, t| health_of(&*Fake::with(deferred_chain(n), 9_000), t);
assert!(matches!(at(1, STARVED), Health::Deferring { deferrals: 1, .. }));
assert!(matches!(at(STARVED, STARVED), Health::Starved { .. }));
assert!(matches!(at(1, 0), Health::Starved { deferrals: 1, .. }));
let i = SentinelaIntrospect::new(Fake::with(deferred_chain(1), 9_000), STARVED);
let v = i.query(&Query::field(["health"])).expect("ok");
assert_eq!(v["state"], "deferring");
assert_eq!(v["consecutive_deferrals"], 1);
}
fn activated_chain(at: u64) -> ReceiptChain {
let mut c = ReceiptChain::new();
let r = c.next_receipt(
rev(R1),
Outcome::Activated {
generation: Generation(7),
},
at,
);
c.append(r).expect("append");
c
}
#[test]
fn the_head_answer_carries_its_own_age() {
let env = Fake::with(activated_chain(1_000), 1_000 + 20 * 60 * 1_000);
let i = SentinelaIntrospect::new(env, STARVED);
let v = i.query(&Query::field(["chain", "head"])).expect("ok");
assert_eq!(v["age_ms"], 20 * 60 * 1_000);
assert_eq!(v["status"], "found");
}
#[test]
fn a_backwards_clock_reports_zero_age_not_a_wrapped_u64() {
let env = Fake::with(activated_chain(9_000), 1_000);
let i = SentinelaIntrospect::new(env, STARVED);
let v = i.query(&Query::field(["chain", "head"])).expect("ok");
assert_eq!(v["age_ms"], 0);
}
#[test]
fn an_empty_chain_is_a_finding_not_an_error() {
let env = Fake::with(ReceiptChain::new(), 5_000);
let i = SentinelaIntrospect::new(env, STARVED);
let v = i.query(&Query::field(["chain", "head"])).expect("not an Err");
assert_eq!(v["status"], "empty");
}
#[test]
fn a_never_deployed_loop_is_not_reported_as_converged() {
let env = Fake::with(ReceiptChain::new(), 5_000);
let i = SentinelaIntrospect::new(env, STARVED);
let v = i.query(&Query::field(["health"])).expect("ok");
assert_eq!(v["state"], "never-deployed");
}
#[test]
fn an_unreadable_chain_says_so_rather_than_reporting_health() {
let i = SentinelaIntrospect::new(Fake::unreadable(), STARVED);
let v = i.query(&Query::field(["health"])).expect("ok");
assert_eq!(v["state"], "unreadable");
}
#[test]
fn a_converged_loop_reports_its_last_activation() {
let env = Fake::with(activated_chain(1_000), 2_000);
let i = SentinelaIntrospect::new(env, STARVED);
let v = i.query(&Query::field(["health"])).expect("ok");
assert_eq!(v["state"], "converged");
assert_eq!(v["last_activated"], &R1[..7]);
}
#[test]
fn every_answer_carries_when_it_was_answered() {
let env = Fake::with(activated_chain(1_000), 4_242);
let i = SentinelaIntrospect::new(env, STARVED);
for leaf in [
vec!["health"],
vec!["chain", "head"],
vec!["chain", "len"],
vec!["chain", "verify"],
] {
let v = i.query(&Query::field(leaf.clone())).expect("ok");
assert_eq!(v["answered_at_unix_ms"], 4_242, "leaf {leaf:?}");
}
}
#[test]
fn every_advertised_leaf_answers() {
let env = Fake::with(activated_chain(1_000), 2_000);
let i = SentinelaIntrospect::new(env, STARVED);
for leaf in i.schema() {
let path: Vec<&str> = leaf.split('.').collect();
assert!(
i.query(&Query::field(path)).is_ok(),
"advertised leaf `{leaf}` does not answer"
);
}
}
#[test]
fn an_unknown_leaf_is_refused_by_name() {
let env = Fake::with(activated_chain(1_000), 2_000);
let i = SentinelaIntrospect::new(env, STARVED);
let e = i
.query(&Query::field(["chain", "nope"]))
.expect_err("must refuse");
assert_eq!(
e,
QueryError::UnknownField {
field: "chain.nope".into()
}
);
}
#[test]
fn the_chain_verifies() {
let env = Fake::with(activated_chain(1_000), 2_000);
let i = SentinelaIntrospect::new(env, STARVED);
let v = i.query(&Query::field(["chain", "verify"])).expect("ok");
assert_eq!(v["ok"], true);
}
}