//! The status payload contract every monitored service emits and the release //! viewer renders. //! //! Spec + rationale: maintainer wiki. //! //! //! # Producers describe meaning, the renderer decides appearance //! //! A producer says what a value *means*; the renderer decides what it *looks //! like*. That split is the whole point of the crate, and it is enforced by the //! type system rather than by discipline: there is nowhere in [`Value`] to put //! a pre-formatted string, so a producer cannot own presentation even by //! accident. //! //! ```text //! {"label": "burn-in", "value": "31h of 48h"} // no //! {"label": "burn-in", "kind": "progress", "value": 31, "max": 48} // yes //! ``` //! //! The second form renders as a bar, colored against the same thresholds every //! other progress value in the UI uses, and it is sortable and filterable, //! which a pre-formatted string never is. //! //! # Version skew is expected //! //! Producers and the viewer are separate binaries deployed at different times. //! Two fallbacks keep a skewed pair working instead of erroring: //! //! - an unrecognized [`Value`] kind deserializes to [`Value::Text`] //! - an unrecognized [`Status`] deserializes to [`Status::Unknown`] //! //! Depending on this crate from both ends catches drift when things are built //! together; [`SCHEMA_VERSION`] plus those fallbacks cover the window when they //! are not. //! //! # Example //! //! ``` //! use ops_status::{Payload, Status}; //! //! let json = r#"{ //! "schema": 1, //! "source": "sando", //! "generated_at": "2026-07-21T18:24:39Z", //! "nodes": [ //! { //! "id": "tier:b", //! "kind": "tier", //! "label": "b (prod-1)", //! "status": "degraded", //! "fields": [{"label": "version", "kind": "version", "value": "0.10.14"}], //! "conditions": [{"type": "burn_in", "status": "pending", "detail": "31h of 48h"}] //! } //! ] //! }"#; //! //! let payload: Payload = serde_json::from_str(json).unwrap(); //! assert_eq!(payload.worst_status(), Status::Degraded); //! ``` use std::collections::BTreeMap; use chrono::{DateTime, TimeDelta, Utc}; use serde::de::{self, Deserializer}; use serde::{Deserialize, Serialize}; /// The wire version this crate speaks. A producer stamps it into /// [`Payload::schema`]; a viewer compares it against its own to detect skew. pub const SCHEMA_VERSION: u32 = 1; // --------------------------------------------------------------------------- // Status // --------------------------------------------------------------------------- /// The closed status vocabulary, shared by nodes, conditions, and /// [`Value::State`]. /// /// Consistent color is most of what the viewer is for, so there is exactly one /// vocabulary and producers cannot extend it. Anything unrecognized becomes /// [`Status::Unknown`] rather than failing the parse. /// /// `pass` and `fail` are accepted as aliases for [`Status::Ok`] and /// [`Status::Failed`], since condition-shaped producers reach for those words. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize)] #[serde(rename_all = "snake_case")] pub enum Status { Ok, Degraded, Failed, Pending, /// Also the default: nothing heard from is not the same as healthy. #[default] Unknown, } impl Status { /// How loudly this status should be shown, ascending. /// /// [`Status::Unknown`] outranks [`Status::Degraded`] deliberately: a source /// that cannot be reached is as important as one reporting a failure. A /// silent gap is the failure mode this whole contract exists to close. pub fn severity(self) -> u8 { match self { Status::Ok => 0, Status::Pending => 1, Status::Degraded => 2, Status::Unknown => 3, Status::Failed => 4, } } /// The wire spelling. pub fn as_str(self) -> &'static str { match self { Status::Ok => "ok", Status::Degraded => "degraded", Status::Failed => "failed", Status::Pending => "pending", Status::Unknown => "unknown", } } } /// Orders by [`Status::severity`], so `iter().max()` is "worst status". impl Ord for Status { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.severity().cmp(&other.severity()) } } impl PartialOrd for Status { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl<'de> Deserialize<'de> for Status { fn deserialize>(d: D) -> Result { // Hand-written rather than derived: `#[serde(other)]` is not allowed on // an externally tagged enum, and the unknown-value fallback is the // point. Ok(match String::deserialize(d)?.as_str() { "ok" | "pass" => Status::Ok, "degraded" => Status::Degraded, "failed" | "fail" => Status::Failed, "pending" => Status::Pending, _ => Status::Unknown, }) } } // --------------------------------------------------------------------------- // Values // --------------------------------------------------------------------------- /// The ten value kinds. The spec's worth is entirely in staying short. /// /// Each variant says what a number or string *is*, never how to draw it. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Value { /// A plain string with no further meaning. Also the landing spot for a kind /// this build does not recognize. Text { value: String }, /// An opaque identifier: a digest, a git sha. Rendered monospace and /// truncated at `abbrev_to` characters. Ident { value: String, #[serde(default, skip_serializing_if = "Option::is_none")] abbrev_to: Option, }, /// A semver string, comparable against other versions. Version { value: String }, /// A point in time. Rendered relative ("3m ago"), absolute on focus. Instant { value: DateTime }, /// An elapsed or remaining span, humanized. Duration { seconds: i64 }, /// Progress toward a target. Rendered as a bar. Progress { value: f64, max: f64, #[serde(default, skip_serializing_if = "Option::is_none")] unit: Option, }, /// A magnitude with a unit, humanized (43.5 MB, 1.2k). Quantity { value: f64, #[serde(default, skip_serializing_if = "Option::is_none")] unit: Option, }, /// A status in field position. Rendered as the color alone. State { value: Status }, /// Somewhere to go. Rendered as an actionable control. /// /// The anchor text is `text`, not `label`: a [`Field`] already owns `label` /// and these serialize into the same object, so the two would collide and /// the link's would lose. Link { url: String, #[serde(default, skip_serializing_if = "Option::is_none")] text: Option, }, /// A filesystem path. Rendered monospace, middle-elided. Path { value: String }, } /// Every kind this build understands. Anything else falls back to /// [`Value::Text`] at parse time. const KNOWN_KINDS: &[&str] = &[ "text", "ident", "version", "instant", "duration", "progress", "quantity", "state", "link", "path", ]; impl Value { /// The wire spelling of this value's kind. pub fn kind(&self) -> &'static str { match self { Value::Text { .. } => "text", Value::Ident { .. } => "ident", Value::Version { .. } => "version", Value::Instant { .. } => "instant", Value::Duration { .. } => "duration", Value::Progress { .. } => "progress", Value::Quantity { .. } => "quantity", Value::State { .. } => "state", Value::Link { .. } => "link", Value::Path { .. } => "path", } } } /// One labeled value on a node. /// /// Serializes flat: `{"label": "digest", "kind": "ident", "value": "a3f9..."}`. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct Field { pub label: String, #[serde(flatten)] pub value: Value, } impl Field { pub fn new(label: impl Into, value: Value) -> Self { Field { label: label.into(), value, } } } impl<'de> Deserialize<'de> for Field { fn deserialize>(d: D) -> Result { // Routed through serde_json rather than derived so that an unrecognized // `kind` degrades to text instead of failing the whole payload. A // viewer one release behind a producer must still render everything it // does understand. let mut raw = serde_json::Map::::deserialize(d)?; let label = match raw.remove("label") { Some(serde_json::Value::String(s)) => s, Some(other) => { return Err(de::Error::custom(format!( "label must be a string, got {other}" ))); } None => return Err(de::Error::missing_field("label")), }; let known = raw .get("kind") .and_then(serde_json::Value::as_str) .is_some_and(|k| KNOWN_KINDS.contains(&k)); let value = if known { serde_json::from_value(serde_json::Value::Object(raw)).map_err(de::Error::custom)? } else { Value::Text { value: unknown_kind_text(&raw), } }; Ok(Field { label, value }) } } /// Best-effort text for a kind this build does not know: the `value` member if /// there is one, else the whole object verbatim. Something legible beats a /// blank cell. fn unknown_kind_text(raw: &serde_json::Map) -> String { match raw.get("value") { Some(serde_json::Value::String(s)) => s.clone(), Some(v) => v.to_string(), None => serde_json::Value::Object(raw.clone()).to_string(), } } // --------------------------------------------------------------------------- // Conditions // --------------------------------------------------------------------------- /// Why a node is in the status it is in. /// /// Borrowed from the Kubernetes conditions pattern. This is the part usually /// omitted and the part that earns its keep: "blocked" is useless, "blocked /// because burn_in is 31h of 48h" is what saves an SSH. /// /// `type` is domain vocabulary the viewer never interprets. Sando's gates, /// Bento's build steps, and PoM's checks all map on without translation. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Condition { #[serde(rename = "type")] pub condition_type: String, pub status: Status, #[serde(default, skip_serializing_if = "Option::is_none")] pub since: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub detail: Option, } // --------------------------------------------------------------------------- // Nodes // --------------------------------------------------------------------------- /// One thing a source reports on: a tier, a host, an app, a build target. /// /// Children are referenced by id rather than nested. Flat is easier to diff /// between polls, easier to update incrementally, and keeps the renderer from /// recursing into something unbounded. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Node { pub id: String, /// Domain vocabulary ("tier", "node", "app", "target"). Free-form; the /// viewer may group by it but never branches on it. pub kind: String, pub label: String, pub status: Status, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub fields: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub conditions: Vec, /// Ids of child nodes, which must appear elsewhere in [`Payload::nodes`]. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub children: Vec, /// Keys into [`Payload::actions`]. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub actions: Vec, } // --------------------------------------------------------------------------- // Actions // --------------------------------------------------------------------------- /// The HTTP verb an action is invoked with. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "UPPERCASE")] pub enum Method { Get, Post, Put, Delete, } /// Something the operator can do, declared as data. /// /// This is where a generic viewer usually dies. The moment the shell knows what /// "promote" means, it is not a shell. So the producer declares label, method, /// URL, and how dangerous it is; the viewer renders a control and issues the /// request, never learning domain vocabulary. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Action { pub label: String, pub method: Method, /// Resolved against the source's base URL. pub url: String, /// Prompt before issuing. #[serde(default)] pub confirm: bool, /// Render as destructive. #[serde(default)] pub danger: bool, /// JSON body to send, verbatim. #[serde(default, skip_serializing_if = "Option::is_none")] pub body: Option, } // --------------------------------------------------------------------------- // Events // --------------------------------------------------------------------------- /// A recent notable moment, newest first. Optional: a source with no event /// history sends an empty list. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Event { pub at: DateTime, pub label: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub status: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub detail: Option, /// The node this concerns, if any. #[serde(default, skip_serializing_if = "Option::is_none")] pub node_id: Option, } // --------------------------------------------------------------------------- // Payload // --------------------------------------------------------------------------- /// What one source serves at `GET /status.json`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Payload { /// [`SCHEMA_VERSION`] as of the build that produced this. pub schema: u32, /// Stable source name ("sando", "bento", "pom"). pub source: String, /// When this snapshot was taken. A viewer treats a stale value as its own /// kind of unhealthy: the 40-day-stale backup was green by every check that /// existed. pub generated_at: DateTime, #[serde(default)] pub nodes: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub events: Vec, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub actions: BTreeMap, } impl Payload { /// An empty payload stamped with the current schema version. pub fn new(source: impl Into, generated_at: DateTime) -> Self { Payload { schema: SCHEMA_VERSION, source: source.into(), generated_at, nodes: Vec::new(), events: Vec::new(), actions: BTreeMap::new(), } } /// The worst status across every node: this source's line in the rollup. /// /// A source reporting no nodes at all is [`Status::Unknown`], not healthy. /// Absence of evidence is the thing that went unnoticed for twenty hours. pub fn worst_status(&self) -> Status { self.nodes .iter() .map(|n| n.status) .max() .unwrap_or(Status::Unknown) } /// How old this snapshot is at `now`. Negative if the producer's clock runs /// ahead. /// /// The clock is an explicit argument here and everywhere else in the /// contract: render is a pure function of `(payload, now)`, which is what /// makes golden-snapshot tests possible. pub fn age(&self, now: DateTime) -> TimeDelta { now - self.generated_at } /// Whether this snapshot is older than `limit` at `now`. pub fn is_stale(&self, now: DateTime, limit: TimeDelta) -> bool { self.age(now) > limit } /// The payload a viewer substitutes for a source it could not reach. /// /// Unreachability is a status, not a blank tab, and it carries the last /// time anyone heard from the source. pub fn unreachable( source: impl Into, last_seen: DateTime, why: impl Into, ) -> Self { let source = source.into(); let mut payload = Payload::new(source.clone(), last_seen); payload.nodes.push(Node { id: format!("source:{source}"), kind: "source".into(), label: source, status: Status::Unknown, fields: Vec::new(), conditions: vec![Condition { condition_type: "reachable".into(), status: Status::Failed, since: Some(last_seen), detail: Some(why.into()), }], children: Vec::new(), actions: Vec::new(), }); payload } /// Nodes with no parent, in payload order: where a renderer starts. pub fn roots(&self) -> impl Iterator { let claimed: std::collections::HashSet<&str> = self .nodes .iter() .flat_map(|n| n.children.iter().map(String::as_str)) .collect(); self.nodes .iter() .filter(move |n| !claimed.contains(n.id.as_str())) } /// Look up a node by id. pub fn node(&self, id: &str) -> Option<&Node> { self.nodes.iter().find(|n| n.id == id) } /// Structural problems a producer's tests should catch: dangling child ids, /// duplicate node ids, actions referencing keys that were never declared. /// /// The viewer tolerates all of these at runtime. This exists so a producer /// fails at build time instead. pub fn validate(&self) -> Result<(), Vec> { let mut problems = Vec::new(); let mut seen = std::collections::HashSet::new(); for node in &self.nodes { if !seen.insert(node.id.as_str()) { problems.push(format!("duplicate node id {:?}", node.id)); } } for node in &self.nodes { for child in &node.children { if !seen.contains(child.as_str()) { problems.push(format!( "node {:?} references missing child {child:?}", node.id )); } } for action in &node.actions { if !self.actions.contains_key(action) { problems.push(format!( "node {:?} references undeclared action {action:?}", node.id )); } } } if problems.is_empty() { Ok(()) } else { Err(problems) } } } #[cfg(test)] mod tests { use super::*; /// The example payload from the spec note, verbatim. const SPEC_EXAMPLE: &str = r#"{ "schema": 1, "source": "sando", "generated_at": "2026-07-21T18:24:39Z", "nodes": [ { "id": "tier:b", "kind": "tier", "label": "b (prod-1)", "status": "degraded", "fields": [ {"label": "version", "kind": "version", "value": "0.10.14"}, {"label": "digest", "kind": "ident", "value": "a3f9c21b7e4d8056", "abbrev_to": 8}, {"label": "sha", "kind": "ident", "value": "68f44d7a", "abbrev_to": 8}, {"label": "built", "kind": "instant", "value": "2026-07-21T14:02:00Z"} ], "conditions": [ {"type": "node_health", "status": "pass", "since": "2026-07-21T14:02:00Z"}, {"type": "burn_in", "status": "pending", "detail": "31h of 48h"} ], "children": ["node:prod-1"], "actions": ["rollback-b"] }, { "id": "node:prod-1", "kind": "node", "label": "prod-1", "status": "ok" } ], "events": [], "actions": { "rollback-b": { "label": "Roll back", "method": "POST", "url": "/rollback/b", "confirm": true, "danger": true } } }"#; fn spec_example() -> Payload { serde_json::from_str(SPEC_EXAMPLE).expect("spec example parses") } #[test] fn spec_example_parses() { let p = spec_example(); assert_eq!(p.schema, SCHEMA_VERSION); assert_eq!(p.source, "sando"); assert_eq!(p.nodes.len(), 2); assert_eq!(p.nodes[0].fields.len(), 4); assert_eq!(p.actions["rollback-b"].method, Method::Post); assert!(p.actions["rollback-b"].danger); } #[test] fn spec_example_validates() { assert_eq!(spec_example().validate(), Ok(())); } #[test] fn spec_example_roundtrips() { let p = spec_example(); let back: Payload = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap(); assert_eq!(p, back); } #[test] fn field_serializes_flat() { let f = Field::new( "digest", Value::Ident { value: "a3f9c21b".into(), abbrev_to: Some(8), }, ); let json: serde_json::Value = serde_json::to_value(&f).unwrap(); assert_eq!(json["label"], "digest"); assert_eq!(json["kind"], "ident"); assert_eq!(json["value"], "a3f9c21b"); assert_eq!(json["abbrev_to"], 8); } #[test] fn condition_pass_is_ok_and_fail_is_failed() { // The spec's example writes conditions as pass/pending rather than the // node vocabulary. One enum, two spellings on the way in. let c: Condition = serde_json::from_str(r#"{"type": "node_health", "status": "pass"}"#).unwrap(); assert_eq!(c.status, Status::Ok); let c: Condition = serde_json::from_str(r#"{"type": "boot", "status": "fail"}"#).unwrap(); assert_eq!(c.status, Status::Failed); } #[test] fn unknown_kind_falls_back_to_text() { let f: Field = serde_json::from_str(r#"{"label": "temp", "kind": "celsius", "value": "41"}"#).unwrap(); assert_eq!(f.label, "temp"); assert_eq!(f.value, Value::Text { value: "41".into() }); } #[test] fn unknown_kind_without_a_string_value_is_still_legible() { let f: Field = serde_json::from_str(r#"{"label": "load", "kind": "tuple", "value": [1, 5, 15]}"#) .unwrap(); assert_eq!( f.value, Value::Text { value: "[1,5,15]".into() } ); let f: Field = serde_json::from_str(r#"{"label": "x", "kind": "weird", "a": 1}"#).unwrap(); assert_eq!( f.value, Value::Text { value: r#"{"a":1,"kind":"weird"}"#.into() } ); } #[test] fn a_known_kind_with_a_bad_payload_still_errors() { // Fallback covers version skew, not producer bugs. A malformed // `progress` is a bug and should be loud. let err = serde_json::from_str::(r#"{"label": "burn-in", "kind": "progress"}"#); assert!( err.is_err(), "missing required progress members must not silently degrade" ); } #[test] fn unknown_status_is_unknown() { assert_eq!( serde_json::from_str::(r#""catastrophe""#).unwrap(), Status::Unknown ); } #[test] fn worst_status_orders_unknown_above_degraded() { // A source that cannot be reached must be as visible as one reporting // trouble. assert!(Status::Unknown > Status::Degraded); assert!(Status::Failed > Status::Unknown); assert!(Status::Degraded > Status::Pending); assert!(Status::Pending > Status::Ok); } #[test] fn worst_status_picks_the_loudest_node() { assert_eq!(spec_example().worst_status(), Status::Degraded); } #[test] fn a_payload_with_no_nodes_is_unknown_not_ok() { let p = Payload::new("bento", "2026-07-21T18:00:00Z".parse().unwrap()); assert_eq!(p.worst_status(), Status::Unknown); } #[test] fn unreachable_reports_unknown_with_a_reason() { let last = "2026-07-21T18:00:00Z".parse().unwrap(); let p = Payload::unreachable("bento", last, "connection refused"); assert_eq!(p.worst_status(), Status::Unknown); assert_eq!( p.nodes[0].conditions[0].detail.as_deref(), Some("connection refused") ); assert_eq!(p.validate(), Ok(())); } #[test] fn staleness_is_measured_against_a_passed_in_clock() { let p = Payload::new("pom", "2026-07-21T18:00:00Z".parse().unwrap()); let now: DateTime = "2026-07-21T18:20:00Z".parse().unwrap(); assert_eq!(p.age(now), TimeDelta::minutes(20)); assert!(p.is_stale(now, TimeDelta::minutes(5))); assert!(!p.is_stale(now, TimeDelta::hours(1))); } #[test] fn roots_excludes_claimed_children() { let p = spec_example(); let roots: Vec<&str> = p.roots().map(|n| n.id.as_str()).collect(); assert_eq!(roots, vec!["tier:b"]); } #[test] fn validate_catches_dangling_references() { let mut p = spec_example(); p.nodes[0].children.push("node:ghost".into()); p.nodes[0].actions.push("promote-c".into()); let problems = p.validate().unwrap_err(); assert_eq!(problems.len(), 2); assert!(problems.iter().any(|m| m.contains("node:ghost"))); assert!(problems.iter().any(|m| m.contains("promote-c"))); } #[test] fn validate_catches_duplicate_ids() { let mut p = spec_example(); let dup = p.nodes[1].clone(); p.nodes.push(dup); assert!( p.validate() .unwrap_err() .iter() .any(|m| m.contains("duplicate")) ); } #[test] fn optional_members_stay_off_the_wire() { let p = Payload::new("sando", "2026-07-21T18:00:00Z".parse().unwrap()); let json = serde_json::to_string(&p).unwrap(); assert!(!json.contains("events"), "{json}"); assert!(!json.contains("actions"), "{json}"); } #[test] fn every_known_kind_roundtrips() { let values = vec![ Value::Text { value: "x".into() }, Value::Ident { value: "a3f9c21b7e4d8056".into(), abbrev_to: Some(8), }, Value::Version { value: "0.10.14".into(), }, Value::Instant { value: "2026-07-21T14:02:00Z".parse().unwrap(), }, Value::Duration { seconds: 3600 }, Value::Progress { value: 31.0, max: 48.0, unit: Some("hour".into()), }, Value::Quantity { value: 43.5, unit: Some("MB".into()), }, Value::State { value: Status::Degraded, }, Value::Link { url: "https://makenot.work".into(), text: Some("site".into()), }, Value::Path { value: "/srv/sando/releases/a3f9c21b".into(), }, ]; assert_eq!( values.len(), KNOWN_KINDS.len(), "a kind was added without a roundtrip test" ); for value in values { let kind = value.kind(); assert!( KNOWN_KINDS.contains(&kind), "{kind} missing from KNOWN_KINDS" ); let f = Field::new("l", value.clone()); let wire = serde_json::to_string(&f).unwrap(); // A value member named `label` would collide with the field's own // and lose, silently. This is how `link` was caught. let obj: serde_json::Map = serde_json::from_str(&wire).unwrap(); assert_eq!(obj["label"], "l", "{kind} clobbers the field label: {wire}"); let back: Field = serde_json::from_str(&wire).unwrap(); assert_eq!(back.value, value, "{kind} did not roundtrip"); } } }