Skip to main content

max / makenotwork

24.7 KB · 589 lines History Blame Raw
1 //! Typed gate outcomes.
2 //!
3 //! Replaces the `(passed: bool, detail: Option<String>)` pair on
4 //! `GateOutcome`. The point is to push failure classification into the
5 //! type itself: a `GateFailure::MigrationDrift { migration }` is what it
6 //! says, not a string the operator has to parse. See
7 //! `plans/observability.md` for the full argument.
8 //!
9 //! The variants here describe what the gate runner actually observed.
10 //! Mapping raw process output (stderr tails, exit codes) to these
11 //! variants is the classifier's job — `classify.rs`.
12
13 use crate::domain::{GateKind, Version};
14 use chrono::{DateTime, Utc};
15 use serde::{Deserialize, Serialize};
16
17 /// A gate's result, persisted to `gate_runs.outcome_json` and emitted
18 /// over WS in `GateDone`.
19 #[derive(Debug, Clone, Serialize, Deserialize)]
20 pub struct GateOutcome {
21 pub status: GateStatus,
22 /// Relative path under `cfg.logs_root` to the persisted stdout/stderr
23 /// for this run. `None` for gates that don't produce process output
24 /// (burn_in, manual_confirm).
25 #[serde(skip_serializing_if = "Option::is_none", default)]
26 pub log_ref: Option<LogRef>,
27 }
28
29 impl GateOutcome {
30 pub fn passed(note: PassNote) -> Self {
31 Self {
32 status: GateStatus::Passed { note },
33 log_ref: None,
34 }
35 }
36 pub fn failed(failure: GateFailure) -> Self {
37 Self {
38 status: GateStatus::Failed { failure },
39 log_ref: None,
40 }
41 }
42 pub fn blocked(blocker: GateBlocker) -> Self {
43 Self {
44 status: GateStatus::Blocked { blocker },
45 log_ref: None,
46 }
47 }
48 #[must_use]
49 pub fn with_log_ref(mut self, log_ref: LogRef) -> Self {
50 self.log_ref = Some(log_ref);
51 self
52 }
53
54 /// True iff the gate ran and succeeded. `Blocked` is not passing:
55 /// the gate has not satisfied the pipeline, the operator just owes
56 /// it a precondition.
57 pub fn is_passed(&self) -> bool {
58 matches!(self.status, GateStatus::Passed { .. })
59 }
60
61 /// The high-level status word for the `gate_runs.status` column.
62 pub fn status_str(&self) -> &'static str {
63 match self.status {
64 GateStatus::Passed { .. } => "passed",
65 GateStatus::Failed { .. } => "failed",
66 GateStatus::Blocked { .. } => "blocked",
67 }
68 }
69 }
70
71 #[derive(Debug, Clone, Serialize, Deserialize)]
72 #[serde(tag = "kind", rename_all = "snake_case")]
73 pub enum GateStatus {
74 /// Gate ran and succeeded. The note carries gate-specific evidence
75 /// (e.g. `TestsPassed { duration_s }`).
76 Passed { note: PassNote },
77 /// Gate ran and failed. Two-layer tag: outer `kind = "failed"`, inner
78 /// `failure.kind` names the classified variant. If no classifier
79 /// matched, that's `unclassified`.
80 Failed { failure: GateFailure },
81 /// Gate cannot run yet. Burn-in clock not started, scratch DB not
82 /// configured, backup missing — pre-conditions the operator can fix
83 /// out of band. Distinguished from `Failed` so the TUI can render
84 /// these yellow rather than red.
85 Blocked { blocker: GateBlocker },
86 }
87
88 #[derive(Debug, Clone, Serialize, Deserialize)]
89 #[serde(tag = "kind", rename_all = "snake_case")]
90 pub enum PassNote {
91 /// `boot_smoke` / `code_smoke` — the binary came up and served `GET /health`
92 /// (readiness, not just liveness). `after_ms` is how long until the first
93 /// good probe. For `code_smoke` this is a probe of the real server booted
94 /// against a freshly migrated + seeded throwaway DB; for `boot_smoke` it is
95 /// the minimal no-DB smoke server.
96 HealthyProbe { after_ms: u32 },
97 /// `burn_in` — the configured number of hours have elapsed since
98 /// the gate's clock started.
99 BurnInElapsed { hours: u32 },
100 /// `migration_dry_run` — scratch DB restored from `backup_path` and
101 /// every migration ran without error. `checks` names each configured
102 /// migrations dir that passed; it is empty on rows written before the gate
103 /// ran more than the server's, and `backup_path` is the first check's dump.
104 Migrated {
105 backup_path: String,
106 #[serde(default)]
107 checks: Vec<String>,
108 },
109 /// `cargo_test` — `cargo test --release` exited 0.
110 TestsPassed { duration_s: u32 },
111 /// `manual_confirm` — an operator inserted a passing row out-of-band.
112 OperatorConfirmed { at: DateTime<Utc> },
113 /// `node_health` — every deployed node's service was active and (where a
114 /// `health_url` is configured) served its readiness probe. `nodes` is how
115 /// many nodes were verified.
116 NodesHealthy { nodes: u32 },
117 /// Legacy rows backfilled from the pre-typed schema. Carries the
118 /// original `detail` string so nothing is lost.
119 Legacy { text: String },
120 }
121
122 impl PassNote {
123 pub fn summary(&self) -> String {
124 match self {
125 PassNote::HealthyProbe { after_ms } => format!("served /health in {after_ms}ms"),
126 PassNote::BurnInElapsed { hours } => format!("{hours} hours elapsed"),
127 PassNote::Migrated {
128 backup_path,
129 checks,
130 } => match checks.len() {
131 // A pre-list row, or the single-check case: keep the wording the
132 // operator surface has always shown.
133 0 | 1 => format!("restored {backup_path} + migrated"),
134 n => format!("restored + migrated {n} databases: {}", checks.join(", ")),
135 },
136 PassNote::TestsPassed { duration_s } => format!("tests passed in {duration_s}s"),
137 PassNote::OperatorConfirmed { at } => format!("operator confirmed at {at}"),
138 PassNote::NodesHealthy { nodes } => format!("{nodes} node(s) healthy"),
139 PassNote::Legacy { text } => text.clone(),
140 }
141 }
142 }
143
144 #[derive(Debug, Clone, Serialize, Deserialize)]
145 #[serde(tag = "kind", rename_all = "snake_case")]
146 pub enum GateBlocker {
147 /// `burn_in`: the tier's `tier_state.burn_in_started_at` is NULL.
148 BurnInClockNotStarted,
149 /// `burn_in`: clock running but not enough time elapsed yet.
150 BurnInRemaining {
151 hours_remaining: u32,
152 hours_total: u32,
153 },
154 /// `manual_confirm`: no out-of-band passing row exists for this
155 /// (tier, version).
156 AwaitingOperatorConfirmation,
157 /// `migration_dry_run`: no row in `backups` for the named dump to restore
158 /// from. `check` is the migrations dir that wanted it. Both fields default
159 /// to empty on rows written before the gate took a list of checks.
160 NoBackupAvailable {
161 #[serde(default)]
162 check: String,
163 #[serde(default)]
164 backup: String,
165 },
166 /// `migration_dry_run`: the newest `backups` row for this check's dump is
167 /// older than `cfg.backup_max_age_hours`. Restoring it would dry-run the
168 /// migrations against a schema prod has since moved past, which passes green
169 /// while proving nothing — so the gate blocks instead.
170 BackupStale {
171 age_hours: i64,
172 max_age_hours: u32,
173 #[serde(default)]
174 check: String,
175 },
176 /// `migration_dry_run` / `boot_smoke` / `cargo_test`: daemon config
177 /// has no `scratch_db_url`.
178 ScratchDbUrlUnset,
179 /// `boot_smoke`: no `artifact_path` in `versions` for this version.
180 ArtifactMissing { version: Version },
181 /// `node_health`: the gate ran with no nodes to probe (a serving tier should
182 /// always have nodes; this fails closed so a misconfigured tier can't pass).
183 NoNodesToProbe,
184 }
185
186 impl GateBlocker {
187 pub fn summary(&self) -> String {
188 match self {
189 GateBlocker::BurnInClockNotStarted => "burn-in clock not started".into(),
190 GateBlocker::BurnInRemaining {
191 hours_remaining,
192 hours_total,
193 } => format!("{hours_remaining} hours remaining of {hours_total}"),
194 GateBlocker::AwaitingOperatorConfirmation => "waiting on operator confirmation".into(),
195 GateBlocker::NoBackupAvailable { backup, .. } => {
196 let which = if backup.is_empty() {
197 String::new()
198 } else {
199 format!("{backup} ")
200 };
201 format!("no {which}backup fetched; call /backup/fetch first")
202 }
203 GateBlocker::BackupStale {
204 age_hours,
205 max_age_hours,
206 check,
207 } => {
208 let which = if check.is_empty() {
209 String::new()
210 } else {
211 format!(" for {check}")
212 };
213 format!(
214 "backup{which} is {age_hours}h old (max {max_age_hours}h); re-run \
215 /backup/fetch"
216 )
217 }
218 GateBlocker::ScratchDbUrlUnset => "scratch_db_url unset in daemon config".into(),
219 GateBlocker::ArtifactMissing { version } => {
220 format!("no artifact for version {version}")
221 }
222 GateBlocker::NoNodesToProbe => "node_health has no nodes to probe".into(),
223 }
224 }
225 }
226
227 #[derive(Debug, Clone, Serialize, Deserialize)]
228 #[serde(tag = "kind", rename_all = "snake_case")]
229 pub enum GateFailure {
230 /// `cargo_test` exited non-zero. `failed_count` may be 0 if the
231 /// classifier couldn't parse the count (e.g. compile error).
232 /// `first_failed` is the first failing test's name; `first_panic` is the
233 /// first panic *message* (root cause), chosen to skip the "Once instance
234 /// has previously been poisoned" cascade so 800 poisoned tests don't bury
235 /// the one real panic that poisoned them.
236 CargoTest {
237 failed_count: u32,
238 first_failed: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 first_panic: Option<String>,
241 },
242 /// `cargo_test` fast pre-gate (`cargo test --no-run`): the test
243 /// targets failed to compile, so no tests ran. `first_error` is the
244 /// headline diagnostic (e.g. `error[E0063]: missing field
245 /// user_pages_host`) and `error_count` is cargo's "N previous errors".
246 /// Distinct from `CargoTest` so a test-only-target compile break reads
247 /// as a build error, not "0 tests failed".
248 CompileError {
249 error_count: u32,
250 first_error: Option<String>,
251 },
252 /// `migration_dry_run`: a migration that was previously applied is
253 /// no longer present in the resolved migrations directory.
254 MigrationDrift { migration: String },
255 /// `migration_dry_run`: a migration that was previously applied has
256 /// been modified (checksum mismatch).
257 MigrationModified { migration: String },
258 /// `migration_dry_run`: postgres rejected a migration's SQL.
259 MigrationSqlError {
260 migration: String,
261 sqlstate: Option<String>,
262 },
263 /// `migration_dry_run`: scratch DB reset or dump restore failed.
264 RestoreFailed { reason: String },
265 /// `boot_smoke`: binary exited with a non-zero status during the
266 /// smoke window. Most likely a panic; `exit_code` carries the OS
267 /// status when one is available.
268 BootPanic { exit_code: Option<i32> },
269 /// `boot_smoke`: binary exited 0 before the smoke window elapsed.
270 BootExitedEarly { exit_code: Option<i32> },
271 /// `boot_smoke`: binary stayed up but never served `GET /health` 200 within
272 /// the smoke window — started but not ready. `last_error` is the final probe
273 /// error (connection refused, non-200, timeout).
274 BootHealthProbeFailed { last_error: String },
275 /// `node_health`: a deployed node's service was not active, or did not serve
276 /// its `health_url`, within the probe window. `node` is the failing node;
277 /// `detail` carries the probe's stderr/reason.
278 NodeUnhealthy { node: String, detail: String },
279 /// `code_smoke`: creating or dropping the throwaway smoke DB failed (a
280 /// postgres/cluster problem, not the built code). `reason` carries the DB
281 /// error. Kept distinct from the boot/seed failures so an environment issue
282 /// here isn't misread as unsound code.
283 CodeSmokeSetup { reason: String },
284 /// `code_smoke`: the `--seed-examples` run (which migrates the empty DB from
285 /// scratch, then seeds the example catalog, then exits) returned non-zero —
286 /// a broken migration, a seed error, or a config-load failure. The persisted
287 /// gate log carries the process output; `exit_code` is the OS status.
288 CodeSmokeSeed { exit_code: Option<i32> },
289 /// `code_smoke`: the DB-free `MNW_CHECK_DOCS` run found broken internal docs
290 /// links (an internal `[..](x.md)` resolving to a slug no page serves — a
291 /// live 404 waiting in the public docs). Runs first, before the throwaway
292 /// DB is even created, so a rotted link fails cheaply. `broken` is the
293 /// reported count (0 if the sentinel could not be parsed); the gate log
294 /// carries the offending source→target lines.
295 CodeSmokeDocs { broken: u32 },
296 /// `code_smoke`: the booted server answered `GET /health` but never emitted
297 /// its `listening` startup log. The gate asserts both signals — the bind
298 /// readiness log and the health probe — so a missing log line fails even
299 /// when the probe passes.
300 CodeSmokeNoListeningLog,
301 /// `code_smoke`: a configured `frontend_build` failed to compile. `dir` is
302 /// the npm project under the worktree; `exit_code` is npm's status. This is
303 /// the failure both MNW build scripts downgrade to a `cargo::warning` on
304 /// purpose (so a broken chat widget cannot stop the forum compiling) — which
305 /// means this gate is the only place it is ever fatal, and the only thing
306 /// standing between a `tsc` error and a deploy that rsyncs the previous
307 /// build's `static/dist/`.
308 CodeSmokeFrontend { dir: String, exit_code: Option<i32> },
309 /// `cargo_test` / `boot_smoke`: tokio could not spawn the child.
310 SpawnFailed { message: String },
311 /// Gate took longer than the configured ceiling.
312 Timeout { gate: GateKind, after_s: u32 },
313 /// The gate reads a source checkout and this run has none, because the
314 /// artifact was built elsewhere and handed to Sando (wiki
315 /// [[sando-bento-boundary]]). Not a failure of the artifact: a failure of
316 /// the tier's gate list, which is asking Sando to re-prove something about
317 /// bytes it did not compile. Either the gate belongs to the builder, or the
318 /// bundle needs to carry what the gate reads.
319 NeedsSource { gate: GateKind, artifact: String },
320 /// Classifier could not match the output to any known variant. The
321 /// `log_ref` on the enclosing `GateOutcome` is the diagnostic path.
322 Unclassified { legacy_detail: Option<String> },
323 }
324
325 impl GateFailure {
326 pub fn summary(&self) -> String {
327 match self {
328 // The panic message is the diagnostic; prefer it over the test name.
329 GateFailure::CargoTest {
330 failed_count,
331 first_panic: Some(p),
332 ..
333 } => format!("{failed_count} test(s) failed; first panic: {p}"),
334 GateFailure::CargoTest {
335 failed_count,
336 first_failed: Some(name),
337 first_panic: None,
338 } => format!("{failed_count} test(s) failed; first: {name}"),
339 GateFailure::CargoTest {
340 failed_count,
341 first_failed: None,
342 first_panic: None,
343 } => format!("{failed_count} test(s) failed"),
344 GateFailure::CompileError {
345 error_count,
346 first_error: Some(e),
347 } => format!("compile failed ({error_count} error(s)); first: {e}"),
348 GateFailure::CompileError {
349 error_count,
350 first_error: None,
351 } => format!("compile failed ({error_count} error(s))"),
352 GateFailure::MigrationDrift { migration } => {
353 format!("migration {migration} previously applied but missing")
354 }
355 GateFailure::MigrationModified { migration } => {
356 format!("migration {migration} previously applied but modified")
357 }
358 GateFailure::MigrationSqlError {
359 migration,
360 sqlstate: Some(s),
361 } => format!("migration {migration} sql error ({s})"),
362 GateFailure::MigrationSqlError {
363 migration,
364 sqlstate: None,
365 } => format!("migration {migration} sql error"),
366 GateFailure::RestoreFailed { reason } => format!("restore: {reason}"),
367 GateFailure::BootPanic { exit_code: Some(c) } => format!("binary panicked: exit {c}"),
368 GateFailure::BootPanic { exit_code: None } => "binary panicked".into(),
369 GateFailure::BootExitedEarly { exit_code: Some(c) } => {
370 format!("binary exited early: exit {c}")
371 }
372 GateFailure::BootExitedEarly { exit_code: None } => "binary exited early".into(),
373 GateFailure::BootHealthProbeFailed { last_error } => {
374 format!("started but never served /health: {last_error}")
375 }
376 GateFailure::NodeUnhealthy { node, detail } => {
377 format!("node {node} unhealthy: {detail}")
378 }
379 GateFailure::CodeSmokeSetup { reason } => format!("code smoke db setup: {reason}"),
380 GateFailure::CodeSmokeSeed { exit_code: Some(c) } => {
381 format!("migrate+seed run failed: exit {c}")
382 }
383 GateFailure::CodeSmokeSeed { exit_code: None } => "migrate+seed run failed".into(),
384 GateFailure::CodeSmokeDocs { broken: 0 } => "broken internal docs link(s)".into(),
385 GateFailure::CodeSmokeDocs { broken } => {
386 format!("{broken} broken internal docs link(s)")
387 }
388 GateFailure::CodeSmokeNoListeningLog => {
389 "served /health but never logged 'listening'".into()
390 }
391 GateFailure::CodeSmokeFrontend { dir, exit_code } => match exit_code {
392 Some(c) => format!("frontend build failed in {dir}: exit {c}"),
393 None => format!("frontend build failed in {dir}"),
394 },
395 GateFailure::NeedsSource { gate, artifact } => format!(
396 "{gate} needs a source checkout; this artifact was built elsewhere ({artifact})"
397 ),
398 GateFailure::SpawnFailed { message } => format!("spawn: {message}"),
399 GateFailure::Timeout { gate, after_s } => format!("{gate} timed out after {after_s}s"),
400 GateFailure::Unclassified {
401 legacy_detail: Some(d),
402 } => d.clone(),
403 GateFailure::Unclassified {
404 legacy_detail: None,
405 } => "unclassified failure".into(),
406 }
407 }
408 }
409
410 // ---------------------------------------------------------------------
411 // Deploy outcomes (step 7)
412 // ---------------------------------------------------------------------
413
414 /// Typed outcome of one node-deploy attempt. Stored as `outcome_json` in
415 /// the `deploys` table and emitted in `Event::DeployFailed` so consumers
416 /// can distinguish a node-unreachable error (operator: check the box)
417 /// from rsync mid-transfer corruption (operator: check disk/network).
418 #[derive(Debug, Clone, Serialize, Deserialize)]
419 pub struct DeployOutcome {
420 pub status: DeployStatus,
421 }
422
423 impl DeployOutcome {
424 pub fn ok() -> Self {
425 Self {
426 status: DeployStatus::Ok,
427 }
428 }
429 pub fn failed(failure: DeployFailureKind) -> Self {
430 Self {
431 status: DeployStatus::Failed { failure },
432 }
433 }
434 pub fn in_progress() -> Self {
435 Self {
436 status: DeployStatus::InProgress,
437 }
438 }
439
440 /// `'in_progress' | 'ok' | 'failed'` — the value of the legacy
441 /// `deploys.outcome` column.
442 pub fn status_str(&self) -> &'static str {
443 match self.status {
444 DeployStatus::InProgress => "in_progress",
445 DeployStatus::Ok => "ok",
446 DeployStatus::Failed { .. } => "failed",
447 }
448 }
449 }
450
451 #[derive(Debug, Clone, Serialize, Deserialize)]
452 #[serde(tag = "kind", rename_all = "snake_case")]
453 pub enum DeployStatus {
454 InProgress,
455 Ok,
456 Failed { failure: DeployFailureKind },
457 }
458
459 #[derive(Debug, Clone, Serialize, Deserialize)]
460 #[serde(tag = "kind", rename_all = "snake_case")]
461 pub enum DeployFailureKind {
462 /// SSH to the node failed before any state changed. Typically a dead
463 /// host, network partition, or stale known_hosts.
464 NodeUnreachable { detail: String },
465 /// rsync exited non-zero mid-transfer. The on-target release dir may
466 /// be partially populated, but the `current` symlink is untouched.
467 RsyncFailed { detail: String },
468 /// Files copied successfully but the atomic symlink swap step
469 /// failed. The new release is on disk; the service is still running
470 /// the old one.
471 SymlinkSwapFailed { detail: String },
472 /// Symlink swapped but `systemctl reload-or-restart` returned
473 /// non-zero. The new code is current but the service may have
474 /// crashed on startup.
475 ServiceRestartFailed { detail: String },
476 /// Classifier couldn't match the error to a known variant. The full
477 /// anyhow chain is in `detail`.
478 Unclassified { detail: String },
479 }
480
481 impl DeployFailureKind {
482 pub fn summary(&self) -> String {
483 match self {
484 DeployFailureKind::NodeUnreachable { detail } => format!("node unreachable: {detail}"),
485 DeployFailureKind::RsyncFailed { detail } => format!("rsync: {detail}"),
486 DeployFailureKind::SymlinkSwapFailed { detail } => format!("symlink swap: {detail}"),
487 DeployFailureKind::ServiceRestartFailed { detail } => {
488 format!("service restart: {detail}")
489 }
490 DeployFailureKind::Unclassified { detail } => detail.chars().take(200).collect(),
491 }
492 }
493 }
494
495 /// Pointer to the on-disk gate log: a path relative to `cfg.logs_root`
496 /// of the form `<version>/<gate_kind>.log`. Stored in `gate_runs.log_ref`
497 /// and surfaced in `/state` so the TUI/operator can request the full
498 /// tail via `GET /logs/<version>/<gate>` only when needed.
499 #[derive(Debug, Clone, Serialize, Deserialize)]
500 #[serde(transparent)]
501 pub struct LogRef(pub String);
502
503 impl LogRef {
504 pub fn new(version: &Version, gate: GateKind) -> Self {
505 Self(format!("{}/{}.log", version, gate.as_str()))
506 }
507 pub fn as_str(&self) -> &str {
508 &self.0
509 }
510 }
511
512 impl std::fmt::Display for LogRef {
513 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
514 self.0.fmt(f)
515 }
516 }
517
518 #[cfg(test)]
519 mod tests {
520 use super::*;
521
522 #[test]
523 fn outcome_serialization_is_two_layer_tagged() {
524 let o = GateOutcome::failed(GateFailure::MigrationDrift {
525 migration: "0047_widgets".into(),
526 });
527 let v: serde_json::Value = serde_json::to_value(&o).unwrap();
528 assert_eq!(v["status"]["kind"], "failed");
529 assert_eq!(v["status"]["failure"]["kind"], "migration_drift");
530 assert_eq!(v["status"]["failure"]["migration"], "0047_widgets");
531 }
532
533 #[test]
534 fn outcome_round_trips_through_json() {
535 let o = GateOutcome::passed(PassNote::TestsPassed { duration_s: 42 });
536 let s = serde_json::to_string(&o).unwrap();
537 let back: GateOutcome = serde_json::from_str(&s).unwrap();
538 assert!(back.is_passed());
539 assert_eq!(back.status_str(), "passed");
540 }
541
542 #[test]
543 fn timeout_failure_renders_and_is_not_passed() {
544 // Timeout is now a live failure (cargo_test / migration_dry_run ceilings),
545 // not an aspirational variant — keep it mapped in summary() and serde.
546 let o = GateOutcome::failed(GateFailure::Timeout {
547 gate: GateKind::CargoTest,
548 after_s: 2400,
549 });
550 assert!(!o.is_passed());
551 let v: serde_json::Value = serde_json::to_value(&o).unwrap();
552 assert_eq!(v["status"]["failure"]["kind"], "timeout");
553 assert!(
554 matches!(&o.status, GateStatus::Failed { failure } if failure.summary().contains("timed out"))
555 );
556 }
557
558 #[test]
559 fn blocked_is_not_passed() {
560 let o = GateOutcome::blocked(GateBlocker::BurnInClockNotStarted);
561 assert!(!o.is_passed());
562 assert_eq!(o.status_str(), "blocked");
563 }
564
565 #[test]
566 fn log_ref_construction_matches_disk_layout() {
567 let v: Version = "0.9.6".parse().unwrap();
568 let lr = LogRef::new(&v, GateKind::CargoTest);
569 assert_eq!(lr.as_str(), "0.9.6/cargo_test.log");
570 }
571
572 #[test]
573 fn unclassified_preserves_legacy_detail() {
574 let o = GateOutcome::failed(GateFailure::Unclassified {
575 legacy_detail: Some(
576 "binary exited early: exit status: 101\n==== stdout ====\n...".into(),
577 ),
578 });
579 let v: serde_json::Value = serde_json::to_value(&o).unwrap();
580 assert_eq!(v["status"]["failure"]["kind"], "unclassified");
581 assert!(
582 v["status"]["failure"]["legacy_detail"]
583 .as_str()
584 .unwrap()
585 .contains("exit status: 101")
586 );
587 }
588 }
589