Skip to main content

max / makenotwork

27.4 KB · 848 lines History Blame Raw
1 //! Sando's projection onto the shared operator status payload.
2 //!
3 //! Spec + rationale: maintainer wiki.
4 //! <!-- wiki: release-status-payload -->
5 //!
6 //! `GET /status.json` serves this. It is the same read as `/state`, restated in
7 //! the vocabulary every operator surface renders, so a viewer needs no
8 //! knowledge of tiers, gates, or promotion to draw Sando.
9 //!
10 //! [`payload`] is a pure function of `(StateView, now)`. Every clock is an
11 //! argument, so a fixture state renders identically forever and the mapping is
12 //! testable without a database.
13 //!
14 //! # What maps to what
15 //!
16 //! | Sando | payload |
17 //! |---|---|
18 //! | tier | node, `kind = "tier"` |
19 //! | deploy target | node, `kind = "node"`, child of its tier |
20 //! | latest build | node, `kind = "build"` |
21 //! | gate | condition on its tier |
22 //! | promote / rollback / confirm | declared action |
23 //!
24 //! # Gates are promotion guards, not tier health
25 //!
26 //! The load-bearing judgement here. A gate blocks promotion *out of* a tier; it
27 //! does not describe whether that tier is serving. `burn_in` sits blocked for
28 //! 48 hours as a matter of routine, and a tier whose status went yellow for two
29 //! days every release is a tier nobody looks at.
30 //!
31 //! So a blocked or in-flight gate is reported as a `pending` condition and
32 //! leaves the tier `ok`. Only evidence of something actually wrong — a failed
33 //! gate, or a partial (mixed-version) tier — degrades the tier itself. The why
34 //! is never lost: it is in the conditions either way.
35
36 use chrono::{DateTime, Utc};
37 use ops_status::{Action, Condition, Field, Method, Node, Payload, Status, Value};
38
39 use crate::outcome::GateOutcome;
40 use crate::routes::{GateView, StateView, TierView};
41
42 /// The `source` name Sando answers to in a viewer's config.
43 pub const SOURCE: &str = "sando";
44
45 /// Restate `/state` as the shared payload.
46 ///
47 /// `now` is an argument rather than read from the clock so the mapping stays
48 /// pure and snapshot-testable.
49 pub(crate) fn payload(view: &StateView, now: DateTime<Utc>) -> Payload {
50 let mut payload = Payload::new(SOURCE, now);
51
52 payload.actions.insert(
53 "rebuild".into(),
54 Action {
55 label: "Rebuild".into(),
56 method: Method::Post,
57 url: "/rebuild".into(),
58 confirm: false,
59 danger: false,
60 body: None,
61 },
62 );
63
64 // Always emitted, even before the first `/rebuild`. Actions reach a viewer
65 // only by hanging off a node, so a daemon with no build history and no
66 // build node would offer no way to start one.
67 payload.nodes.push(build_node(view.build.as_ref()));
68
69 for tier in &view.tiers {
70 for action in tier_actions(tier) {
71 payload.actions.insert(action.0, action.1);
72 }
73 payload.nodes.push(tier_node(tier));
74 payload.nodes.extend(node_nodes(tier));
75 }
76
77 payload
78 }
79
80 // ---------------------------------------------------------------------------
81 // Build
82 // ---------------------------------------------------------------------------
83
84 fn build_node(build: Option<&crate::runs::BuildSummary>) -> Node {
85 let Some(build) = build else {
86 return Node {
87 id: "build".into(),
88 kind: "build".into(),
89 label: "latest build".into(),
90 status: Status::Pending,
91 fields: Vec::new(),
92 conditions: vec![Condition {
93 condition_type: "build".into(),
94 status: Status::Pending,
95 since: None,
96 detail: Some("no build run yet".into()),
97 }],
98 children: Vec::new(),
99 actions: vec!["rebuild".into()],
100 };
101 };
102
103 // `build_runs.result` is one of building / passed / failed / aborted. An
104 // aborted run is degraded rather than failed: the daemon settling an
105 // orphaned run on restart is not the same event as a build that broke.
106 let status = match build.result.as_str() {
107 "passed" => Status::Ok,
108 "failed" => Status::Failed,
109 "building" => Status::Pending,
110 "aborted" => Status::Degraded,
111 _ => Status::Unknown,
112 };
113
114 let mut fields = vec![
115 Field::new(
116 "sha",
117 Value::Ident {
118 value: build.sha.clone(),
119 abbrev_to: Some(8),
120 },
121 ),
122 Field::new(
123 "phase",
124 Value::Text {
125 value: build.phase.clone(),
126 },
127 ),
128 Field::new(
129 "elapsed",
130 Value::Duration {
131 seconds: build.elapsed_s,
132 },
133 ),
134 ];
135 if let Some(version) = &build.version {
136 fields.insert(
137 0,
138 Field::new(
139 "version",
140 Value::Version {
141 value: version.clone(),
142 },
143 ),
144 );
145 }
146
147 let mut conditions = vec![Condition {
148 condition_type: "build".into(),
149 status,
150 since: None,
151 detail: Some(format!("run {} {}", build.run_id, build.result)),
152 }];
153 if let Some(failure) = &build.failure_summary {
154 conditions.push(Condition {
155 condition_type: "build_failure".into(),
156 status: Status::Failed,
157 since: None,
158 detail: Some(failure.clone()),
159 });
160 }
161
162 Node {
163 id: "build".into(),
164 kind: "build".into(),
165 label: "latest build".into(),
166 status,
167 fields,
168 conditions,
169 children: Vec::new(),
170 actions: vec!["rebuild".into()],
171 }
172 }
173
174 // ---------------------------------------------------------------------------
175 // Tiers
176 // ---------------------------------------------------------------------------
177
178 fn tier_id(tier: &TierView) -> String {
179 format!("tier:{}", tier.name)
180 }
181
182 fn tier_node(tier: &TierView) -> Node {
183 let mut conditions: Vec<Condition> = tier.gates.iter().map(gate_condition).collect();
184
185 // A partial tier is mid-version and could not be compensated back to
186 // consistency. It is the loudest thing Sando can report.
187 if let Some(reason) = &tier.partial_reason {
188 conditions.insert(
189 0,
190 Condition {
191 condition_type: "consistent".into(),
192 status: Status::Failed,
193 since: None,
194 detail: Some(reason.clone()),
195 },
196 );
197 }
198 if !tier.provisioned {
199 conditions.push(Condition {
200 condition_type: "provisioned".into(),
201 status: Status::Pending,
202 since: None,
203 detail: Some("declared in topology, not provisioned".into()),
204 });
205 }
206
207 let mut fields = vec![Field::new(
208 "canary",
209 Value::Text {
210 value: tier.canary.clone(),
211 },
212 )];
213 if let Some(version) = &tier.current_version {
214 fields.insert(
215 0,
216 Field::new(
217 "version",
218 Value::Version {
219 value: version.clone(),
220 },
221 ),
222 );
223 }
224 if let Some(previous) = &tier.previous_version {
225 fields.push(Field::new(
226 "previous",
227 Value::Version {
228 value: previous.clone(),
229 },
230 ));
231 }
232 if let Some(started) = tier.burn_in_started_at.as_deref().and_then(parse_instant) {
233 fields.push(Field::new(
234 "burn-in since",
235 Value::Instant { value: started },
236 ));
237 }
238 if let Some(progress) = burn_in_progress(tier) {
239 fields.push(progress);
240 }
241
242 Node {
243 id: tier_id(tier),
244 kind: "tier".into(),
245 label: tier.name.clone(),
246 status: tier_status(tier),
247 fields,
248 conditions,
249 children: tier.nodes.iter().map(|n| format!("node:{n}")).collect(),
250 actions: tier_actions(tier).into_iter().map(|(key, _)| key).collect(),
251 }
252 }
253
254 /// Whether the tier itself is healthy — deliberately not "are its gates green".
255 ///
256 /// See the module docs: gates guard promotion out, so a blocked one is routine
257 /// and must not color the tier.
258 fn tier_status(tier: &TierView) -> Status {
259 if tier.partial_reason.is_some() {
260 return Status::Failed;
261 }
262 if tier
263 .gates
264 .iter()
265 .any(|g| g.status.as_deref() == Some("failed"))
266 {
267 return Status::Failed;
268 }
269 // An unrecognized gate status is contract drift between daemon and viewer,
270 // which is worth surfacing rather than smoothing over. The TUI renders it
271 // magenta for the same reason.
272 if tier.gates.iter().any(|g| gate_status(g) == Status::Unknown) {
273 return Status::Degraded;
274 }
275 if !tier.provisioned || tier.current_version.is_none() {
276 return Status::Pending;
277 }
278 Status::Ok
279 }
280
281 /// Burn-in as a bar rather than a sentence, so it sorts and colors like every
282 /// other progress value in the UI.
283 fn burn_in_progress(tier: &TierView) -> Option<Field> {
284 let gate = tier.gates.iter().find(|g| g.kind == "burn_in")?;
285 let outcome = gate.outcome.clone()?;
286 let outcome: GateOutcome = serde_json::from_value(outcome).ok()?;
287
288 let (value, max) = match outcome.status {
289 crate::outcome::GateStatus::Blocked {
290 blocker:
291 crate::outcome::GateBlocker::BurnInRemaining {
292 hours_remaining,
293 hours_total,
294 },
295 } => (
296 f64::from(hours_total.saturating_sub(hours_remaining)),
297 f64::from(hours_total),
298 ),
299 crate::outcome::GateStatus::Passed {
300 note: crate::outcome::PassNote::BurnInElapsed { hours },
301 } => (f64::from(hours), f64::from(hours)),
302 _ => return None,
303 };
304
305 Some(Field::new(
306 "burn-in",
307 Value::Progress {
308 value,
309 max,
310 unit: Some("hour".into()),
311 },
312 ))
313 }
314
315 /// Promote, roll back, confirm — declared as data so the viewer issues them
316 /// without knowing what any of them mean.
317 ///
318 /// All three carry `confirm` and `danger`. Sando has no flag marking which
319 /// tiers are production, and inventing one from the tier name would be a
320 /// heuristic that fails silently the day the topology changes. Over-confirming
321 /// a deploy is the cheap direction to be wrong in.
322 fn tier_actions(tier: &TierView) -> Vec<(String, Action)> {
323 if !tier.provisioned {
324 return Vec::new();
325 }
326 let name = &tier.name;
327 vec![
328 (
329 format!("promote-{name}"),
330 Action {
331 label: format!("Promote to {name}"),
332 method: Method::Post,
333 url: format!("/promote/{name}"),
334 confirm: true,
335 danger: true,
336 body: None,
337 },
338 ),
339 (
340 format!("rollback-{name}"),
341 Action {
342 label: format!("Roll back {name}"),
343 method: Method::Post,
344 url: format!("/rollback/{name}"),
345 confirm: true,
346 danger: true,
347 body: None,
348 },
349 ),
350 (
351 format!("confirm-{name}"),
352 Action {
353 label: format!("Confirm {name}"),
354 method: Method::Post,
355 url: format!("/confirm/{name}"),
356 confirm: true,
357 danger: true,
358 body: None,
359 },
360 ),
361 ]
362 }
363
364 // ---------------------------------------------------------------------------
365 // Gates
366 // ---------------------------------------------------------------------------
367
368 /// `passed | failed | blocked`, or NULL while the gate is in flight.
369 ///
370 /// `blocked` becomes `pending`: a blocked gate is waiting on something, which
371 /// is what pending means here. An unrecognized string is `unknown`, not
372 /// in-flight — that distinction is why the TUI renders it magenta.
373 fn gate_status(gate: &GateView) -> Status {
374 match gate.status.as_deref() {
375 Some("passed") => Status::Ok,
376 Some("failed") => Status::Failed,
377 Some("blocked") | None => Status::Pending,
378 Some(_) => Status::Unknown,
379 }
380 }
381
382 fn gate_condition(gate: &GateView) -> Condition {
383 Condition {
384 condition_type: gate.kind.clone(),
385 status: gate_status(gate),
386 since: gate.finished_at.as_deref().and_then(parse_instant),
387 detail: gate_detail(gate),
388 }
389 }
390
391 /// The why, from the typed outcome when there is one.
392 ///
393 /// "blocked" is useless; "blocked because burn_in has 17 hours remaining of 48"
394 /// is what saves an SSH.
395 fn gate_detail(gate: &GateView) -> Option<String> {
396 if gate.status.is_none() {
397 return Some("running".into());
398 }
399 let outcome = gate.outcome.clone()?;
400 let outcome: GateOutcome = serde_json::from_value(outcome).ok()?;
401 Some(match outcome.status {
402 crate::outcome::GateStatus::Passed { note } => note.summary(),
403 crate::outcome::GateStatus::Failed { failure } => failure.summary(),
404 crate::outcome::GateStatus::Blocked { blocker } => blocker.summary(),
405 })
406 }
407
408 // ---------------------------------------------------------------------------
409 // Deploy targets
410 // ---------------------------------------------------------------------------
411
412 /// One node per deploy target, carrying whatever independent health evidence
413 /// exists for it.
414 ///
415 /// `node_health` is the only per-node signal Sando has. When it failed on a
416 /// named node, that node is failed and its siblings are not. With no evidence
417 /// either way a node inherits its tier: claiming `unknown` for every node on a
418 /// tier without the gate would make Sando permanently loud in the rollup for a
419 /// topology choice, and a viewer that cries wolf is the thing this replaces.
420 fn node_nodes(tier: &TierView) -> Vec<Node> {
421 let unhealthy = unhealthy_node(tier);
422 let health_gate = tier.gates.iter().find(|g| g.kind == "node_health");
423
424 tier.nodes
425 .iter()
426 .map(|name| {
427 let status = if unhealthy.as_deref() == Some(name.as_str()) {
428 Status::Failed
429 } else {
430 match health_gate.map(gate_status) {
431 Some(Status::Ok) => Status::Ok,
432 // The gate failed on a *different* node, so this one is
433 // healthy by the same evidence.
434 Some(Status::Failed) if unhealthy.is_some() => Status::Ok,
435 Some(_) | None => tier_status(tier),
436 }
437 };
438
439 let mut conditions = Vec::new();
440 if let Some(gate) = health_gate {
441 conditions.push(Condition {
442 condition_type: "node_health".into(),
443 status,
444 since: gate.finished_at.as_deref().and_then(parse_instant),
445 detail: gate_detail(gate),
446 });
447 }
448
449 Node {
450 id: format!("node:{name}"),
451 kind: "node".into(),
452 label: name.clone(),
453 status,
454 fields: tier
455 .current_version
456 .as_ref()
457 .map(|v| vec![Field::new("version", Value::Version { value: v.clone() })])
458 .unwrap_or_default(),
459 conditions,
460 children: Vec::new(),
461 actions: Vec::new(),
462 }
463 })
464 .collect()
465 }
466
467 /// The node named by a `node_health` failure, if that is why the gate is red.
468 fn unhealthy_node(tier: &TierView) -> Option<String> {
469 let gate = tier.gates.iter().find(|g| g.kind == "node_health")?;
470 let outcome: GateOutcome = serde_json::from_value(gate.outcome.clone()?).ok()?;
471 match outcome.status {
472 crate::outcome::GateStatus::Failed {
473 failure: crate::outcome::GateFailure::NodeUnhealthy { node, .. },
474 } => Some(node),
475 _ => None,
476 }
477 }
478
479 // ---------------------------------------------------------------------------
480
481 /// Timestamps cross the DB boundary as RFC 3339 strings. A row that fails to
482 /// parse loses its `since` rather than failing the whole payload: a viewer that
483 /// blanks on one malformed timestamp is worse than one missing a tooltip.
484 fn parse_instant(raw: &str) -> Option<DateTime<Utc>> {
485 DateTime::parse_from_rfc3339(raw)
486 .ok()
487 .map(|d| d.with_timezone(&Utc))
488 }
489
490 #[cfg(test)]
491 mod tests {
492 use super::*;
493 use crate::outcome::{GateBlocker, GateFailure, GateStatus, PassNote};
494
495 fn now() -> DateTime<Utc> {
496 "2026-07-21T18:24:39Z".parse().unwrap()
497 }
498
499 fn gate(kind: &str, status: Option<&str>, outcome: Option<GateOutcome>) -> GateView {
500 GateView {
501 kind: kind.into(),
502 finished_at: Some("2026-07-21T14:02:00Z".into()),
503 status: status.map(Into::into),
504 outcome: outcome.map(|o| serde_json::to_value(o).unwrap()),
505 log_ref: None,
506 }
507 }
508
509 fn passed(note: PassNote) -> GateOutcome {
510 GateOutcome {
511 status: GateStatus::Passed { note },
512 log_ref: None,
513 }
514 }
515
516 fn blocked(blocker: GateBlocker) -> GateOutcome {
517 GateOutcome {
518 status: GateStatus::Blocked { blocker },
519 log_ref: None,
520 }
521 }
522
523 fn failed(failure: GateFailure) -> GateOutcome {
524 GateOutcome {
525 status: GateStatus::Failed { failure },
526 log_ref: None,
527 }
528 }
529
530 fn tier(name: &str) -> TierView {
531 TierView {
532 name: name.into(),
533 ord: 1,
534 provisioned: true,
535 canary: "sequential".into(),
536 current_version: Some("0.10.14".into()),
537 previous_version: Some("0.10.13".into()),
538 burn_in_started_at: Some("2026-07-21T14:02:00Z".into()),
539 partial_reason: None,
540 nodes: vec![format!("{name}-1")],
541 gates: Vec::new(),
542 }
543 }
544
545 fn view(tiers: Vec<TierView>) -> StateView {
546 StateView {
547 sandod_version: "0.2.2",
548 tiers,
549 build: None,
550 }
551 }
552
553 fn node<'a>(p: &'a Payload, id: &str) -> &'a Node {
554 p.node(id).unwrap_or_else(|| panic!("no node {id}"))
555 }
556
557 #[test]
558 fn a_healthy_tier_is_ok_and_structurally_sound() {
559 let mut t = tier("b");
560 t.gates = vec![gate(
561 "node_health",
562 Some("passed"),
563 Some(passed(PassNote::NodesHealthy { nodes: 1 })),
564 )];
565 let mut v = view(vec![t]);
566 v.build = Some(crate::runs::BuildSummary {
567 run_id: 40,
568 sha: "68f44d7ac21b7e4d".into(),
569 version: Some("0.10.14".into()),
570 phase: "done".into(),
571 result: "passed".into(),
572 failure_summary: None,
573 elapsed_s: 512,
574 });
575 let p = payload(&v, now());
576
577 assert_eq!(p.source, SOURCE);
578 assert_eq!(p.schema, ops_status::SCHEMA_VERSION);
579 assert_eq!(p.validate(), Ok(()));
580 assert_eq!(node(&p, "tier:b").status, Status::Ok);
581 assert_eq!(node(&p, "node:b-1").status, Status::Ok);
582 assert_eq!(p.worst_status(), Status::Ok);
583 }
584
585 #[test]
586 fn a_blocked_burn_in_leaves_the_tier_ok() {
587 // The judgement this module turns on: burn-in blocks for 48 hours as a
588 // matter of routine and must not make prod look sick for two days.
589 let mut t = tier("b");
590 t.gates = vec![gate(
591 "burn_in",
592 Some("blocked"),
593 Some(blocked(GateBlocker::BurnInRemaining {
594 hours_remaining: 17,
595 hours_total: 48,
596 })),
597 )];
598 let p = payload(&view(vec![t]), now());
599
600 assert_eq!(node(&p, "tier:b").status, Status::Ok);
601 let c = &node(&p, "tier:b").conditions[0];
602 assert_eq!(c.status, Status::Pending);
603 assert_eq!(c.detail.as_deref(), Some("17 hours remaining of 48"));
604 }
605
606 #[test]
607 fn burn_in_becomes_a_bar_not_a_sentence() {
608 let mut t = tier("b");
609 t.gates = vec![gate(
610 "burn_in",
611 Some("blocked"),
612 Some(blocked(GateBlocker::BurnInRemaining {
613 hours_remaining: 17,
614 hours_total: 48,
615 })),
616 )];
617 let p = payload(&view(vec![t]), now());
618
619 let field = node(&p, "tier:b")
620 .fields
621 .iter()
622 .find(|f| f.label == "burn-in")
623 .unwrap();
624 assert_eq!(
625 field.value,
626 Value::Progress {
627 value: 31.0,
628 max: 48.0,
629 unit: Some("hour".into())
630 }
631 );
632 }
633
634 #[test]
635 fn a_failed_gate_fails_the_tier() {
636 let mut t = tier("b");
637 t.gates = vec![gate(
638 "cargo_test",
639 Some("failed"),
640 Some(failed(GateFailure::CargoTest {
641 failed_count: 3,
642 first_failed: Some("auth::rate_limit".into()),
643 first_panic: None,
644 })),
645 )];
646 let p = payload(&view(vec![t]), now());
647
648 assert_eq!(node(&p, "tier:b").status, Status::Failed);
649 assert_eq!(p.worst_status(), Status::Failed);
650 assert!(
651 node(&p, "tier:b").conditions[0]
652 .detail
653 .as_deref()
654 .unwrap()
655 .contains("3 test(s)")
656 );
657 }
658
659 #[test]
660 fn a_partial_tier_is_failed_and_says_why_first() {
661 let mut t = tier("b");
662 t.partial_reason = Some("rsync failed on prod-1 after symlink swap".into());
663 let p = payload(&view(vec![t]), now());
664
665 let n = node(&p, "tier:b");
666 assert_eq!(n.status, Status::Failed);
667 assert_eq!(n.conditions[0].condition_type, "consistent");
668 assert_eq!(n.conditions[0].status, Status::Failed);
669 }
670
671 #[test]
672 fn an_unprovisioned_tier_is_pending_with_no_actions() {
673 let mut t = tier("c");
674 t.provisioned = false;
675 t.current_version = None;
676 t.nodes = Vec::new();
677 let p = payload(&view(vec![t]), now());
678
679 let n = node(&p, "tier:c");
680 assert_eq!(n.status, Status::Pending);
681 assert!(
682 n.actions.is_empty(),
683 "an unprovisioned tier must not offer a promote"
684 );
685 assert_eq!(n.conditions.last().unwrap().condition_type, "provisioned");
686 }
687
688 #[test]
689 fn node_health_blames_only_the_named_node() {
690 let mut t = tier("b");
691 t.nodes = vec!["prod-1".into(), "prod-2".into()];
692 t.gates = vec![gate(
693 "node_health",
694 Some("failed"),
695 Some(failed(GateFailure::NodeUnhealthy {
696 node: "prod-2".into(),
697 detail: "inactive".into(),
698 })),
699 )];
700 let p = payload(&view(vec![t]), now());
701
702 assert_eq!(node(&p, "node:prod-2").status, Status::Failed);
703 assert_eq!(node(&p, "node:prod-1").status, Status::Ok);
704 assert_eq!(node(&p, "tier:b").status, Status::Failed);
705 }
706
707 #[test]
708 fn a_node_with_no_health_evidence_inherits_its_tier() {
709 // Not `unknown`: a tier that simply has no node_health gate must not
710 // make Sando permanently loud in the rollup.
711 let t = tier("host");
712 let p = payload(&view(vec![t]), now());
713 assert_eq!(node(&p, "node:host-1").status, Status::Ok);
714 }
715
716 #[test]
717 fn an_unrecognized_gate_status_degrades_rather_than_hiding() {
718 let mut t = tier("b");
719 t.gates = vec![gate("mystery", Some("sideways"), None)];
720 let p = payload(&view(vec![t]), now());
721
722 assert_eq!(node(&p, "tier:b").conditions[0].status, Status::Unknown);
723 assert_eq!(node(&p, "tier:b").status, Status::Degraded);
724 }
725
726 #[test]
727 fn an_in_flight_gate_is_pending_and_says_so() {
728 let mut t = tier("b");
729 t.gates = vec![gate("cargo_test", None, None)];
730 let p = payload(&view(vec![t]), now());
731
732 let c = &node(&p, "tier:b").conditions[0];
733 assert_eq!(c.status, Status::Pending);
734 assert_eq!(c.detail.as_deref(), Some("running"));
735 assert_eq!(node(&p, "tier:b").status, Status::Ok);
736 }
737
738 #[test]
739 fn every_declared_action_is_referenced_and_every_reference_declared() {
740 let mut t = tier("b");
741 t.gates = vec![gate(
742 "node_health",
743 Some("passed"),
744 Some(passed(PassNote::NodesHealthy { nodes: 1 })),
745 )];
746 let p = payload(&view(vec![t, tier("a")]), now());
747
748 assert_eq!(p.validate(), Ok(()));
749 for action in &node(&p, "tier:b").actions {
750 let declared = &p.actions[action];
751 assert!(declared.confirm, "{action} must confirm");
752 assert!(declared.danger, "{action} must read as dangerous");
753 }
754 }
755
756 #[test]
757 fn a_failed_build_is_a_node_and_carries_its_summary() {
758 let mut v = view(vec![tier("b")]);
759 v.build = Some(crate::runs::BuildSummary {
760 run_id: 41,
761 sha: "68f44d7ac21b7e4d".into(),
762 version: Some("0.10.15".into()),
763 phase: "done".into(),
764 result: "failed".into(),
765 failure_summary: Some("error[E0063]: missing field user_pages_host".into()),
766 elapsed_s: 214,
767 });
768 let p = payload(&v, now());
769
770 let n = node(&p, "build");
771 assert_eq!(n.status, Status::Failed);
772 assert_eq!(n.actions, vec!["rebuild".to_string()]);
773 assert!(
774 n.conditions
775 .iter()
776 .any(|c| c.condition_type == "build_failure")
777 );
778 assert_eq!(p.worst_status(), Status::Failed);
779 assert_eq!(p.validate(), Ok(()));
780 }
781
782 #[test]
783 fn a_daemon_that_has_never_built_can_still_be_told_to_build() {
784 // An action reaches a viewer only through a node, so the build node is
785 // unconditional even before the first `/rebuild`.
786 let p = payload(&view(vec![tier("b")]), now());
787 let n = node(&p, "build");
788 assert_eq!(n.status, Status::Pending);
789 assert_eq!(n.actions, vec!["rebuild".to_string()]);
790 assert_eq!(n.conditions[0].detail.as_deref(), Some("no build run yet"));
791 assert_eq!(p.validate(), Ok(()));
792 }
793
794 #[test]
795 fn an_in_flight_build_is_pending_not_failed() {
796 let mut v = view(vec![tier("b")]);
797 v.build = Some(crate::runs::BuildSummary {
798 run_id: 42,
799 sha: "68f44d7a".into(),
800 version: None,
801 phase: "cargo build".into(),
802 result: "building".into(),
803 failure_summary: None,
804 elapsed_s: 61,
805 });
806 let p = payload(&v, now());
807 assert_eq!(node(&p, "build").status, Status::Pending);
808 }
809
810 #[test]
811 fn render_is_a_pure_function_of_state_and_clock() {
812 let v = view(vec![tier("b")]);
813 let a = payload(&v, now());
814 let b = payload(&v, now());
815 assert_eq!(
816 serde_json::to_value(&a).unwrap(),
817 serde_json::to_value(&b).unwrap()
818 );
819 }
820
821 #[test]
822 fn tiers_appear_in_topology_order() {
823 let p = payload(&view(vec![tier("host"), tier("a"), tier("b")]), now());
824 let tiers: Vec<&str> = p
825 .nodes
826 .iter()
827 .filter(|n| n.kind == "tier")
828 .map(|n| n.label.as_str())
829 .collect();
830 assert_eq!(tiers, vec!["host", "a", "b"]);
831 }
832
833 #[test]
834 fn a_malformed_timestamp_costs_only_that_timestamp() {
835 let mut t = tier("b");
836 t.gates = vec![GateView {
837 kind: "node_health".into(),
838 finished_at: Some("not a timestamp".into()),
839 status: Some("passed".into()),
840 outcome: None,
841 log_ref: None,
842 }];
843 let p = payload(&view(vec![t]), now());
844 assert!(node(&p, "tier:b").conditions[0].since.is_none());
845 assert_eq!(node(&p, "tier:b").status, Status::Ok);
846 }
847 }
848