Skip to main content

max / makenotwork

31.7 KB · 966 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 if let Some(condition) = distributable(app, build) {
106 conditions.push(condition);
107 }
108 }
109 None => conditions.push(Condition {
110 condition_type: "build".into(),
111 status: Status::Pending,
112 since: None,
113 detail: Some("never built".into()),
114 }),
115 }
116
117 fields.push(Field::new(
118 "kind",
119 Value::Text {
120 value: match app.kind {
121 Kind::App => "app".into(),
122 Kind::Library => "library".into(),
123 Kind::Service => "service".into(),
124 },
125 },
126 ));
127
128 Node {
129 id: app_id(app),
130 kind: "app".into(),
131 label: app.app.clone(),
132 status,
133 fields,
134 conditions,
135 children: target_names(app)
136 .iter()
137 .map(|t| target_id(app, t))
138 .collect(),
139 actions: vec![build_action_key(app)],
140 }
141 }
142
143 fn app_status(app: &AppStatusView) -> Status {
144 let Some(build) = &app.build else {
145 return Status::Pending;
146 };
147 if build.status == "failed" || build.targets.iter().any(|t| t.status == "failed") {
148 return Status::Failed;
149 }
150 match run_status(&build.status) {
151 Status::Pending => Status::Pending,
152 // A build the runner called finished, that did not cover every declared
153 // target, is a partial release wearing a green build status.
154 Status::Ok if !missing_targets(app, build).is_empty() => Status::Degraded,
155 // Every target built — but a release nobody can download is not a
156 // release. Green is reserved for what has actually reached the world.
157 Status::Ok => match &app.distribution {
158 Some(dist) if dist.error.is_some() => Status::Unknown,
159 Some(dist) if !dist.complete() => Status::Undistributed,
160 _ => Status::Ok,
161 },
162 other => other,
163 }
164 }
165
166 /// Declared targets with no run in the latest build.
167 fn missing_targets(app: &AppStatusView, build: &BuildView) -> Vec<String> {
168 app.declared_targets
169 .iter()
170 .filter(|d| !build.targets.iter().any(|t| &&t.target == d))
171 .cloned()
172 .collect()
173 }
174
175 /// "3 of 5 targets built" — emitted only once the build has settled, since a
176 /// running build is *supposed* to have targets outstanding.
177 fn release_completeness(app: &AppStatusView, build: &BuildView) -> Option<Condition> {
178 if matches!(run_status(&build.status), Status::Pending) {
179 return None;
180 }
181 let missing = missing_targets(app, build);
182 let failed: Vec<&str> = build
183 .targets
184 .iter()
185 .filter(|t| t.status == "failed")
186 .map(|t| t.target.as_str())
187 .collect();
188 if missing.is_empty() && failed.is_empty() {
189 return None;
190 }
191
192 let mut detail = format!(
193 "{} of {} declared targets built",
194 app.declared_targets.len() - missing.len(),
195 app.declared_targets.len()
196 );
197 if !failed.is_empty() {
198 write!(detail, "; failed: {}", failed.join(", ")).unwrap();
199 }
200 if !missing.is_empty() {
201 write!(detail, "; never ran: {}", missing.join(", ")).unwrap();
202 }
203
204 Some(Condition {
205 condition_type: "release_complete".into(),
206 status: if failed.is_empty() {
207 Status::Degraded
208 } else {
209 Status::Failed
210 },
211 since: None,
212 detail: Some(detail),
213 })
214 }
215
216 /// "2 of 5 targets published" — a partial publish is the same failure as a
217 /// partial build, one stage later.
218 ///
219 /// Silent when nothing has been published for this version. Publishing is not
220 /// wired for every app, and a condition that reads red for a stage an app does
221 /// not use is how a viewer teaches you to stop reading it.
222 fn publish_completeness(app: &AppStatusView, build: &BuildView) -> Option<Condition> {
223 if app.published_targets.is_empty() {
224 return None;
225 }
226 let expected = app.declared_targets.len();
227 let published = app.published_targets.len();
228 Some(Condition {
229 condition_type: "published".into(),
230 status: if published >= expected {
231 Status::Ok
232 } else {
233 Status::Degraded
234 },
235 since: None,
236 detail: Some(format!(
237 "{published} of {expected} targets published at {}",
238 build.version
239 )),
240 })
241 }
242
243 /// "downloadable from makenot.work at 1.4.0", or what is not.
244 ///
245 /// Silent for anything with no MNW distribution question to answer — the
246 /// libraries and services, which [`crate::routes::status_view`] leaves as
247 /// `None`. Silent too when every declared target is mobile, since TestFlight
248 /// and Play are not MNW's to report on and a permanently unanswerable condition
249 /// is the kind that teaches an operator to stop reading the board.
250 fn distributable(app: &AppStatusView, build: &BuildView) -> Option<Condition> {
251 let dist = app.distribution.as_ref()?;
252 if matches!(run_status(&build.status), Status::Pending) {
253 return None;
254 }
255 if let Some(error) = &dist.error {
256 return Some(Condition {
257 condition_type: "distributable".into(),
258 status: Status::Unknown,
259 since: None,
260 detail: Some(format!("could not reach MNW: {error}")),
261 });
262 }
263 let checked = dist.fetchable.len() + dist.missing.len();
264 if checked == 0 {
265 return None;
266 }
267 Some(Condition {
268 condition_type: "distributable".into(),
269 status: if dist.missing.is_empty() {
270 Status::Ok
271 } else {
272 Status::Undistributed
273 },
274 since: None,
275 detail: Some(if dist.missing.is_empty() {
276 format!(
277 "{} of {checked} fetchable from MNW at {}",
278 dist.fetchable.len(),
279 build.version
280 )
281 } else {
282 format!(
283 "{} of {checked} fetchable from MNW at {}; not published: {}",
284 dist.fetchable.len(),
285 build.version,
286 dist.missing.join(", ")
287 )
288 }),
289 })
290 }
291
292 // ---------------------------------------------------------------------------
293 // Targets
294 // ---------------------------------------------------------------------------
295
296 /// Declared targets plus any the latest build ran that the manifest no longer
297 /// lists, so a target dropped from `bento.toml` mid-release does not vanish
298 /// from the surface while its run is still the newest thing that happened.
299 fn target_names(app: &AppStatusView) -> Vec<String> {
300 let mut names = app.declared_targets.clone();
301 if let Some(build) = &app.build {
302 for run in &build.targets {
303 if !names.contains(&run.target) {
304 names.push(run.target.clone());
305 }
306 }
307 }
308 names
309 }
310
311 fn target_id(app: &AppStatusView, target: &str) -> String {
312 format!("target:{}:{target}", app.app)
313 }
314
315 fn target_node(app: &AppStatusView, target: &str) -> Node {
316 let run = app
317 .build
318 .as_ref()
319 .and_then(|b| b.targets.iter().find(|t| t.target == target));
320
321 let status = match run {
322 Some(run) => run_status(&run.status),
323 // Declared but not run in the latest build.
324 None => Status::Pending,
325 };
326
327 let mut fields = Vec::new();
328 let mut conditions = Vec::new();
329
330 if let Some(run) = run {
331 if let Some(build) = &app.build {
332 fields.push(Field::new(
333 "version",
334 Value::Version {
335 value: build.version.clone(),
336 },
337 ));
338 }
339 if let Some(step) = &run.current_step {
340 fields.push(Field::new(
341 "step",
342 Value::Text {
343 value: step.clone(),
344 },
345 ));
346 }
347 if app.published_targets.iter().any(|t| t == target) {
348 fields.push(Field::new("published", Value::State { value: Status::Ok }));
349 }
350 conditions.extend(run.steps.iter().map(step_condition));
351 if let Some(error) = &run.error {
352 conditions.push(Condition {
353 condition_type: "error".into(),
354 status: Status::Failed,
355 since: None,
356 detail: Some(error.clone()),
357 });
358 }
359 } else {
360 conditions.push(Condition {
361 condition_type: "build".into(),
362 status: Status::Pending,
363 since: None,
364 detail: Some(match &app.build {
365 Some(build) => format!("no run at {}", build.version),
366 None => "never built".into(),
367 }),
368 });
369 }
370
371 Node {
372 id: target_id(app, target),
373 kind: "target".into(),
374 label: target.to_string(),
375 status,
376 fields,
377 conditions,
378 children: Vec::new(),
379 actions: retry_action(app, target)
380 .map(|(key, _)| vec![key])
381 .unwrap_or_default(),
382 }
383 }
384
385 fn step_condition(step: &StepView) -> Condition {
386 Condition {
387 condition_type: step.step.clone(),
388 status: run_status(&step.status),
389 since: None,
390 detail: Some(match step.status.as_str() {
391 "running" => "running".into(),
392 other => other.into(),
393 }),
394 }
395 }
396
397 // ---------------------------------------------------------------------------
398 // Actions
399 // ---------------------------------------------------------------------------
400
401 /// `POST /build` and `POST /retry` take their arguments in the body, so the
402 /// declared action carries one. The viewer posts it verbatim and still learns
403 /// nothing about what an app or a target is.
404 fn build_action_key(app: &AppStatusView) -> String {
405 format!("build-{}", app.app)
406 }
407
408 fn build_action(app: &AppStatusView) -> (String, Action) {
409 (
410 build_action_key(app),
411 Action {
412 label: format!("Build {}", app.app),
413 method: Method::Post,
414 url: "/build".into(),
415 confirm: true,
416 // A library build ends at a crates.io publish, which cannot be
417 // taken back. An app build is recoverable.
418 danger: matches!(app.kind, Kind::Library),
419 body: Some(json!({ "app": app.app })),
420 },
421 )
422 }
423
424 /// Retry is offered only where there is a run to retry.
425 fn retry_action(app: &AppStatusView, target: &str) -> Option<(String, Action)> {
426 let build = app.build.as_ref()?;
427 build.targets.iter().find(|t| t.target == target)?;
428 Some((
429 format!("retry-{}-{target}", app.app),
430 Action {
431 label: format!("Retry {target}"),
432 method: Method::Post,
433 url: "/retry".into(),
434 confirm: true,
435 danger: matches!(app.kind, Kind::Library),
436 body: Some(json!({
437 "app": app.app,
438 "target": target,
439 "version": build.version,
440 })),
441 },
442 ))
443 }
444
445 // ---------------------------------------------------------------------------
446
447 /// `builds`, `target_runs` and `step_runs` share one status vocabulary:
448 /// `pending | running | ok | failed`. Anything else is drift, and drift should
449 /// be visible rather than smoothed into "fine".
450 fn run_status(raw: &str) -> Status {
451 match raw {
452 "ok" => Status::Ok,
453 "failed" => Status::Failed,
454 "pending" | "running" => Status::Pending,
455 _ => Status::Unknown,
456 }
457 }
458
459 /// A timestamp that fails to parse costs only itself. A viewer that blanks on
460 /// one malformed row is worse than one missing a tooltip.
461 fn parse_instant(raw: &str) -> Option<DateTime<Utc>> {
462 DateTime::parse_from_rfc3339(raw)
463 .ok()
464 .map(|d| d.with_timezone(&Utc))
465 }
466
467 #[cfg(test)]
468 mod tests {
469 use super::*;
470 use crate::routes::TargetView;
471
472 fn now() -> DateTime<Utc> {
473 "2026-07-21T18:24:39Z".parse().unwrap()
474 }
475
476 fn step(name: &str, status: &str) -> StepView {
477 StepView {
478 run_id: 1,
479 step: name.into(),
480 status: status.into(),
481 log_ref: None,
482 }
483 }
484
485 fn target_run(target: &str, status: &str) -> TargetView {
486 TargetView {
487 target: target.into(),
488 status: status.into(),
489 current_step: None,
490 error: None,
491 steps: vec![step("build", status)],
492 }
493 }
494
495 fn build(version: &str, status: &str, targets: Vec<TargetView>) -> BuildView {
496 BuildView {
497 id: 7,
498 app: "goingson".into(),
499 version: version.into(),
500 status: status.into(),
501 created_at: "2026-07-21T14:02:00Z".into(),
502 targets,
503 }
504 }
505
506 fn app(declared: &[&str], build: Option<BuildView>) -> AppStatusView {
507 AppStatusView {
508 app: "goingson".into(),
509 kind: Kind::App,
510 declared_targets: declared.iter().map(|s| (*s).to_string()).collect(),
511 build,
512 published_targets: Vec::new(),
513 distribution: None,
514 }
515 }
516
517 fn node<'a>(p: &'a Payload, id: &str) -> &'a Node {
518 p.node(id).unwrap_or_else(|| panic!("no node {id}"))
519 }
520
521 #[test]
522 fn a_fully_built_app_is_ok_and_structurally_sound() {
523 let a = app(
524 &["linux/x86_64", "macos/aarch64"],
525 Some(build(
526 "1.4.0",
527 "ok",
528 vec![
529 target_run("linux/x86_64", "ok"),
530 target_run("macos/aarch64", "ok"),
531 ],
532 )),
533 );
534 let p = payload(&[a], now());
535
536 assert_eq!(p.source, SOURCE);
537 assert_eq!(p.schema, ops_status::SCHEMA_VERSION);
538 assert_eq!(p.validate(), Ok(()));
539 assert_eq!(node(&p, "app:goingson").status, Status::Ok);
540 assert_eq!(node(&p, "target:goingson:linux/x86_64").status, Status::Ok);
541 assert_eq!(p.worst_status(), Status::Ok);
542 }
543
544 #[test]
545 fn a_failed_target_fails_the_app_and_names_itself() {
546 // The audit's H2: a failed windows target neither stops nor rolls back
547 // macOS, and nothing surfaced that the release went out partial.
548 let a = app(
549 &["linux/x86_64", "windows/x86_64"],
550 Some(build(
551 "1.4.0",
552 "ok",
553 vec![
554 target_run("linux/x86_64", "ok"),
555 target_run("windows/x86_64", "failed"),
556 ],
557 )),
558 );
559 let p = payload(&[a], now());
560
561 assert_eq!(node(&p, "app:goingson").status, Status::Failed);
562 assert_eq!(
563 node(&p, "target:goingson:windows/x86_64").status,
564 Status::Failed
565 );
566 assert_eq!(node(&p, "target:goingson:linux/x86_64").status, Status::Ok);
567
568 let c = node(&p, "app:goingson")
569 .conditions
570 .iter()
571 .find(|c| c.condition_type == "release_complete")
572 .expect("a partial release must say so");
573 assert!(c.detail.as_deref().unwrap().contains("windows/x86_64"));
574 }
575
576 #[test]
577 fn a_green_build_that_skipped_a_target_is_degraded_not_ok() {
578 // The quieter half of the same bug: build status "ok" while a declared
579 // target never ran at all.
580 let a = app(
581 &["linux/x86_64", "macos/aarch64", "windows/x86_64"],
582 Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
583 );
584 let p = payload(&[a], now());
585
586 assert_eq!(node(&p, "app:goingson").status, Status::Degraded);
587 let c = node(&p, "app:goingson")
588 .conditions
589 .iter()
590 .find(|c| c.condition_type == "release_complete")
591 .unwrap();
592 assert_eq!(c.status, Status::Degraded);
593 let detail = c.detail.as_deref().unwrap();
594 assert!(detail.contains("1 of 3"), "{detail}");
595 assert!(detail.contains("never ran"), "{detail}");
596 }
597
598 #[test]
599 fn a_running_build_is_pending_and_claims_nothing_about_completeness() {
600 let a = app(
601 &["linux/x86_64", "macos/aarch64"],
602 Some(build(
603 "1.4.0",
604 "running",
605 vec![target_run("linux/x86_64", "running")],
606 )),
607 );
608 let p = payload(&[a], now());
609
610 let n = node(&p, "app:goingson");
611 assert_eq!(n.status, Status::Pending);
612 assert!(
613 !n.conditions
614 .iter()
615 .any(|c| c.condition_type == "release_complete"),
616 "a build still running is supposed to have targets outstanding"
617 );
618 }
619
620 #[test]
621 fn an_app_that_has_never_built_still_appears_and_can_be_built() {
622 let p = payload(&[app(&["linux/x86_64"], None)], now());
623
624 let n = node(&p, "app:goingson");
625 assert_eq!(n.status, Status::Pending);
626 assert_eq!(n.conditions[0].detail.as_deref(), Some("never built"));
627 assert_eq!(n.actions, vec!["build-goingson".to_string()]);
628 assert_eq!(
629 node(&p, "target:goingson:linux/x86_64").status,
630 Status::Pending
631 );
632 assert_eq!(p.validate(), Ok(()));
633 }
634
635 #[test]
636 fn actions_carry_the_body_the_route_requires() {
637 let a = app(
638 &["linux/x86_64"],
639 Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
640 );
641 let p = payload(&[a], now());
642
643 let build_action = &p.actions["build-goingson"];
644 assert_eq!(build_action.url, "/build");
645 assert_eq!(build_action.body, Some(json!({"app": "goingson"})));
646
647 let retry = &p.actions["retry-goingson-linux/x86_64"];
648 assert_eq!(retry.url, "/retry");
649 assert_eq!(
650 retry.body,
651 Some(json!({"app": "goingson", "target": "linux/x86_64", "version": "1.4.0"}))
652 );
653 }
654
655 #[test]
656 fn retry_is_not_offered_for_a_target_that_never_ran() {
657 let a = app(
658 &["linux/x86_64", "macos/aarch64"],
659 Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
660 );
661 let p = payload(&[a], now());
662
663 assert!(node(&p, "target:goingson:macos/aarch64").actions.is_empty());
664 assert!(!p.actions.contains_key("retry-goingson-macos/aarch64"));
665 assert_eq!(p.validate(), Ok(()));
666 }
667
668 #[test]
669 fn a_library_build_reads_as_dangerous() {
670 // A library build ends at a crates.io publish, which cannot be undone.
671 let mut a = app(&["linux/x86_64"], None);
672 a.kind = Kind::Library;
673 a.app = "pter".into();
674 let p = payload(&[a], now());
675 assert!(p.actions["build-pter"].danger);
676 }
677
678 #[test]
679 fn publish_is_silent_when_nothing_has_been_published() {
680 // Publishing is not wired for every app; a permanently red condition
681 // for a stage an app does not use teaches you to ignore the viewer.
682 let a = app(
683 &["linux/x86_64"],
684 Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
685 );
686 let p = payload(&[a], now());
687 assert!(
688 !node(&p, "app:goingson")
689 .conditions
690 .iter()
691 .any(|c| c.condition_type == "published")
692 );
693 }
694
695 #[test]
696 fn a_partial_publish_is_visible() {
697 let mut a = app(
698 &["linux/x86_64", "macos/aarch64"],
699 Some(build(
700 "1.4.0",
701 "ok",
702 vec![
703 target_run("linux/x86_64", "ok"),
704 target_run("macos/aarch64", "ok"),
705 ],
706 )),
707 );
708 a.published_targets = vec!["linux/x86_64".into()];
709 let p = payload(&[a], now());
710
711 let c = node(&p, "app:goingson")
712 .conditions
713 .iter()
714 .find(|c| c.condition_type == "published")
715 .unwrap();
716 assert_eq!(c.status, Status::Degraded);
717 assert!(c.detail.as_deref().unwrap().contains("1 of 2"));
718
719 assert!(
720 node(&p, "target:goingson:linux/x86_64")
721 .fields
722 .iter()
723 .any(|f| f.label == "published")
724 );
725 assert!(
726 !node(&p, "target:goingson:macos/aarch64")
727 .fields
728 .iter()
729 .any(|f| f.label == "published")
730 );
731 }
732
733 #[test]
734 fn one_apps_failure_cannot_be_hidden_by_another_apps_build() {
735 // This is why the read is per app: `/state` returns only the newest
736 // build row, so any later build masks an earlier half-failure.
737 let mut broken = app(
738 &["windows/x86_64"],
739 Some(build(
740 "1.4.0",
741 "failed",
742 vec![target_run("windows/x86_64", "failed")],
743 )),
744 );
745 broken.app = "audiofiles".into();
746
747 let healthy = app(
748 &["linux/x86_64"],
749 Some(build("2.0.0", "ok", vec![target_run("linux/x86_64", "ok")])),
750 );
751
752 let p = payload(&[broken, healthy], now());
753 assert_eq!(node(&p, "app:audiofiles").status, Status::Failed);
754 assert_eq!(node(&p, "app:goingson").status, Status::Ok);
755 assert_eq!(p.worst_status(), Status::Failed);
756 assert_eq!(p.validate(), Ok(()));
757 }
758
759 #[test]
760 fn a_target_dropped_from_the_manifest_still_shows_its_run() {
761 let a = app(
762 &["linux/x86_64"],
763 Some(build(
764 "1.4.0",
765 "ok",
766 vec![
767 target_run("linux/x86_64", "ok"),
768 target_run("ios/universal", "failed"),
769 ],
770 )),
771 );
772 let p = payload(&[a], now());
773 assert_eq!(
774 node(&p, "target:goingson:ios/universal").status,
775 Status::Failed
776 );
777 assert_eq!(p.validate(), Ok(()));
778 }
779
780 #[test]
781 fn an_unrecognized_run_status_is_unknown_not_fine() {
782 let a = app(
783 &["linux/x86_64"],
784 Some(build(
785 "1.4.0",
786 "sideways",
787 vec![target_run("linux/x86_64", "sideways")],
788 )),
789 );
790 let p = payload(&[a], now());
791 assert_eq!(
792 node(&p, "target:goingson:linux/x86_64").status,
793 Status::Unknown
794 );
795 assert_eq!(node(&p, "app:goingson").status, Status::Unknown);
796 }
797
798 #[test]
799 fn steps_become_conditions_on_their_target() {
800 let mut run = target_run("linux/x86_64", "failed");
801 run.steps = vec![step("build", "ok"), step("sign", "failed")];
802 run.error = Some("codesign: no identity found".into());
803 let a = app(&["linux/x86_64"], Some(build("1.4.0", "failed", vec![run])));
804 let p = payload(&[a], now());
805
806 let n = node(&p, "target:goingson:linux/x86_64");
807 assert_eq!(n.conditions[0].condition_type, "build");
808 assert_eq!(n.conditions[0].status, Status::Ok);
809 assert_eq!(n.conditions[1].condition_type, "sign");
810 assert_eq!(n.conditions[1].status, Status::Failed);
811 assert_eq!(
812 n.conditions[2].detail.as_deref(),
813 Some("codesign: no identity found")
814 );
815 }
816
817 fn dist(fetchable: &[&str], missing: &[&str]) -> crate::ota::Distribution {
818 crate::ota::Distribution {
819 fetchable: fetchable.iter().map(|s| (*s).to_string()).collect(),
820 missing: missing.iter().map(|s| (*s).to_string()).collect(),
821 error: None,
822 }
823 }
824
825 #[test]
826 fn a_green_build_nobody_can_download_is_not_ok() {
827 // The whole point: every target built and published, but MNW serves
828 // nothing, so the release has not actually reached anyone.
829 let mut a = app(
830 &["linux/x86_64", "macos/aarch64"],
831 Some(build(
832 "1.4.0",
833 "ok",
834 vec![
835 target_run("linux/x86_64", "ok"),
836 target_run("macos/aarch64", "ok"),
837 ],
838 )),
839 );
840 a.distribution = Some(dist(&["linux/x86_64"], &["macos/aarch64"]));
841 let p = payload(&[a], now());
842
843 assert_eq!(node(&p, "app:goingson").status, Status::Undistributed);
844 let c = node(&p, "app:goingson")
845 .conditions
846 .iter()
847 .find(|c| c.condition_type == "distributable")
848 .expect("an undistributed release must say so");
849 assert_eq!(c.status, Status::Undistributed);
850 let detail = c.detail.as_deref().unwrap();
851 assert!(detail.contains("1 of 2"), "{detail}");
852 assert!(detail.contains("macos/aarch64"), "{detail}");
853 assert_eq!(p.validate(), Ok(()));
854 }
855
856 #[test]
857 fn everything_fetchable_is_finally_ok() {
858 let mut a = app(
859 &["linux/x86_64"],
860 Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
861 );
862 a.distribution = Some(dist(&["linux/x86_64"], &[]));
863 let p = payload(&[a], now());
864
865 assert_eq!(node(&p, "app:goingson").status, Status::Ok);
866 assert_eq!(p.worst_status(), Status::Ok);
867 }
868
869 #[test]
870 fn an_unreachable_mnw_is_unknown_rather_than_green_or_red() {
871 // Not knowing whether a release is downloadable is its own state. The
872 // silent gap is the failure mode this contract exists to close, so it
873 // must not be smoothed into either "fine" or "not published".
874 let mut a = app(
875 &["linux/x86_64"],
876 Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
877 );
878 a.distribution = Some(crate::ota::Distribution {
879 error: Some("connection refused".into()),
880 ..Default::default()
881 });
882 let p = payload(&[a], now());
883
884 assert_eq!(node(&p, "app:goingson").status, Status::Unknown);
885 let c = node(&p, "app:goingson")
886 .conditions
887 .iter()
888 .find(|c| c.condition_type == "distributable")
889 .unwrap();
890 assert_eq!(c.status, Status::Unknown);
891 }
892
893 #[test]
894 fn a_library_is_never_judged_against_mnw() {
895 // Libraries go to crates.io; probing MNW for one would invent a red
896 // that means nothing. status_view leaves their distribution None.
897 let mut a = app(
898 &["linux/x86_64"],
899 Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
900 );
901 a.kind = Kind::Library;
902 a.app = "pter".into();
903 let p = payload(&[a], now());
904
905 assert_eq!(node(&p, "app:pter").status, Status::Ok);
906 assert!(
907 !node(&p, "app:pter")
908 .conditions
909 .iter()
910 .any(|c| c.condition_type == "distributable")
911 );
912 }
913
914 #[test]
915 fn a_partial_build_outranks_its_distribution_gap() {
916 // A release that never finished building is degraded on that ground;
917 // reporting it as merely undistributed would understate it.
918 let mut a = app(
919 &["linux/x86_64", "windows/x86_64"],
920 Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
921 );
922 a.distribution = Some(dist(&[], &["linux/x86_64"]));
923 let p = payload(&[a], now());
924 assert_eq!(node(&p, "app:goingson").status, Status::Degraded);
925 }
926
927 #[test]
928 fn mobile_only_targets_raise_no_distribution_question() {
929 // iOS rides TestFlight, which MNW does not host and cannot report on.
930 let mut a = app(
931 &["ios/universal"],
932 Some(build(
933 "1.4.0",
934 "ok",
935 vec![target_run("ios/universal", "ok")],
936 )),
937 );
938 a.distribution = Some(crate::ota::Distribution::default());
939 let p = payload(&[a], now());
940
941 assert_eq!(node(&p, "app:goingson").status, Status::Ok);
942 assert!(
943 !node(&p, "app:goingson")
944 .conditions
945 .iter()
946 .any(|c| c.condition_type == "distributable")
947 );
948 }
949
950 #[test]
951 fn render_is_a_pure_function_of_state_and_clock() {
952 let make = || {
953 app(
954 &["linux/x86_64"],
955 Some(build("1.4.0", "ok", vec![target_run("linux/x86_64", "ok")])),
956 )
957 };
958 let a = payload(&[make()], now());
959 let b = payload(&[make()], now());
960 assert_eq!(
961 serde_json::to_value(&a).unwrap(),
962 serde_json::to_value(&b).unwrap()
963 );
964 }
965 }
966