//! What has been proven about the bytes, and by whom. use chrono::{DateTime, Utc}; /// What a gate's evidence is *about*. /// /// This is the boundary between the two controllers, expressed as a type /// rather than as a convention. A build host can prove things about an /// artifact: it compiled, its tests passed, it is signed and notarized. It /// cannot prove anything about that artifact in production, because it does /// not have production: no restored dump to migrate, no nodes to probe, no /// clock that has been running for 48 hours. /// /// Keeping the two apart in the type means an evaluator never has to decide /// whether `boot_smoke` from a build host counts. It cannot be handed one. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum Scope { /// About the artifact itself: clippy, unit tests, `cargo_audit`, signing, /// notarization, Gatekeeper. True wherever the bytes go. Artifact, /// About the artifact in one environment: `migration_dry_run`, /// `boot_smoke` against a restored dump, `node_health`, `burn_in`. /// Says nothing about the same bytes anywhere else, so it names where. Environment { env: String }, } /// Passed, failed, or could not run. /// /// `Blocked` is not a third flavour of failure. The gate did not run because it /// owes a precondition somebody can satisfy out of band (no backup fetched, the /// burn-in clock never started). Collapsing it into `Failed` turns "not yet" /// into "no", which is how an operator learns to route around a gate. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum Verdict { Passed, Failed, Blocked, } impl Verdict { pub fn is_passed(self) -> bool { matches!(self, Verdict::Passed) } } /// One gate, run once, against one artifact. /// /// `detail` is deliberately untyped. Both controllers already have rich, /// divergent failure vocabularies (Sando's `GateFailure` names the migration /// that drifted), and hoisting either into this crate would make the contract /// the union of two daemons' internals and force a coordinated change every /// time one of them learns a new failure mode. The envelope is shared; what a /// producer puts inside stays its own. A reader that does not know the producer /// still gets `verdict` and `summary`, which is what deciding requires. #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct GateRecord { /// The gate's name in its producer's vocabulary (`cargo_test`, `boot_smoke`). pub gate: String, pub scope: Scope, pub verdict: Verdict, /// One line, human-facing. `12 test(s) failed; first panic: ...` pub summary: String, pub ran_at: DateTime, /// Where the producer kept the full output, in the producer's own terms. #[serde(default, skip_serializing_if = "Option::is_none")] pub log_ref: Option, /// The producer's own typed outcome, verbatim. #[serde(default, skip_serializing_if = "Option::is_none")] pub detail: Option, } impl GateRecord { pub fn new( gate: impl Into, scope: Scope, verdict: Verdict, summary: impl Into, ran_at: DateTime, ) -> Self { Self { gate: gate.into(), scope, verdict, summary: summary.into(), ran_at, log_ref: None, detail: None, } } #[must_use] pub fn with_log_ref(mut self, log_ref: impl Into) -> Self { self.log_ref = Some(log_ref.into()); self } #[must_use] pub fn with_detail(mut self, detail: serde_json::Value) -> Self { self.detail = Some(detail); self } } #[cfg(test)] mod tests { use super::*; fn at() -> DateTime { DateTime::::from_timestamp(1_754_000_000, 0).unwrap() } #[test] fn an_environment_scope_names_its_environment() { let g = GateRecord::new( "boot_smoke", Scope::Environment { env: "tier:a".into(), }, Verdict::Passed, "served /health in 340ms", at(), ); let v = serde_json::to_value(&g).unwrap(); assert_eq!(v["scope"]["kind"], "environment"); assert_eq!(v["scope"]["env"], "tier:a"); assert_eq!(v["verdict"], "passed"); } #[test] fn a_producers_own_failure_type_survives_verbatim() { // Sando's GateFailure, serialized by Sando, carried through untouched. let detail = serde_json::json!({ "kind": "migration_drift", "migration": "0047_widgets", }); let g = GateRecord::new( "migration_dry_run", Scope::Environment { env: "tier:a".into(), }, Verdict::Failed, "migration 0047_widgets previously applied but missing", at(), ) .with_detail(detail.clone()); let back: GateRecord = serde_json::from_str(&serde_json::to_string(&g).unwrap()).unwrap(); assert_eq!(back.detail.unwrap(), detail); } #[test] fn optional_fields_are_absent_rather_than_null() { let g = GateRecord::new("clippy", Scope::Artifact, Verdict::Passed, "clean", at()); let v = serde_json::to_value(&g).unwrap(); assert!(v.get("log_ref").is_none()); assert!(v.get("detail").is_none()); } #[test] fn blocked_is_not_passed() { assert!(!Verdict::Blocked.is_passed()); assert!(!Verdict::Failed.is_passed()); assert!(Verdict::Passed.is_passed()); } }