//! Bento's projection onto the shared operator status payload. //! //! Spec + rationale: maintainer wiki. //! //! //! `GET /status.json` serves this. [`payload`] is a pure function of //! `(apps, now)`, so a fixture renders identically forever and the mapping is //! testable without a database. //! //! # What maps to what //! //! | Bento | payload | //! |---|---| //! | app | node, `kind = "app"` | //! | target | node, `kind = "target"`, child of its app | //! | step | condition on its target | //! | build / retry | declared action, with its POST body | //! //! # Partial releases are the thing this exists to show //! //! Bento's targets are independent: a failed `windows/x86_64` neither stops nor //! rolls back the macOS build, and publishing is per target with no all-green //! gate. So "the release went out" and "the release went out on three of five //! platforms" look identical from `/state`, which reports only the newest build //! row across every app — one unrelated build is enough to hide another app's //! half-failure entirely. //! //! Two things here answer that. The read is per app, so nothing can be masked //! by a later build. And an app whose build finished without covering every //! declared target reports `degraded` with a `release_complete` condition //! naming what is missing, rather than the `ok` that a build-level status alone //! would imply. use std::fmt::Write as _; use chrono::{DateTime, Utc}; use ops_status::{Action, Condition, Field, Method, Node, Payload, Status, Value}; use serde_json::json; use crate::routes::{AppStatusView, BuildView, StepView}; use crate::topology::Kind; /// The `source` name Bento answers to in a viewer's config. pub const SOURCE: &str = "bento"; /// Restate every app's latest build as the shared payload. /// /// `now` is an argument rather than read from the clock so the mapping stays /// pure and snapshot-testable. pub(crate) fn payload(apps: &[AppStatusView], now: DateTime) -> Payload { let mut payload = Payload::new(SOURCE, now); for app in apps { let (key, action) = build_action(app); payload.actions.insert(key, action); payload.nodes.push(app_node(app)); for target in target_names(app) { if let Some((key, action)) = retry_action(app, &target) { payload.actions.insert(key, action); } payload.nodes.push(target_node(app, &target)); } } payload } // --------------------------------------------------------------------------- // Apps // --------------------------------------------------------------------------- fn app_id(app: &AppStatusView) -> String { format!("app:{}", app.app) } fn app_node(app: &AppStatusView) -> Node { let status = app_status(app); let mut fields = Vec::new(); let mut conditions = Vec::new(); match &app.build { Some(build) => { fields.push(Field::new( "version", Value::Version { value: build.version.clone(), }, )); if let Some(at) = parse_instant(&build.created_at) { fields.push(Field::new("started", Value::Instant { value: at })); } conditions.push(Condition { condition_type: "build".into(), status: run_status(&build.status), since: parse_instant(&build.created_at), detail: Some(format!("build {} {}", build.id, build.status)), }); if let Some(condition) = release_completeness(app, build) { conditions.push(condition); } if let Some(condition) = publish_completeness(app, build) { conditions.push(condition); } } None => conditions.push(Condition { condition_type: "build".into(), status: Status::Pending, since: None, detail: Some("never built".into()), }), } fields.push(Field::new( "kind", Value::Text { value: match app.kind { Kind::App => "app".into(), Kind::Library => "library".into(), }, }, )); Node { id: app_id(app), kind: "app".into(), label: app.app.clone(), status, fields, conditions, children: target_names(app) .iter() .map(|t| target_id(app, t)) .collect(), actions: vec![build_action_key(app)], } } fn app_status(app: &AppStatusView) -> Status { let Some(build) = &app.build else { return Status::Pending; }; if build.status == "failed" || build.targets.iter().any(|t| t.status == "failed") { return Status::Failed; } match run_status(&build.status) { Status::Pending => Status::Pending, // A build the runner called finished, that did not cover every declared // target, is a partial release wearing a green build status. Status::Ok if missing_targets(app, build).is_empty() => Status::Ok, Status::Ok => Status::Degraded, other => other, } } /// Declared targets with no run in the latest build. fn missing_targets(app: &AppStatusView, build: &BuildView) -> Vec { app.declared_targets .iter() .filter(|d| !build.targets.iter().any(|t| &&t.target == d)) .cloned() .collect() } /// "3 of 5 targets built" — emitted only once the build has settled, since a /// running build is *supposed* to have targets outstanding. fn release_completeness(app: &AppStatusView, build: &BuildView) -> Option { if matches!(run_status(&build.status), Status::Pending) { return None; } let missing = missing_targets(app, build); let failed: Vec<&str> = build .targets .iter() .filter(|t| t.status == "failed") .map(|t| t.target.as_str()) .collect(); if missing.is_empty() && failed.is_empty() { return None; } let mut detail = format!( "{} of {} declared targets built", app.declared_targets.len() - missing.len(), app.declared_targets.len() ); if !failed.is_empty() { write!(detail, "; failed: {}", failed.join(", ")).unwrap(); } if !missing.is_empty() { write!(detail, "; never ran: {}", missing.join(", ")).unwrap(); } Some(Condition { condition_type: "release_complete".into(), status: if failed.is_empty() { Status::Degraded } else { Status::Failed }, since: None, detail: Some(detail), }) } /// "2 of 5 targets published" — a partial publish is the same failure as a /// partial build, one stage later. /// /// Silent when nothing has been published for this version. Publishing is not /// wired for every app, and a condition that reads red for a stage an app does /// not use is how a viewer teaches you to stop reading it. fn publish_completeness(app: &AppStatusView, build: &BuildView) -> Option { if app.published_targets.is_empty() { return None; } let expected = app.declared_targets.len(); let published = app.published_targets.len(); Some(Condition { condition_type: "published".into(), status: if published >= expected { Status::Ok } else { Status::Degraded }, since: None, detail: Some(format!( "{published} of {expected} targets published at {}", build.version )), }) } // --------------------------------------------------------------------------- // Targets // --------------------------------------------------------------------------- /// Declared targets plus any the latest build ran that the manifest no longer /// lists, so a target dropped from `bento.toml` mid-release does not vanish /// from the surface while its run is still the newest thing that happened. fn target_names(app: &AppStatusView) -> Vec { let mut names = app.declared_targets.clone(); if let Some(build) = &app.build { for run in &build.targets { if !names.contains(&run.target) { names.push(run.target.clone()); } } } names } fn target_id(app: &AppStatusView, target: &str) -> String { format!("target:{}:{target}", app.app) } fn target_node(app: &AppStatusView, target: &str) -> Node { let run = app .build .as_ref() .and_then(|b| b.targets.iter().find(|t| t.target == target)); let status = match run { Some(run) => run_status(&run.status), // Declared but not run in the latest build. None => Status::Pending, }; let mut fields = Vec::new(); let mut conditions = Vec::new(); if let Some(run) = run { if let Some(build) = &app.build { fields.push(Field::new( "version", Value::Version { value: build.version.clone(), }, )); } if let Some(step) = &run.current_step { fields.push(Field::new( "step", Value::Text { value: step.clone(), }, )); } if app.published_targets.iter().any(|t| t == target) { fields.push(Field::new("published", Value::State { value: Status::Ok })); } conditions.extend(run.steps.iter().map(step_condition)); if let Some(error) = &run.error { conditions.push(Condition { condition_type: "error".into(), status: Status::Failed, since: None, detail: Some(error.clone()), }); } } else { conditions.push(Condition { condition_type: "build".into(), status: Status::Pending, since: None, detail: Some(match &app.build { Some(build) => format!("no run at {}", build.version), None => "never built".into(), }), }); } Node { id: target_id(app, target), kind: "target".into(), label: target.to_string(), status, fields, conditions, children: Vec::new(), actions: retry_action(app, target) .map(|(key, _)| vec![key]) .unwrap_or_default(), } } fn step_condition(step: &StepView) -> Condition { Condition { condition_type: step.step.clone(), status: run_status(&step.status), since: None, detail: Some(match step.status.as_str() { "running" => "running".into(), other => other.into(), }), } } // --------------------------------------------------------------------------- // Actions // --------------------------------------------------------------------------- /// `POST /build` and `POST /retry` take their arguments in the body, so the /// declared action carries one. The viewer posts it verbatim and still learns /// nothing about what an app or a target is. fn build_action_key(app: &AppStatusView) -> String { format!("build-{}", app.app) } fn build_action(app: &AppStatusView) -> (String, Action) { ( build_action_key(app), Action { label: format!("Build {}", app.app), method: Method::Post, url: "/build".into(), confirm: true, // A library build ends at a crates.io publish, which cannot be // taken back. An app build is recoverable. danger: matches!(app.kind, Kind::Library), body: Some(json!({ "app": app.app })), }, ) } /// Retry is offered only where there is a run to retry. fn retry_action(app: &AppStatusView, target: &str) -> Option<(String, Action)> { let build = app.build.as_ref()?; build.targets.iter().find(|t| t.target == target)?; Some(( format!("retry-{}-{target}", app.app), Action { label: format!("Retry {target}"), method: Method::Post, url: "/retry".into(), confirm: true, danger: matches!(app.kind, Kind::Library), body: Some(json!({ "app": app.app, "target": target, "version": build.version, })), }, )) } // --------------------------------------------------------------------------- /// `builds`, `target_runs` and `step_runs` share one status vocabulary: /// `pending | running | ok | failed`. Anything else is drift, and drift should /// be visible rather than smoothed into "fine". fn run_status(raw: &str) -> Status { match raw { "ok" => Status::Ok, "failed" => Status::Failed, "pending" | "running" => Status::Pending, _ => Status::Unknown, } } /// A timestamp that fails to parse costs only itself. A viewer that blanks on /// one malformed row is worse than one missing a tooltip. fn parse_instant(raw: &str) -> Option> { DateTime::parse_from_rfc3339(raw) .ok() .map(|d| d.with_timezone(&Utc)) } #[cfg(test)] mod tests { use super::*; use crate::routes::TargetView; fn now() -> DateTime { "2026-07-21T18:24:39Z".parse().unwrap() } fn step(name: &str, status: &str) -> StepView { StepView { run_id: 1, step: name.into(), status: status.into(), log_ref: None, } } fn target_run(target: &str, status: &str) -> TargetView { TargetView { target: target.into(), status: status.into(), current_step: None, error: None, steps: vec![step("build", status)], } } fn build(version: &str, status: &str, targets: Vec) -> BuildView { BuildView { id: 7, app: "goingson".into(), version: version.into(), status: status.into(), created_at: "2026-07-21T14:02:00Z".into(), targets, } } fn app(declared: &[&str], build: Option) -> AppStatusView { AppStatusView { app: "goingson".into(), kind: Kind::App, declared_targets: declared.iter().map(|s| (*s).to_string()).collect(), build, published_targets: Vec::new(), } } fn node<'a>(p: &'a Payload, id: &str) -> &'a Node { p.node(id).unwrap_or_else(|| panic!("no node {id}")) } #[test] fn a_fully_built_app_is_ok_and_structurally_sound() { let a = app( &["linux/x86_64", "macos/aarch64"], Some(build( "1.4.0", "ok", vec![ target_run("linux/x86_64", "ok"), target_run("macos/aarch64", "ok"), ], )), ); let p = payload(&[a], now()); assert_eq!(p.source, SOURCE); assert_eq!(p.schema, ops_status::SCHEMA_VERSION); assert_eq!(p.validate(), Ok(())); assert_eq!(node(&p, "app:goingson").status, Status::Ok); assert_eq!(node(&p, "target:goingson:linux/x86_64").status, Status::Ok); assert_eq!(p.worst_status(), Status::Ok); } #[test] fn a_failed_target_fails_the_app_and_names_itself() { // The audit's H2: a failed windows target neither stops nor rolls back // macOS, and nothing surfaced that the release went out partial. let a = app( &["linux/x86_64", "windows/x86_64"], Some(build( "1.4.0", "ok", vec![ target_run("linux/x86_64", "ok"), target_run("windows/x86_64", "failed"), ], )), ); let p = payload(&[a], now()); assert_eq!(node(&p, "app:goingson").status, Status::Failed); assert_eq!( node(&p, "target:goingson:windows/x86_64").status, Status::Failed ); assert_eq!(node(&p, "target:goingson:linux/x86_64").status, Status::Ok); let c = node(&p, "app:goingson") .conditions .iter() .find(|c| c.condition_type == "release_complete") .expect("a partial release must say so"); assert!(c.detail.as_deref().unwrap().contains("windows/x86_64")); } #[test] fn a_green_build_that_skipped_a_target_is_degraded_not_ok() { // The quieter half of the same bug: build status "ok" while a declared // target never ran at all. let a = app( &["linux/x86_64", "macos/aarch64", "windows/x86_64"], Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])), ); let p = payload(&[a], now()); assert_eq!(node(&p, "app:goingson").status, Status::Degraded); let c = node(&p, "app:goingson") .conditions .iter() .find(|c| c.condition_type == "release_complete") .unwrap(); assert_eq!(c.status, Status::Degraded); let detail = c.detail.as_deref().unwrap(); assert!(detail.contains("1 of 3"), "{detail}"); assert!(detail.contains("never ran"), "{detail}"); } #[test] fn a_running_build_is_pending_and_claims_nothing_about_completeness() { let a = app( &["linux/x86_64", "macos/aarch64"], Some(build( "1.4.0", "running", vec![target_run("linux/x86_64", "running")], )), ); let p = payload(&[a], now()); let n = node(&p, "app:goingson"); assert_eq!(n.status, Status::Pending); assert!( !n.conditions .iter() .any(|c| c.condition_type == "release_complete"), "a build still running is supposed to have targets outstanding" ); } #[test] fn an_app_that_has_never_built_still_appears_and_can_be_built() { let p = payload(&[app(&["linux/x86_64"], None)], now()); let n = node(&p, "app:goingson"); assert_eq!(n.status, Status::Pending); assert_eq!(n.conditions[0].detail.as_deref(), Some("never built")); assert_eq!(n.actions, vec!["build-goingson".to_string()]); assert_eq!( node(&p, "target:goingson:linux/x86_64").status, Status::Pending ); assert_eq!(p.validate(), Ok(())); } #[test] fn actions_carry_the_body_the_route_requires() { let a = app( &["linux/x86_64"], Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])), ); let p = payload(&[a], now()); let build_action = &p.actions["build-goingson"]; assert_eq!(build_action.url, "/build"); assert_eq!(build_action.body, Some(json!({"app": "goingson"}))); let retry = &p.actions["retry-goingson-linux/x86_64"]; assert_eq!(retry.url, "/retry"); assert_eq!( retry.body, Some(json!({"app": "goingson", "target": "linux/x86_64", "version": "1.4.0"})) ); } #[test] fn retry_is_not_offered_for_a_target_that_never_ran() { let a = app( &["linux/x86_64", "macos/aarch64"], Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])), ); let p = payload(&[a], now()); assert!(node(&p, "target:goingson:macos/aarch64").actions.is_empty()); assert!(!p.actions.contains_key("retry-goingson-macos/aarch64")); assert_eq!(p.validate(), Ok(())); } #[test] fn a_library_build_reads_as_dangerous() { // A library build ends at a crates.io publish, which cannot be undone. let mut a = app(&["linux/x86_64"], None); a.kind = Kind::Library; a.app = "pter".into(); let p = payload(&[a], now()); assert!(p.actions["build-pter"].danger); } #[test] fn publish_is_silent_when_nothing_has_been_published() { // Publishing is not wired for every app; a permanently red condition // for a stage an app does not use teaches you to ignore the viewer. let a = app( &["linux/x86_64"], Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])), ); let p = payload(&[a], now()); assert!( !node(&p, "app:goingson") .conditions .iter() .any(|c| c.condition_type == "published") ); } #[test] fn a_partial_publish_is_visible() { let mut a = app( &["linux/x86_64", "macos/aarch64"], Some(build( "1.4.0", "ok", vec![ target_run("linux/x86_64", "ok"), target_run("macos/aarch64", "ok"), ], )), ); a.published_targets = vec!["linux/x86_64".into()]; let p = payload(&[a], now()); let c = node(&p, "app:goingson") .conditions .iter() .find(|c| c.condition_type == "published") .unwrap(); assert_eq!(c.status, Status::Degraded); assert!(c.detail.as_deref().unwrap().contains("1 of 2")); assert!( node(&p, "target:goingson:linux/x86_64") .fields .iter() .any(|f| f.label == "published") ); assert!( !node(&p, "target:goingson:macos/aarch64") .fields .iter() .any(|f| f.label == "published") ); } #[test] fn one_apps_failure_cannot_be_hidden_by_another_apps_build() { // This is why the read is per app: `/state` returns only the newest // build row, so any later build masks an earlier half-failure. let mut broken = app( &["windows/x86_64"], Some(build( "1.4.0", "failed", vec![target_run("windows/x86_64", "failed")], )), ); broken.app = "audiofiles".into(); let healthy = app( &["linux/x86_64"], Some(build("2.0.0", "ok", vec![target_run("linux/x86_64", "ok")])), ); let p = payload(&[broken, healthy], now()); assert_eq!(node(&p, "app:audiofiles").status, Status::Failed); assert_eq!(node(&p, "app:goingson").status, Status::Ok); assert_eq!(p.worst_status(), Status::Failed); assert_eq!(p.validate(), Ok(())); } #[test] fn a_target_dropped_from_the_manifest_still_shows_its_run() { let a = app( &["linux/x86_64"], Some(build( "1.4.0", "ok", vec![ target_run("linux/x86_64", "ok"), target_run("ios/universal", "failed"), ], )), ); let p = payload(&[a], now()); assert_eq!( node(&p, "target:goingson:ios/universal").status, Status::Failed ); assert_eq!(p.validate(), Ok(())); } #[test] fn an_unrecognized_run_status_is_unknown_not_fine() { let a = app( &["linux/x86_64"], Some(build( "1.4.0", "sideways", vec![target_run("linux/x86_64", "sideways")], )), ); let p = payload(&[a], now()); assert_eq!( node(&p, "target:goingson:linux/x86_64").status, Status::Unknown ); assert_eq!(node(&p, "app:goingson").status, Status::Unknown); } #[test] fn steps_become_conditions_on_their_target() { let mut run = target_run("linux/x86_64", "failed"); run.steps = vec![step("build", "ok"), step("sign", "failed")]; run.error = Some("codesign: no identity found".into()); let a = app(&["linux/x86_64"], Some(build("1.4.0", "failed", vec![run]))); let p = payload(&[a], now()); let n = node(&p, "target:goingson:linux/x86_64"); assert_eq!(n.conditions[0].condition_type, "build"); assert_eq!(n.conditions[0].status, Status::Ok); assert_eq!(n.conditions[1].condition_type, "sign"); assert_eq!(n.conditions[1].status, Status::Failed); assert_eq!( n.conditions[2].detail.as_deref(), Some("codesign: no identity found") ); } #[test] fn render_is_a_pure_function_of_state_and_clock() { let make = || { app( &["linux/x86_64"], Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])), ) }; let a = payload(&[make()], now()); let b = payload(&[make()], now()); assert_eq!( serde_json::to_value(&a).unwrap(), serde_json::to_value(&b).unwrap() ); } }