Skip to main content

max / makenotwork

28.5 KB · 843 lines History Blame Raw
1 //! The status payload contract every monitored service emits and the release
2 //! viewer renders.
3 //!
4 //! Spec + rationale: maintainer wiki.
5 //! <!-- wiki: release-status-payload -->
6 //!
7 //! # Producers describe meaning, the renderer decides appearance
8 //!
9 //! A producer says what a value *means*; the renderer decides what it *looks
10 //! like*. That split is the whole point of the crate, and it is enforced by the
11 //! type system rather than by discipline: there is nowhere in [`Value`] to put
12 //! a pre-formatted string, so a producer cannot own presentation even by
13 //! accident.
14 //!
15 //! ```text
16 //! {"label": "burn-in", "value": "31h of 48h"} // no
17 //! {"label": "burn-in", "kind": "progress", "value": 31, "max": 48} // yes
18 //! ```
19 //!
20 //! The second form renders as a bar, colored against the same thresholds every
21 //! other progress value in the UI uses, and it is sortable and filterable,
22 //! which a pre-formatted string never is.
23 //!
24 //! # Version skew is expected
25 //!
26 //! Producers and the viewer are separate binaries deployed at different times.
27 //! Two fallbacks keep a skewed pair working instead of erroring:
28 //!
29 //! - an unrecognized [`Value`] kind deserializes to [`Value::Text`]
30 //! - an unrecognized [`Status`] deserializes to [`Status::Unknown`]
31 //!
32 //! Depending on this crate from both ends catches drift when things are built
33 //! together; [`SCHEMA_VERSION`] plus those fallbacks cover the window when they
34 //! are not.
35 //!
36 //! # Example
37 //!
38 //! ```
39 //! use ops_status::{Payload, Status};
40 //!
41 //! let json = r#"{
42 //! "schema": 1,
43 //! "source": "sando",
44 //! "generated_at": "2026-07-21T18:24:39Z",
45 //! "nodes": [
46 //! {
47 //! "id": "tier:b",
48 //! "kind": "tier",
49 //! "label": "b (prod-1)",
50 //! "status": "degraded",
51 //! "fields": [{"label": "version", "kind": "version", "value": "0.10.14"}],
52 //! "conditions": [{"type": "burn_in", "status": "pending", "detail": "31h of 48h"}]
53 //! }
54 //! ]
55 //! }"#;
56 //!
57 //! let payload: Payload = serde_json::from_str(json).unwrap();
58 //! assert_eq!(payload.worst_status(), Status::Degraded);
59 //! ```
60
61 use std::collections::BTreeMap;
62
63 use chrono::{DateTime, TimeDelta, Utc};
64 use serde::de::{self, Deserializer};
65 use serde::{Deserialize, Serialize};
66
67 /// The wire version this crate speaks. A producer stamps it into
68 /// [`Payload::schema`]; a viewer compares it against its own to detect skew.
69 pub const SCHEMA_VERSION: u32 = 1;
70
71 // ---------------------------------------------------------------------------
72 // Status
73 // ---------------------------------------------------------------------------
74
75 /// The closed status vocabulary, shared by nodes, conditions, and
76 /// [`Value::State`].
77 ///
78 /// Consistent color is most of what the viewer is for, so there is exactly one
79 /// vocabulary and producers cannot extend it. Anything unrecognized becomes
80 /// [`Status::Unknown`] rather than failing the parse.
81 ///
82 /// `pass` and `fail` are accepted as aliases for [`Status::Ok`] and
83 /// [`Status::Failed`], since condition-shaped producers reach for those words.
84 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize)]
85 #[serde(rename_all = "snake_case")]
86 pub enum Status {
87 Ok,
88 Degraded,
89 Failed,
90 Pending,
91 /// Also the default: nothing heard from is not the same as healthy.
92 #[default]
93 Unknown,
94 }
95
96 impl Status {
97 /// How loudly this status should be shown, ascending.
98 ///
99 /// [`Status::Unknown`] outranks [`Status::Degraded`] deliberately: a source
100 /// that cannot be reached is as important as one reporting a failure. A
101 /// silent gap is the failure mode this whole contract exists to close.
102 pub fn severity(self) -> u8 {
103 match self {
104 Status::Ok => 0,
105 Status::Pending => 1,
106 Status::Degraded => 2,
107 Status::Unknown => 3,
108 Status::Failed => 4,
109 }
110 }
111
112 /// The wire spelling.
113 pub fn as_str(self) -> &'static str {
114 match self {
115 Status::Ok => "ok",
116 Status::Degraded => "degraded",
117 Status::Failed => "failed",
118 Status::Pending => "pending",
119 Status::Unknown => "unknown",
120 }
121 }
122 }
123
124 /// Orders by [`Status::severity`], so `iter().max()` is "worst status".
125 impl Ord for Status {
126 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
127 self.severity().cmp(&other.severity())
128 }
129 }
130
131 impl PartialOrd for Status {
132 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
133 Some(self.cmp(other))
134 }
135 }
136
137 impl<'de> Deserialize<'de> for Status {
138 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
139 // Hand-written rather than derived: `#[serde(other)]` is not allowed on
140 // an externally tagged enum, and the unknown-value fallback is the
141 // point.
142 Ok(match String::deserialize(d)?.as_str() {
143 "ok" | "pass" => Status::Ok,
144 "degraded" => Status::Degraded,
145 "failed" | "fail" => Status::Failed,
146 "pending" => Status::Pending,
147 _ => Status::Unknown,
148 })
149 }
150 }
151
152 // ---------------------------------------------------------------------------
153 // Values
154 // ---------------------------------------------------------------------------
155
156 /// The ten value kinds. The spec's worth is entirely in staying short.
157 ///
158 /// Each variant says what a number or string *is*, never how to draw it.
159 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160 #[serde(tag = "kind", rename_all = "snake_case")]
161 pub enum Value {
162 /// A plain string with no further meaning. Also the landing spot for a kind
163 /// this build does not recognize.
164 Text { value: String },
165 /// An opaque identifier: a digest, a git sha. Rendered monospace and
166 /// truncated at `abbrev_to` characters.
167 Ident {
168 value: String,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
170 abbrev_to: Option<usize>,
171 },
172 /// A semver string, comparable against other versions.
173 Version { value: String },
174 /// A point in time. Rendered relative ("3m ago"), absolute on focus.
175 Instant { value: DateTime<Utc> },
176 /// An elapsed or remaining span, humanized.
177 Duration { seconds: i64 },
178 /// Progress toward a target. Rendered as a bar.
179 Progress {
180 value: f64,
181 max: f64,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 unit: Option<String>,
184 },
185 /// A magnitude with a unit, humanized (43.5 MB, 1.2k).
186 Quantity {
187 value: f64,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 unit: Option<String>,
190 },
191 /// A status in field position. Rendered as the color alone.
192 State { value: Status },
193 /// Somewhere to go. Rendered as an actionable control.
194 ///
195 /// The anchor text is `text`, not `label`: a [`Field`] already owns `label`
196 /// and these serialize into the same object, so the two would collide and
197 /// the link's would lose.
198 Link {
199 url: String,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 text: Option<String>,
202 },
203 /// A filesystem path. Rendered monospace, middle-elided.
204 Path { value: String },
205 }
206
207 /// Every kind this build understands. Anything else falls back to
208 /// [`Value::Text`] at parse time.
209 const KNOWN_KINDS: &[&str] = &[
210 "text", "ident", "version", "instant", "duration", "progress", "quantity", "state", "link",
211 "path",
212 ];
213
214 impl Value {
215 /// The wire spelling of this value's kind.
216 pub fn kind(&self) -> &'static str {
217 match self {
218 Value::Text { .. } => "text",
219 Value::Ident { .. } => "ident",
220 Value::Version { .. } => "version",
221 Value::Instant { .. } => "instant",
222 Value::Duration { .. } => "duration",
223 Value::Progress { .. } => "progress",
224 Value::Quantity { .. } => "quantity",
225 Value::State { .. } => "state",
226 Value::Link { .. } => "link",
227 Value::Path { .. } => "path",
228 }
229 }
230 }
231
232 /// One labeled value on a node.
233 ///
234 /// Serializes flat: `{"label": "digest", "kind": "ident", "value": "a3f9..."}`.
235 #[derive(Debug, Clone, PartialEq, Serialize)]
236 pub struct Field {
237 pub label: String,
238 #[serde(flatten)]
239 pub value: Value,
240 }
241
242 impl Field {
243 pub fn new(label: impl Into<String>, value: Value) -> Self {
244 Field {
245 label: label.into(),
246 value,
247 }
248 }
249 }
250
251 impl<'de> Deserialize<'de> for Field {
252 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
253 // Routed through serde_json rather than derived so that an unrecognized
254 // `kind` degrades to text instead of failing the whole payload. A
255 // viewer one release behind a producer must still render everything it
256 // does understand.
257 let mut raw = serde_json::Map::<String, serde_json::Value>::deserialize(d)?;
258
259 let label = match raw.remove("label") {
260 Some(serde_json::Value::String(s)) => s,
261 Some(other) => {
262 return Err(de::Error::custom(format!(
263 "label must be a string, got {other}"
264 )));
265 }
266 None => return Err(de::Error::missing_field("label")),
267 };
268
269 let known = raw
270 .get("kind")
271 .and_then(serde_json::Value::as_str)
272 .is_some_and(|k| KNOWN_KINDS.contains(&k));
273
274 let value = if known {
275 serde_json::from_value(serde_json::Value::Object(raw)).map_err(de::Error::custom)?
276 } else {
277 Value::Text {
278 value: unknown_kind_text(&raw),
279 }
280 };
281
282 Ok(Field { label, value })
283 }
284 }
285
286 /// Best-effort text for a kind this build does not know: the `value` member if
287 /// there is one, else the whole object verbatim. Something legible beats a
288 /// blank cell.
289 fn unknown_kind_text(raw: &serde_json::Map<String, serde_json::Value>) -> String {
290 match raw.get("value") {
291 Some(serde_json::Value::String(s)) => s.clone(),
292 Some(v) => v.to_string(),
293 None => serde_json::Value::Object(raw.clone()).to_string(),
294 }
295 }
296
297 // ---------------------------------------------------------------------------
298 // Conditions
299 // ---------------------------------------------------------------------------
300
301 /// Why a node is in the status it is in.
302 ///
303 /// Borrowed from the Kubernetes conditions pattern. This is the part usually
304 /// omitted and the part that earns its keep: "blocked" is useless, "blocked
305 /// because burn_in is 31h of 48h" is what saves an SSH.
306 ///
307 /// `type` is domain vocabulary the viewer never interprets. Sando's gates,
308 /// Bento's build steps, and PoM's checks all map on without translation.
309 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
310 pub struct Condition {
311 #[serde(rename = "type")]
312 pub condition_type: String,
313 pub status: Status,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub since: Option<DateTime<Utc>>,
316 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub detail: Option<String>,
318 }
319
320 // ---------------------------------------------------------------------------
321 // Nodes
322 // ---------------------------------------------------------------------------
323
324 /// One thing a source reports on: a tier, a host, an app, a build target.
325 ///
326 /// Children are referenced by id rather than nested. Flat is easier to diff
327 /// between polls, easier to update incrementally, and keeps the renderer from
328 /// recursing into something unbounded.
329 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
330 pub struct Node {
331 pub id: String,
332 /// Domain vocabulary ("tier", "node", "app", "target"). Free-form; the
333 /// viewer may group by it but never branches on it.
334 pub kind: String,
335 pub label: String,
336 pub status: Status,
337 #[serde(default, skip_serializing_if = "Vec::is_empty")]
338 pub fields: Vec<Field>,
339 #[serde(default, skip_serializing_if = "Vec::is_empty")]
340 pub conditions: Vec<Condition>,
341 /// Ids of child nodes, which must appear elsewhere in [`Payload::nodes`].
342 #[serde(default, skip_serializing_if = "Vec::is_empty")]
343 pub children: Vec<String>,
344 /// Keys into [`Payload::actions`].
345 #[serde(default, skip_serializing_if = "Vec::is_empty")]
346 pub actions: Vec<String>,
347 }
348
349 // ---------------------------------------------------------------------------
350 // Actions
351 // ---------------------------------------------------------------------------
352
353 /// The HTTP verb an action is invoked with.
354 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
355 #[serde(rename_all = "UPPERCASE")]
356 pub enum Method {
357 Get,
358 Post,
359 Put,
360 Delete,
361 }
362
363 /// Something the operator can do, declared as data.
364 ///
365 /// This is where a generic viewer usually dies. The moment the shell knows what
366 /// "promote" means, it is not a shell. So the producer declares label, method,
367 /// URL, and how dangerous it is; the viewer renders a control and issues the
368 /// request, never learning domain vocabulary.
369 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
370 pub struct Action {
371 pub label: String,
372 pub method: Method,
373 /// Resolved against the source's base URL.
374 pub url: String,
375 /// Prompt before issuing.
376 #[serde(default)]
377 pub confirm: bool,
378 /// Render as destructive.
379 #[serde(default)]
380 pub danger: bool,
381 /// JSON body to send, verbatim.
382 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub body: Option<serde_json::Value>,
384 }
385
386 // ---------------------------------------------------------------------------
387 // Events
388 // ---------------------------------------------------------------------------
389
390 /// A recent notable moment, newest first. Optional: a source with no event
391 /// history sends an empty list.
392 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
393 pub struct Event {
394 pub at: DateTime<Utc>,
395 pub label: String,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub status: Option<Status>,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub detail: Option<String>,
400 /// The node this concerns, if any.
401 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub node_id: Option<String>,
403 }
404
405 // ---------------------------------------------------------------------------
406 // Payload
407 // ---------------------------------------------------------------------------
408
409 /// What one source serves at `GET /status.json`.
410 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
411 pub struct Payload {
412 /// [`SCHEMA_VERSION`] as of the build that produced this.
413 pub schema: u32,
414 /// Stable source name ("sando", "bento", "pom").
415 pub source: String,
416 /// When this snapshot was taken. A viewer treats a stale value as its own
417 /// kind of unhealthy: the 40-day-stale backup was green by every check that
418 /// existed.
419 pub generated_at: DateTime<Utc>,
420 #[serde(default)]
421 pub nodes: Vec<Node>,
422 #[serde(default, skip_serializing_if = "Vec::is_empty")]
423 pub events: Vec<Event>,
424 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
425 pub actions: BTreeMap<String, Action>,
426 }
427
428 impl Payload {
429 /// An empty payload stamped with the current schema version.
430 pub fn new(source: impl Into<String>, generated_at: DateTime<Utc>) -> Self {
431 Payload {
432 schema: SCHEMA_VERSION,
433 source: source.into(),
434 generated_at,
435 nodes: Vec::new(),
436 events: Vec::new(),
437 actions: BTreeMap::new(),
438 }
439 }
440
441 /// The worst status across every node: this source's line in the rollup.
442 ///
443 /// A source reporting no nodes at all is [`Status::Unknown`], not healthy.
444 /// Absence of evidence is the thing that went unnoticed for twenty hours.
445 pub fn worst_status(&self) -> Status {
446 self.nodes
447 .iter()
448 .map(|n| n.status)
449 .max()
450 .unwrap_or(Status::Unknown)
451 }
452
453 /// How old this snapshot is at `now`. Negative if the producer's clock runs
454 /// ahead.
455 ///
456 /// The clock is an explicit argument here and everywhere else in the
457 /// contract: render is a pure function of `(payload, now)`, which is what
458 /// makes golden-snapshot tests possible.
459 pub fn age(&self, now: DateTime<Utc>) -> TimeDelta {
460 now - self.generated_at
461 }
462
463 /// Whether this snapshot is older than `limit` at `now`.
464 pub fn is_stale(&self, now: DateTime<Utc>, limit: TimeDelta) -> bool {
465 self.age(now) > limit
466 }
467
468 /// The payload a viewer substitutes for a source it could not reach.
469 ///
470 /// Unreachability is a status, not a blank tab, and it carries the last
471 /// time anyone heard from the source.
472 pub fn unreachable(
473 source: impl Into<String>,
474 last_seen: DateTime<Utc>,
475 why: impl Into<String>,
476 ) -> Self {
477 let source = source.into();
478 let mut payload = Payload::new(source.clone(), last_seen);
479 payload.nodes.push(Node {
480 id: format!("source:{source}"),
481 kind: "source".into(),
482 label: source,
483 status: Status::Unknown,
484 fields: Vec::new(),
485 conditions: vec![Condition {
486 condition_type: "reachable".into(),
487 status: Status::Failed,
488 since: Some(last_seen),
489 detail: Some(why.into()),
490 }],
491 children: Vec::new(),
492 actions: Vec::new(),
493 });
494 payload
495 }
496
497 /// Nodes with no parent, in payload order: where a renderer starts.
498 pub fn roots(&self) -> impl Iterator<Item = &Node> {
499 let claimed: std::collections::HashSet<&str> = self
500 .nodes
501 .iter()
502 .flat_map(|n| n.children.iter().map(String::as_str))
503 .collect();
504 self.nodes
505 .iter()
506 .filter(move |n| !claimed.contains(n.id.as_str()))
507 }
508
509 /// Look up a node by id.
510 pub fn node(&self, id: &str) -> Option<&Node> {
511 self.nodes.iter().find(|n| n.id == id)
512 }
513
514 /// Structural problems a producer's tests should catch: dangling child ids,
515 /// duplicate node ids, actions referencing keys that were never declared.
516 ///
517 /// The viewer tolerates all of these at runtime. This exists so a producer
518 /// fails at build time instead.
519 pub fn validate(&self) -> Result<(), Vec<String>> {
520 let mut problems = Vec::new();
521 let mut seen = std::collections::HashSet::new();
522
523 for node in &self.nodes {
524 if !seen.insert(node.id.as_str()) {
525 problems.push(format!("duplicate node id {:?}", node.id));
526 }
527 }
528 for node in &self.nodes {
529 for child in &node.children {
530 if !seen.contains(child.as_str()) {
531 problems.push(format!(
532 "node {:?} references missing child {child:?}",
533 node.id
534 ));
535 }
536 }
537 for action in &node.actions {
538 if !self.actions.contains_key(action) {
539 problems.push(format!(
540 "node {:?} references undeclared action {action:?}",
541 node.id
542 ));
543 }
544 }
545 }
546
547 if problems.is_empty() {
548 Ok(())
549 } else {
550 Err(problems)
551 }
552 }
553 }
554
555 #[cfg(test)]
556 mod tests {
557 use super::*;
558
559 /// The example payload from the spec note, verbatim.
560 const SPEC_EXAMPLE: &str = r#"{
561 "schema": 1,
562 "source": "sando",
563 "generated_at": "2026-07-21T18:24:39Z",
564 "nodes": [
565 {
566 "id": "tier:b",
567 "kind": "tier",
568 "label": "b (prod-1)",
569 "status": "degraded",
570 "fields": [
571 {"label": "version", "kind": "version", "value": "0.10.14"},
572 {"label": "digest", "kind": "ident", "value": "a3f9c21b7e4d8056", "abbrev_to": 8},
573 {"label": "sha", "kind": "ident", "value": "68f44d7a", "abbrev_to": 8},
574 {"label": "built", "kind": "instant", "value": "2026-07-21T14:02:00Z"}
575 ],
576 "conditions": [
577 {"type": "node_health", "status": "pass", "since": "2026-07-21T14:02:00Z"},
578 {"type": "burn_in", "status": "pending", "detail": "31h of 48h"}
579 ],
580 "children": ["node:prod-1"],
581 "actions": ["rollback-b"]
582 },
583 {
584 "id": "node:prod-1",
585 "kind": "node",
586 "label": "prod-1",
587 "status": "ok"
588 }
589 ],
590 "events": [],
591 "actions": {
592 "rollback-b": {
593 "label": "Roll back", "method": "POST", "url": "/rollback/b",
594 "confirm": true, "danger": true
595 }
596 }
597 }"#;
598
599 fn spec_example() -> Payload {
600 serde_json::from_str(SPEC_EXAMPLE).expect("spec example parses")
601 }
602
603 #[test]
604 fn spec_example_parses() {
605 let p = spec_example();
606 assert_eq!(p.schema, SCHEMA_VERSION);
607 assert_eq!(p.source, "sando");
608 assert_eq!(p.nodes.len(), 2);
609 assert_eq!(p.nodes[0].fields.len(), 4);
610 assert_eq!(p.actions["rollback-b"].method, Method::Post);
611 assert!(p.actions["rollback-b"].danger);
612 }
613
614 #[test]
615 fn spec_example_validates() {
616 assert_eq!(spec_example().validate(), Ok(()));
617 }
618
619 #[test]
620 fn spec_example_roundtrips() {
621 let p = spec_example();
622 let back: Payload = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
623 assert_eq!(p, back);
624 }
625
626 #[test]
627 fn field_serializes_flat() {
628 let f = Field::new(
629 "digest",
630 Value::Ident {
631 value: "a3f9c21b".into(),
632 abbrev_to: Some(8),
633 },
634 );
635 let json: serde_json::Value = serde_json::to_value(&f).unwrap();
636 assert_eq!(json["label"], "digest");
637 assert_eq!(json["kind"], "ident");
638 assert_eq!(json["value"], "a3f9c21b");
639 assert_eq!(json["abbrev_to"], 8);
640 }
641
642 #[test]
643 fn condition_pass_is_ok_and_fail_is_failed() {
644 // The spec's example writes conditions as pass/pending rather than the
645 // node vocabulary. One enum, two spellings on the way in.
646 let c: Condition =
647 serde_json::from_str(r#"{"type": "node_health", "status": "pass"}"#).unwrap();
648 assert_eq!(c.status, Status::Ok);
649 let c: Condition = serde_json::from_str(r#"{"type": "boot", "status": "fail"}"#).unwrap();
650 assert_eq!(c.status, Status::Failed);
651 }
652
653 #[test]
654 fn unknown_kind_falls_back_to_text() {
655 let f: Field =
656 serde_json::from_str(r#"{"label": "temp", "kind": "celsius", "value": "41"}"#).unwrap();
657 assert_eq!(f.label, "temp");
658 assert_eq!(f.value, Value::Text { value: "41".into() });
659 }
660
661 #[test]
662 fn unknown_kind_without_a_string_value_is_still_legible() {
663 let f: Field =
664 serde_json::from_str(r#"{"label": "load", "kind": "tuple", "value": [1, 5, 15]}"#)
665 .unwrap();
666 assert_eq!(
667 f.value,
668 Value::Text {
669 value: "[1,5,15]".into()
670 }
671 );
672
673 let f: Field = serde_json::from_str(r#"{"label": "x", "kind": "weird", "a": 1}"#).unwrap();
674 assert_eq!(
675 f.value,
676 Value::Text {
677 value: r#"{"a":1,"kind":"weird"}"#.into()
678 }
679 );
680 }
681
682 #[test]
683 fn a_known_kind_with_a_bad_payload_still_errors() {
684 // Fallback covers version skew, not producer bugs. A malformed
685 // `progress` is a bug and should be loud.
686 let err = serde_json::from_str::<Field>(r#"{"label": "burn-in", "kind": "progress"}"#);
687 assert!(
688 err.is_err(),
689 "missing required progress members must not silently degrade"
690 );
691 }
692
693 #[test]
694 fn unknown_status_is_unknown() {
695 assert_eq!(
696 serde_json::from_str::<Status>(r#""catastrophe""#).unwrap(),
697 Status::Unknown
698 );
699 }
700
701 #[test]
702 fn worst_status_orders_unknown_above_degraded() {
703 // A source that cannot be reached must be as visible as one reporting
704 // trouble.
705 assert!(Status::Unknown > Status::Degraded);
706 assert!(Status::Failed > Status::Unknown);
707 assert!(Status::Degraded > Status::Pending);
708 assert!(Status::Pending > Status::Ok);
709 }
710
711 #[test]
712 fn worst_status_picks_the_loudest_node() {
713 assert_eq!(spec_example().worst_status(), Status::Degraded);
714 }
715
716 #[test]
717 fn a_payload_with_no_nodes_is_unknown_not_ok() {
718 let p = Payload::new("bento", "2026-07-21T18:00:00Z".parse().unwrap());
719 assert_eq!(p.worst_status(), Status::Unknown);
720 }
721
722 #[test]
723 fn unreachable_reports_unknown_with_a_reason() {
724 let last = "2026-07-21T18:00:00Z".parse().unwrap();
725 let p = Payload::unreachable("bento", last, "connection refused");
726 assert_eq!(p.worst_status(), Status::Unknown);
727 assert_eq!(
728 p.nodes[0].conditions[0].detail.as_deref(),
729 Some("connection refused")
730 );
731 assert_eq!(p.validate(), Ok(()));
732 }
733
734 #[test]
735 fn staleness_is_measured_against_a_passed_in_clock() {
736 let p = Payload::new("pom", "2026-07-21T18:00:00Z".parse().unwrap());
737 let now: DateTime<Utc> = "2026-07-21T18:20:00Z".parse().unwrap();
738 assert_eq!(p.age(now), TimeDelta::minutes(20));
739 assert!(p.is_stale(now, TimeDelta::minutes(5)));
740 assert!(!p.is_stale(now, TimeDelta::hours(1)));
741 }
742
743 #[test]
744 fn roots_excludes_claimed_children() {
745 let p = spec_example();
746 let roots: Vec<&str> = p.roots().map(|n| n.id.as_str()).collect();
747 assert_eq!(roots, vec!["tier:b"]);
748 }
749
750 #[test]
751 fn validate_catches_dangling_references() {
752 let mut p = spec_example();
753 p.nodes[0].children.push("node:ghost".into());
754 p.nodes[0].actions.push("promote-c".into());
755 let problems = p.validate().unwrap_err();
756 assert_eq!(problems.len(), 2);
757 assert!(problems.iter().any(|m| m.contains("node:ghost")));
758 assert!(problems.iter().any(|m| m.contains("promote-c")));
759 }
760
761 #[test]
762 fn validate_catches_duplicate_ids() {
763 let mut p = spec_example();
764 let dup = p.nodes[1].clone();
765 p.nodes.push(dup);
766 assert!(
767 p.validate()
768 .unwrap_err()
769 .iter()
770 .any(|m| m.contains("duplicate"))
771 );
772 }
773
774 #[test]
775 fn optional_members_stay_off_the_wire() {
776 let p = Payload::new("sando", "2026-07-21T18:00:00Z".parse().unwrap());
777 let json = serde_json::to_string(&p).unwrap();
778 assert!(!json.contains("events"), "{json}");
779 assert!(!json.contains("actions"), "{json}");
780 }
781
782 #[test]
783 fn every_known_kind_roundtrips() {
784 let values = vec![
785 Value::Text { value: "x".into() },
786 Value::Ident {
787 value: "a3f9c21b7e4d8056".into(),
788 abbrev_to: Some(8),
789 },
790 Value::Version {
791 value: "0.10.14".into(),
792 },
793 Value::Instant {
794 value: "2026-07-21T14:02:00Z".parse().unwrap(),
795 },
796 Value::Duration { seconds: 3600 },
797 Value::Progress {
798 value: 31.0,
799 max: 48.0,
800 unit: Some("hour".into()),
801 },
802 Value::Quantity {
803 value: 43.5,
804 unit: Some("MB".into()),
805 },
806 Value::State {
807 value: Status::Degraded,
808 },
809 Value::Link {
810 url: "https://makenot.work".into(),
811 text: Some("site".into()),
812 },
813 Value::Path {
814 value: "/srv/sando/releases/a3f9c21b".into(),
815 },
816 ];
817 assert_eq!(
818 values.len(),
819 KNOWN_KINDS.len(),
820 "a kind was added without a roundtrip test"
821 );
822
823 for value in values {
824 let kind = value.kind();
825 assert!(
826 KNOWN_KINDS.contains(&kind),
827 "{kind} missing from KNOWN_KINDS"
828 );
829 let f = Field::new("l", value.clone());
830 let wire = serde_json::to_string(&f).unwrap();
831
832 // A value member named `label` would collide with the field's own
833 // and lose, silently. This is how `link` was caught.
834 let obj: serde_json::Map<String, serde_json::Value> =
835 serde_json::from_str(&wire).unwrap();
836 assert_eq!(obj["label"], "l", "{kind} clobbers the field label: {wire}");
837
838 let back: Field = serde_json::from_str(&wire).unwrap();
839 assert_eq!(back.value, value, "{kind} did not roundtrip");
840 }
841 }
842 }
843