Skip to main content

max / makenotwork

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