Skip to main content

max / makenotwork

24.7 KB · 773 lines History Blame Raw
1 //! Bento'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. [`payload`] is a pure function of
7 //! `(apps, now)`, so a fixture renders identically forever and the mapping is
8 //! testable without a database.
9 //!
10 //! # What maps to what
11 //!
12 //! | Bento | payload |
13 //! |---|---|
14 //! | app | node, `kind = "app"` |
15 //! | target | node, `kind = "target"`, child of its app |
16 //! | step | condition on its target |
17 //! | build / retry | declared action, with its POST body |
18 //!
19 //! # Partial releases are the thing this exists to show
20 //!
21 //! Bento's targets are independent: a failed `windows/x86_64` neither stops nor
22 //! rolls back the macOS build, and publishing is per target with no all-green
23 //! gate. So "the release went out" and "the release went out on three of five
24 //! platforms" look identical from `/state`, which reports only the newest build
25 //! row across every app — one unrelated build is enough to hide another app's
26 //! half-failure entirely.
27 //!
28 //! Two things here answer that. The read is per app, so nothing can be masked
29 //! by a later build. And an app whose build finished without covering every
30 //! declared target reports `degraded` with a `release_complete` condition
31 //! naming what is missing, rather than the `ok` that a build-level status alone
32 //! would imply.
33
34 use std::fmt::Write as _;
35
36 use chrono::{DateTime, Utc};
37 use ops_status::{Action, Condition, Field, Method, Node, Payload, Status, Value};
38 use serde_json::json;
39
40 use crate::routes::{AppStatusView, BuildView, StepView};
41 use crate::topology::Kind;
42
43 /// The `source` name Bento answers to in a viewer's config.
44 pub const SOURCE: &str = "bento";
45
46 /// Restate every app's latest build as the shared payload.
47 ///
48 /// `now` is an argument rather than read from the clock so the mapping stays
49 /// pure and snapshot-testable.
50 pub(crate) fn payload(apps: &[AppStatusView], now: DateTime<Utc>) -> Payload {
51 let mut payload = Payload::new(SOURCE, now);
52
53 for app in apps {
54 let (key, action) = build_action(app);
55 payload.actions.insert(key, action);
56 payload.nodes.push(app_node(app));
57
58 for target in target_names(app) {
59 if let Some((key, action)) = retry_action(app, &target) {
60 payload.actions.insert(key, action);
61 }
62 payload.nodes.push(target_node(app, &target));
63 }
64 }
65
66 payload
67 }
68
69 // ---------------------------------------------------------------------------
70 // Apps
71 // ---------------------------------------------------------------------------
72
73 fn app_id(app: &AppStatusView) -> String {
74 format!("app:{}", app.app)
75 }
76
77 fn app_node(app: &AppStatusView) -> Node {
78 let status = app_status(app);
79 let mut fields = Vec::new();
80 let mut conditions = Vec::new();
81
82 match &app.build {
83 Some(build) => {
84 fields.push(Field::new(
85 "version",
86 Value::Version {
87 value: build.version.clone(),
88 },
89 ));
90 if let Some(at) = parse_instant(&build.created_at) {
91 fields.push(Field::new("started", Value::Instant { value: at }));
92 }
93 conditions.push(Condition {
94 condition_type: "build".into(),
95 status: run_status(&build.status),
96 since: parse_instant(&build.created_at),
97 detail: Some(format!("build {} {}", build.id, build.status)),
98 });
99 if let Some(condition) = release_completeness(app, build) {
100 conditions.push(condition);
101 }
102 if let Some(condition) = publish_completeness(app, build) {
103 conditions.push(condition);
104 }
105 }
106 None => conditions.push(Condition {
107 condition_type: "build".into(),
108 status: Status::Pending,
109 since: None,
110 detail: Some("never built".into()),
111 }),
112 }
113
114 fields.push(Field::new(
115 "kind",
116 Value::Text {
117 value: match app.kind {
118 Kind::App => "app".into(),
119 Kind::Library => "library".into(),
120 },
121 },
122 ));
123
124 Node {
125 id: app_id(app),
126 kind: "app".into(),
127 label: app.app.clone(),
128 status,
129 fields,
130 conditions,
131 children: target_names(app)
132 .iter()
133 .map(|t| target_id(app, t))
134 .collect(),
135 actions: vec![build_action_key(app)],
136 }
137 }
138
139 fn app_status(app: &AppStatusView) -> Status {
140 let Some(build) = &app.build else {
141 return Status::Pending;
142 };
143 if build.status == "failed" || build.targets.iter().any(|t| t.status == "failed") {
144 return Status::Failed;
145 }
146 match run_status(&build.status) {
147 Status::Pending => Status::Pending,
148 // A build the runner called finished, that did not cover every declared
149 // target, is a partial release wearing a green build status.
150 Status::Ok if missing_targets(app, build).is_empty() => Status::Ok,
151 Status::Ok => Status::Degraded,
152 other => other,
153 }
154 }
155
156 /// Declared targets with no run in the latest build.
157 fn missing_targets(app: &AppStatusView, build: &BuildView) -> Vec<String> {
158 app.declared_targets
159 .iter()
160 .filter(|d| !build.targets.iter().any(|t| &&t.target == d))
161 .cloned()
162 .collect()
163 }
164
165 /// "3 of 5 targets built" — emitted only once the build has settled, since a
166 /// running build is *supposed* to have targets outstanding.
167 fn release_completeness(app: &AppStatusView, build: &BuildView) -> Option<Condition> {
168 if matches!(run_status(&build.status), Status::Pending) {
169 return None;
170 }
171 let missing = missing_targets(app, build);
172 let failed: Vec<&str> = build
173 .targets
174 .iter()
175 .filter(|t| t.status == "failed")
176 .map(|t| t.target.as_str())
177 .collect();
178 if missing.is_empty() && failed.is_empty() {
179 return None;
180 }
181
182 let mut detail = format!(
183 "{} of {} declared targets built",
184 app.declared_targets.len() - missing.len(),
185 app.declared_targets.len()
186 );
187 if !failed.is_empty() {
188 write!(detail, "; failed: {}", failed.join(", ")).unwrap();
189 }
190 if !missing.is_empty() {
191 write!(detail, "; never ran: {}", missing.join(", ")).unwrap();
192 }
193
194 Some(Condition {
195 condition_type: "release_complete".into(),
196 status: if failed.is_empty() {
197 Status::Degraded
198 } else {
199 Status::Failed
200 },
201 since: None,
202 detail: Some(detail),
203 })
204 }
205
206 /// "2 of 5 targets published" — a partial publish is the same failure as a
207 /// partial build, one stage later.
208 ///
209 /// Silent when nothing has been published for this version. Publishing is not
210 /// wired for every app, and a condition that reads red for a stage an app does
211 /// not use is how a viewer teaches you to stop reading it.
212 fn publish_completeness(app: &AppStatusView, build: &BuildView) -> Option<Condition> {
213 if app.published_targets.is_empty() {
214 return None;
215 }
216 let expected = app.declared_targets.len();
217 let published = app.published_targets.len();
218 Some(Condition {
219 condition_type: "published".into(),
220 status: if published >= expected {
221 Status::Ok
222 } else {
223 Status::Degraded
224 },
225 since: None,
226 detail: Some(format!(
227 "{published} of {expected} targets published at {}",
228 build.version
229 )),
230 })
231 }
232
233 // ---------------------------------------------------------------------------
234 // Targets
235 // ---------------------------------------------------------------------------
236
237 /// Declared targets plus any the latest build ran that the manifest no longer
238 /// lists, so a target dropped from `bento.toml` mid-release does not vanish
239 /// from the surface while its run is still the newest thing that happened.
240 fn target_names(app: &AppStatusView) -> Vec<String> {
241 let mut names = app.declared_targets.clone();
242 if let Some(build) = &app.build {
243 for run in &build.targets {
244 if !names.contains(&run.target) {
245 names.push(run.target.clone());
246 }
247 }
248 }
249 names
250 }
251
252 fn target_id(app: &AppStatusView, target: &str) -> String {
253 format!("target:{}:{target}", app.app)
254 }
255
256 fn target_node(app: &AppStatusView, target: &str) -> Node {
257 let run = app
258 .build
259 .as_ref()
260 .and_then(|b| b.targets.iter().find(|t| t.target == target));
261
262 let status = match run {
263 Some(run) => run_status(&run.status),
264 // Declared but not run in the latest build.
265 None => Status::Pending,
266 };
267
268 let mut fields = Vec::new();
269 let mut conditions = Vec::new();
270
271 if let Some(run) = run {
272 if let Some(build) = &app.build {
273 fields.push(Field::new(
274 "version",
275 Value::Version {
276 value: build.version.clone(),
277 },
278 ));
279 }
280 if let Some(step) = &run.current_step {
281 fields.push(Field::new(
282 "step",
283 Value::Text {
284 value: step.clone(),
285 },
286 ));
287 }
288 if app.published_targets.iter().any(|t| t == target) {
289 fields.push(Field::new("published", Value::State { value: Status::Ok }));
290 }
291 conditions.extend(run.steps.iter().map(step_condition));
292 if let Some(error) = &run.error {
293 conditions.push(Condition {
294 condition_type: "error".into(),
295 status: Status::Failed,
296 since: None,
297 detail: Some(error.clone()),
298 });
299 }
300 } else {
301 conditions.push(Condition {
302 condition_type: "build".into(),
303 status: Status::Pending,
304 since: None,
305 detail: Some(match &app.build {
306 Some(build) => format!("no run at {}", build.version),
307 None => "never built".into(),
308 }),
309 });
310 }
311
312 Node {
313 id: target_id(app, target),
314 kind: "target".into(),
315 label: target.to_string(),
316 status,
317 fields,
318 conditions,
319 children: Vec::new(),
320 actions: retry_action(app, target)
321 .map(|(key, _)| vec![key])
322 .unwrap_or_default(),
323 }
324 }
325
326 fn step_condition(step: &StepView) -> Condition {
327 Condition {
328 condition_type: step.step.clone(),
329 status: run_status(&step.status),
330 since: None,
331 detail: Some(match step.status.as_str() {
332 "running" => "running".into(),
333 other => other.into(),
334 }),
335 }
336 }
337
338 // ---------------------------------------------------------------------------
339 // Actions
340 // ---------------------------------------------------------------------------
341
342 /// `POST /build` and `POST /retry` take their arguments in the body, so the
343 /// declared action carries one. The viewer posts it verbatim and still learns
344 /// nothing about what an app or a target is.
345 fn build_action_key(app: &AppStatusView) -> String {
346 format!("build-{}", app.app)
347 }
348
349 fn build_action(app: &AppStatusView) -> (String, Action) {
350 (
351 build_action_key(app),
352 Action {
353 label: format!("Build {}", app.app),
354 method: Method::Post,
355 url: "/build".into(),
356 confirm: true,
357 // A library build ends at a crates.io publish, which cannot be
358 // taken back. An app build is recoverable.
359 danger: matches!(app.kind, Kind::Library),
360 body: Some(json!({ "app": app.app })),
361 },
362 )
363 }
364
365 /// Retry is offered only where there is a run to retry.
366 fn retry_action(app: &AppStatusView, target: &str) -> Option<(String, Action)> {
367 let build = app.build.as_ref()?;
368 build.targets.iter().find(|t| t.target == target)?;
369 Some((
370 format!("retry-{}-{target}", app.app),
371 Action {
372 label: format!("Retry {target}"),
373 method: Method::Post,
374 url: "/retry".into(),
375 confirm: true,
376 danger: matches!(app.kind, Kind::Library),
377 body: Some(json!({
378 "app": app.app,
379 "target": target,
380 "version": build.version,
381 })),
382 },
383 ))
384 }
385
386 // ---------------------------------------------------------------------------
387
388 /// `builds`, `target_runs` and `step_runs` share one status vocabulary:
389 /// `pending | running | ok | failed`. Anything else is drift, and drift should
390 /// be visible rather than smoothed into "fine".
391 fn run_status(raw: &str) -> Status {
392 match raw {
393 "ok" => Status::Ok,
394 "failed" => Status::Failed,
395 "pending" | "running" => Status::Pending,
396 _ => Status::Unknown,
397 }
398 }
399
400 /// A timestamp that fails to parse costs only itself. A viewer that blanks on
401 /// one malformed row is worse than one missing a tooltip.
402 fn parse_instant(raw: &str) -> Option<DateTime<Utc>> {
403 DateTime::parse_from_rfc3339(raw)
404 .ok()
405 .map(|d| d.with_timezone(&Utc))
406 }
407
408 #[cfg(test)]
409 mod tests {
410 use super::*;
411 use crate::routes::TargetView;
412
413 fn now() -> DateTime<Utc> {
414 "2026-07-21T18:24:39Z".parse().unwrap()
415 }
416
417 fn step(name: &str, status: &str) -> StepView {
418 StepView {
419 run_id: 1,
420 step: name.into(),
421 status: status.into(),
422 log_ref: None,
423 }
424 }
425
426 fn target_run(target: &str, status: &str) -> TargetView {
427 TargetView {
428 target: target.into(),
429 status: status.into(),
430 current_step: None,
431 error: None,
432 steps: vec![step("build", status)],
433 }
434 }
435
436 fn build(version: &str, status: &str, targets: Vec<TargetView>) -> BuildView {
437 BuildView {
438 id: 7,
439 app: "goingson".into(),
440 version: version.into(),
441 status: status.into(),
442 created_at: "2026-07-21T14:02:00Z".into(),
443 targets,
444 }
445 }
446
447 fn app(declared: &[&str], build: Option<BuildView>) -> AppStatusView {
448 AppStatusView {
449 app: "goingson".into(),
450 kind: Kind::App,
451 declared_targets: declared.iter().map(|s| (*s).to_string()).collect(),
452 build,
453 published_targets: Vec::new(),
454 }
455 }
456
457 fn node<'a>(p: &'a Payload, id: &str) -> &'a Node {
458 p.node(id).unwrap_or_else(|| panic!("no node {id}"))
459 }
460
461 #[test]
462 fn a_fully_built_app_is_ok_and_structurally_sound() {
463 let a = app(
464 &["linux/x86_64", "macos/aarch64"],
465 Some(build(
466 "1.4.0",
467 "ok",
468 vec![
469 target_run("linux/x86_64", "ok"),
470 target_run("macos/aarch64", "ok"),
471 ],
472 )),
473 );
474 let p = payload(&[a], now());
475
476 assert_eq!(p.source, SOURCE);
477 assert_eq!(p.schema, ops_status::SCHEMA_VERSION);
478 assert_eq!(p.validate(), Ok(()));
479 assert_eq!(node(&p, "app:goingson").status, Status::Ok);
480 assert_eq!(node(&p, "target:goingson:linux/x86_64").status, Status::Ok);
481 assert_eq!(p.worst_status(), Status::Ok);
482 }
483
484 #[test]
485 fn a_failed_target_fails_the_app_and_names_itself() {
486 // The audit's H2: a failed windows target neither stops nor rolls back
487 // macOS, and nothing surfaced that the release went out partial.
488 let a = app(
489 &["linux/x86_64", "windows/x86_64"],
490 Some(build(
491 "1.4.0",
492 "ok",
493 vec![
494 target_run("linux/x86_64", "ok"),
495 target_run("windows/x86_64", "failed"),
496 ],
497 )),
498 );
499 let p = payload(&[a], now());
500
501 assert_eq!(node(&p, "app:goingson").status, Status::Failed);
502 assert_eq!(
503 node(&p, "target:goingson:windows/x86_64").status,
504 Status::Failed
505 );
506 assert_eq!(node(&p, "target:goingson:linux/x86_64").status, Status::Ok);
507
508 let c = node(&p, "app:goingson")
509 .conditions
510 .iter()
511 .find(|c| c.condition_type == "release_complete")
512 .expect("a partial release must say so");
513 assert!(c.detail.as_deref().unwrap().contains("windows/x86_64"));
514 }
515
516 #[test]
517 fn a_green_build_that_skipped_a_target_is_degraded_not_ok() {
518 // The quieter half of the same bug: build status "ok" while a declared
519 // target never ran at all.
520 let a = app(
521 &["linux/x86_64", "macos/aarch64", "windows/x86_64"],
522 Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
523 );
524 let p = payload(&[a], now());
525
526 assert_eq!(node(&p, "app:goingson").status, Status::Degraded);
527 let c = node(&p, "app:goingson")
528 .conditions
529 .iter()
530 .find(|c| c.condition_type == "release_complete")
531 .unwrap();
532 assert_eq!(c.status, Status::Degraded);
533 let detail = c.detail.as_deref().unwrap();
534 assert!(detail.contains("1 of 3"), "{detail}");
535 assert!(detail.contains("never ran"), "{detail}");
536 }
537
538 #[test]
539 fn a_running_build_is_pending_and_claims_nothing_about_completeness() {
540 let a = app(
541 &["linux/x86_64", "macos/aarch64"],
542 Some(build(
543 "1.4.0",
544 "running",
545 vec![target_run("linux/x86_64", "running")],
546 )),
547 );
548 let p = payload(&[a], now());
549
550 let n = node(&p, "app:goingson");
551 assert_eq!(n.status, Status::Pending);
552 assert!(
553 !n.conditions
554 .iter()
555 .any(|c| c.condition_type == "release_complete"),
556 "a build still running is supposed to have targets outstanding"
557 );
558 }
559
560 #[test]
561 fn an_app_that_has_never_built_still_appears_and_can_be_built() {
562 let p = payload(&[app(&["linux/x86_64"], None)], now());
563
564 let n = node(&p, "app:goingson");
565 assert_eq!(n.status, Status::Pending);
566 assert_eq!(n.conditions[0].detail.as_deref(), Some("never built"));
567 assert_eq!(n.actions, vec!["build-goingson".to_string()]);
568 assert_eq!(
569 node(&p, "target:goingson:linux/x86_64").status,
570 Status::Pending
571 );
572 assert_eq!(p.validate(), Ok(()));
573 }
574
575 #[test]
576 fn actions_carry_the_body_the_route_requires() {
577 let a = app(
578 &["linux/x86_64"],
579 Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
580 );
581 let p = payload(&[a], now());
582
583 let build_action = &p.actions["build-goingson"];
584 assert_eq!(build_action.url, "/build");
585 assert_eq!(build_action.body, Some(json!({"app": "goingson"})));
586
587 let retry = &p.actions["retry-goingson-linux/x86_64"];
588 assert_eq!(retry.url, "/retry");
589 assert_eq!(
590 retry.body,
591 Some(json!({"app": "goingson", "target": "linux/x86_64", "version": "1.4.0"}))
592 );
593 }
594
595 #[test]
596 fn retry_is_not_offered_for_a_target_that_never_ran() {
597 let a = app(
598 &["linux/x86_64", "macos/aarch64"],
599 Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
600 );
601 let p = payload(&[a], now());
602
603 assert!(node(&p, "target:goingson:macos/aarch64").actions.is_empty());
604 assert!(!p.actions.contains_key("retry-goingson-macos/aarch64"));
605 assert_eq!(p.validate(), Ok(()));
606 }
607
608 #[test]
609 fn a_library_build_reads_as_dangerous() {
610 // A library build ends at a crates.io publish, which cannot be undone.
611 let mut a = app(&["linux/x86_64"], None);
612 a.kind = Kind::Library;
613 a.app = "pter".into();
614 let p = payload(&[a], now());
615 assert!(p.actions["build-pter"].danger);
616 }
617
618 #[test]
619 fn publish_is_silent_when_nothing_has_been_published() {
620 // Publishing is not wired for every app; a permanently red condition
621 // for a stage an app does not use teaches you to ignore the viewer.
622 let a = app(
623 &["linux/x86_64"],
624 Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
625 );
626 let p = payload(&[a], now());
627 assert!(
628 !node(&p, "app:goingson")
629 .conditions
630 .iter()
631 .any(|c| c.condition_type == "published")
632 );
633 }
634
635 #[test]
636 fn a_partial_publish_is_visible() {
637 let mut a = app(
638 &["linux/x86_64", "macos/aarch64"],
639 Some(build(
640 "1.4.0",
641 "ok",
642 vec![
643 target_run("linux/x86_64", "ok"),
644 target_run("macos/aarch64", "ok"),
645 ],
646 )),
647 );
648 a.published_targets = vec!["linux/x86_64".into()];
649 let p = payload(&[a], now());
650
651 let c = node(&p, "app:goingson")
652 .conditions
653 .iter()
654 .find(|c| c.condition_type == "published")
655 .unwrap();
656 assert_eq!(c.status, Status::Degraded);
657 assert!(c.detail.as_deref().unwrap().contains("1 of 2"));
658
659 assert!(
660 node(&p, "target:goingson:linux/x86_64")
661 .fields
662 .iter()
663 .any(|f| f.label == "published")
664 );
665 assert!(
666 !node(&p, "target:goingson:macos/aarch64")
667 .fields
668 .iter()
669 .any(|f| f.label == "published")
670 );
671 }
672
673 #[test]
674 fn one_apps_failure_cannot_be_hidden_by_another_apps_build() {
675 // This is why the read is per app: `/state` returns only the newest
676 // build row, so any later build masks an earlier half-failure.
677 let mut broken = app(
678 &["windows/x86_64"],
679 Some(build(
680 "1.4.0",
681 "failed",
682 vec![target_run("windows/x86_64", "failed")],
683 )),
684 );
685 broken.app = "audiofiles".into();
686
687 let healthy = app(
688 &["linux/x86_64"],
689 Some(build("2.0.0", "ok", vec![target_run("linux/x86_64", "ok")])),
690 );
691
692 let p = payload(&[broken, healthy], now());
693 assert_eq!(node(&p, "app:audiofiles").status, Status::Failed);
694 assert_eq!(node(&p, "app:goingson").status, Status::Ok);
695 assert_eq!(p.worst_status(), Status::Failed);
696 assert_eq!(p.validate(), Ok(()));
697 }
698
699 #[test]
700 fn a_target_dropped_from_the_manifest_still_shows_its_run() {
701 let a = app(
702 &["linux/x86_64"],
703 Some(build(
704 "1.4.0",
705 "ok",
706 vec![
707 target_run("linux/x86_64", "ok"),
708 target_run("ios/universal", "failed"),
709 ],
710 )),
711 );
712 let p = payload(&[a], now());
713 assert_eq!(
714 node(&p, "target:goingson:ios/universal").status,
715 Status::Failed
716 );
717 assert_eq!(p.validate(), Ok(()));
718 }
719
720 #[test]
721 fn an_unrecognized_run_status_is_unknown_not_fine() {
722 let a = app(
723 &["linux/x86_64"],
724 Some(build(
725 "1.4.0",
726 "sideways",
727 vec![target_run("linux/x86_64", "sideways")],
728 )),
729 );
730 let p = payload(&[a], now());
731 assert_eq!(
732 node(&p, "target:goingson:linux/x86_64").status,
733 Status::Unknown
734 );
735 assert_eq!(node(&p, "app:goingson").status, Status::Unknown);
736 }
737
738 #[test]
739 fn steps_become_conditions_on_their_target() {
740 let mut run = target_run("linux/x86_64", "failed");
741 run.steps = vec![step("build", "ok"), step("sign", "failed")];
742 run.error = Some("codesign: no identity found".into());
743 let a = app(&["linux/x86_64"], Some(build("1.4.0", "failed", vec![run])));
744 let p = payload(&[a], now());
745
746 let n = node(&p, "target:goingson:linux/x86_64");
747 assert_eq!(n.conditions[0].condition_type, "build");
748 assert_eq!(n.conditions[0].status, Status::Ok);
749 assert_eq!(n.conditions[1].condition_type, "sign");
750 assert_eq!(n.conditions[1].status, Status::Failed);
751 assert_eq!(
752 n.conditions[2].detail.as_deref(),
753 Some("codesign: no identity found")
754 );
755 }
756
757 #[test]
758 fn render_is_a_pure_function_of_state_and_clock() {
759 let make = || {
760 app(
761 &["linux/x86_64"],
762 Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
763 )
764 };
765 let a = payload(&[make()], now());
766 let b = payload(&[make()], now());
767 assert_eq!(
768 serde_json::to_value(&a).unwrap(),
769 serde_json::to_value(&b).unwrap()
770 );
771 }
772 }
773