Skip to main content

max / makenotwork

5.7 KB · 167 lines History Blame Raw
1 //! What has been proven about the bytes, and by whom.
2
3 use chrono::{DateTime, Utc};
4
5 /// What a gate's evidence is *about*.
6 ///
7 /// This is the boundary between the two controllers, expressed as a type
8 /// rather than as a convention. A build host can prove things about an
9 /// artifact: it compiled, its tests passed, it is signed and notarized. It
10 /// cannot prove anything about that artifact in production, because it does
11 /// not have production: no restored dump to migrate, no nodes to probe, no
12 /// clock that has been running for 48 hours.
13 ///
14 /// Keeping the two apart in the type means an evaluator never has to decide
15 /// whether `boot_smoke` from a build host counts. It cannot be handed one.
16 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17 #[serde(tag = "kind", rename_all = "snake_case")]
18 pub enum Scope {
19 /// About the artifact itself: clippy, unit tests, `cargo_audit`, signing,
20 /// notarization, Gatekeeper. True wherever the bytes go.
21 Artifact,
22 /// About the artifact in one environment: `migration_dry_run`,
23 /// `boot_smoke` against a restored dump, `node_health`, `burn_in`.
24 /// Says nothing about the same bytes anywhere else, so it names where.
25 Environment { env: String },
26 }
27
28 /// Passed, failed, or could not run.
29 ///
30 /// `Blocked` is not a third flavour of failure. The gate did not run because it
31 /// owes a precondition somebody can satisfy out of band (no backup fetched, the
32 /// burn-in clock never started). Collapsing it into `Failed` turns "not yet"
33 /// into "no", which is how an operator learns to route around a gate.
34 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35 #[serde(rename_all = "snake_case")]
36 pub enum Verdict {
37 Passed,
38 Failed,
39 Blocked,
40 }
41
42 impl Verdict {
43 pub fn is_passed(self) -> bool {
44 matches!(self, Verdict::Passed)
45 }
46 }
47
48 /// One gate, run once, against one artifact.
49 ///
50 /// `detail` is deliberately untyped. Both controllers already have rich,
51 /// divergent failure vocabularies (Sando's `GateFailure` names the migration
52 /// that drifted), and hoisting either into this crate would make the contract
53 /// the union of two daemons' internals and force a coordinated change every
54 /// time one of them learns a new failure mode. The envelope is shared; what a
55 /// producer puts inside stays its own. A reader that does not know the producer
56 /// still gets `verdict` and `summary`, which is what deciding requires.
57 #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
58 pub struct GateRecord {
59 /// The gate's name in its producer's vocabulary (`cargo_test`, `boot_smoke`).
60 pub gate: String,
61 pub scope: Scope,
62 pub verdict: Verdict,
63 /// One line, human-facing. `12 test(s) failed; first panic: ...`
64 pub summary: String,
65 pub ran_at: DateTime<Utc>,
66 /// Where the producer kept the full output, in the producer's own terms.
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub log_ref: Option<String>,
69 /// The producer's own typed outcome, verbatim.
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub detail: Option<serde_json::Value>,
72 }
73
74 impl GateRecord {
75 pub fn new(
76 gate: impl Into<String>,
77 scope: Scope,
78 verdict: Verdict,
79 summary: impl Into<String>,
80 ran_at: DateTime<Utc>,
81 ) -> Self {
82 Self {
83 gate: gate.into(),
84 scope,
85 verdict,
86 summary: summary.into(),
87 ran_at,
88 log_ref: None,
89 detail: None,
90 }
91 }
92
93 #[must_use]
94 pub fn with_log_ref(mut self, log_ref: impl Into<String>) -> Self {
95 self.log_ref = Some(log_ref.into());
96 self
97 }
98
99 #[must_use]
100 pub fn with_detail(mut self, detail: serde_json::Value) -> Self {
101 self.detail = Some(detail);
102 self
103 }
104 }
105
106 #[cfg(test)]
107 mod tests {
108 use super::*;
109
110 fn at() -> DateTime<Utc> {
111 DateTime::<Utc>::from_timestamp(1_754_000_000, 0).unwrap()
112 }
113
114 #[test]
115 fn an_environment_scope_names_its_environment() {
116 let g = GateRecord::new(
117 "boot_smoke",
118 Scope::Environment {
119 env: "tier:a".into(),
120 },
121 Verdict::Passed,
122 "served /health in 340ms",
123 at(),
124 );
125 let v = serde_json::to_value(&g).unwrap();
126 assert_eq!(v["scope"]["kind"], "environment");
127 assert_eq!(v["scope"]["env"], "tier:a");
128 assert_eq!(v["verdict"], "passed");
129 }
130
131 #[test]
132 fn a_producers_own_failure_type_survives_verbatim() {
133 // Sando's GateFailure, serialized by Sando, carried through untouched.
134 let detail = serde_json::json!({
135 "kind": "migration_drift",
136 "migration": "0047_widgets",
137 });
138 let g = GateRecord::new(
139 "migration_dry_run",
140 Scope::Environment {
141 env: "tier:a".into(),
142 },
143 Verdict::Failed,
144 "migration 0047_widgets previously applied but missing",
145 at(),
146 )
147 .with_detail(detail.clone());
148 let back: GateRecord = serde_json::from_str(&serde_json::to_string(&g).unwrap()).unwrap();
149 assert_eq!(back.detail.unwrap(), detail);
150 }
151
152 #[test]
153 fn optional_fields_are_absent_rather_than_null() {
154 let g = GateRecord::new("clippy", Scope::Artifact, Verdict::Passed, "clean", at());
155 let v = serde_json::to_value(&g).unwrap();
156 assert!(v.get("log_ref").is_none());
157 assert!(v.get("detail").is_none());
158 }
159
160 #[test]
161 fn blocked_is_not_passed() {
162 assert!(!Verdict::Blocked.is_passed());
163 assert!(!Verdict::Failed.is_passed());
164 assert!(Verdict::Passed.is_passed());
165 }
166 }
167