Skip to main content

max / makenotwork

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