Skip to main content

max / makenotwork

6.6 KB · 191 lines History Blame Raw
1 //! Schema-drift guard for services monitored by PoM.
2 //!
3 //! Pattern + rationale: maintainer wiki.
4 //! <!-- wiki: pom-health-contract -->
5 //!
6 //! Background: PoM polls each target's `/api/health` and runs key-by-key
7 //! assertions from `pom/deploy/pom-hetzner.toml` (`json_fields = { ... }`).
8 //! If a producer changes the response shape without updating PoM — or vice
9 //! versa — every snapshot becomes `Degraded` and an incident sits open
10 //! until someone notices. The May-12 (MNW) and April-22 (MT) incidents
11 //! each ran for weeks before discovery.
12 //!
13 //! This crate provides a single test helper that producer crates wire into
14 //! their `#[cfg(test)]` blocks. Run alongside the health endpoint's pure
15 //! body builder, it fails at PR time the moment the response shape stops
16 //! satisfying PoM's expectations.
17 //!
18 //! # Usage
19 //!
20 //! ```ignore
21 //! #[test]
22 //! fn pom_hetzner_health_expectations_resolve() {
23 //! let body = health_body(/* db_ok: */ true);
24 //! pom_contract::assert_health_expectations_resolve(
25 //! "../pom/deploy/pom-hetzner.toml",
26 //! "mnw",
27 //! &body,
28 //! );
29 //! }
30 //! ```
31 //!
32 //! Paths are resolved relative to the calling crate's manifest directory
33 //! at test time (`cargo test`'s CWD). The helper panics with a precise
34 //! diagnostic listing every missing or mismatched field.
35
36 use std::path::Path;
37
38 /// Assert that every `json_fields` entry under `targets.<target>.health.expect`
39 /// in the PoM config at `pom_config_path` resolves to its expected value when
40 /// walked against `body`.
41 ///
42 /// Panics with a multi-line diagnostic on drift; returns silently on success.
43 pub fn assert_health_expectations_resolve(
44 pom_config_path: impl AsRef<Path>,
45 target: &str,
46 body: &serde_json::Value,
47 ) {
48 let path = pom_config_path.as_ref();
49 let raw = std::fs::read_to_string(path)
50 .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
51 let cfg: toml::Table = raw
52 .parse()
53 .unwrap_or_else(|e| panic!("failed to parse {}: {e}", path.display()));
54
55 let json_fields = cfg
56 .get("targets")
57 .and_then(|t| t.get(target))
58 .and_then(|t| t.get("health"))
59 .and_then(|h| h.get("expect"))
60 .and_then(|e| e.get("json_fields"))
61 .and_then(|f| f.as_table())
62 .unwrap_or_else(|| {
63 panic!(
64 "{} has no targets.{target}.health.expect.json_fields",
65 path.display()
66 )
67 });
68
69 let mut failures = Vec::new();
70 for (key, expected) in json_fields {
71 let expected_str = expected
72 .as_str()
73 .map_or_else(|| expected.to_string(), std::string::ToString::to_string);
74
75 match resolve_json_path(body, key) {
76 None => failures.push(format!(
77 " json field \"{key}\" missing from response (expected \"{expected_str}\")",
78 )),
79 Some(actual) => {
80 let actual_str = match actual {
81 serde_json::Value::String(s) => s.clone(),
82 other => other.to_string(),
83 };
84 if actual_str != expected_str {
85 failures.push(format!(
86 " json field \"{key}\": PoM expects \"{expected_str}\", response yields \"{actual_str}\"",
87 ));
88 }
89 }
90 }
91 }
92
93 assert!(
94 failures.is_empty(),
95 "PoM schema-drift detected for target \"{target}\" — {} expectation(s) no longer resolve:\n{}\n\nFix: either restore the missing field in the response builder or drop the assertion from `{}`.",
96 failures.len(),
97 failures.join("\n"),
98 path.display(),
99 );
100 }
101
102 /// Walk a dot-separated JSON path. Mirrors PoM's `resolve_json_path` exactly
103 /// so this helper's path semantics match what runs against prod.
104 fn resolve_json_path<'a>(
105 value: &'a serde_json::Value,
106 path: &str,
107 ) -> Option<&'a serde_json::Value> {
108 let mut current = value;
109 for key in path.split('.') {
110 current = current.get(key)?;
111 }
112 Some(current)
113 }
114
115 #[cfg(test)]
116 mod tests {
117 use super::*;
118 use serde_json::json;
119
120 fn write_config(dir: &std::path::Path, fields: &str) -> std::path::PathBuf {
121 let path = dir.join("pom.toml");
122 let content = format!(
123 "[targets.demo.health.expect]\nstatus_code = 200\njson_fields = {{ {fields} }}\n"
124 );
125 std::fs::write(&path, content).unwrap();
126 path
127 }
128
129 fn tempdir() -> std::path::PathBuf {
130 let p = std::env::temp_dir().join(format!(
131 "pom-contract-test-{}-{}",
132 std::process::id(),
133 std::time::SystemTime::now()
134 .duration_since(std::time::UNIX_EPOCH)
135 .unwrap()
136 .as_nanos()
137 ));
138 std::fs::create_dir_all(&p).unwrap();
139 p
140 }
141
142 #[test]
143 fn passes_when_all_fields_resolve() {
144 let dir = tempdir();
145 let cfg = write_config(&dir, r#""status" = "operational", "database" = "true""#);
146 let body = json!({ "status": "operational", "database": true });
147 assert_health_expectations_resolve(&cfg, "demo", &body);
148 }
149
150 #[test]
151 fn passes_with_nested_path() {
152 let dir = tempdir();
153 let cfg = write_config(
154 &dir,
155 r#""status" = "operational", "checks.database" = "true""#,
156 );
157 let body = json!({ "status": "operational", "checks": { "database": true } });
158 assert_health_expectations_resolve(&cfg, "demo", &body);
159 }
160
161 #[test]
162 #[should_panic(expected = "json field \"checks.git_storage\" missing")]
163 fn fails_when_field_missing() {
164 let dir = tempdir();
165 let cfg = write_config(
166 &dir,
167 r#""status" = "operational", "checks.git_storage" = "true""#,
168 );
169 let body = json!({ "status": "operational", "checks": { "database": true } });
170 assert_health_expectations_resolve(&cfg, "demo", &body);
171 }
172
173 #[test]
174 #[should_panic(expected = "PoM expects \"true\", response yields \"false\"")]
175 fn fails_when_value_mismatches() {
176 let dir = tempdir();
177 let cfg = write_config(&dir, r#""status" = "operational", "database" = "true""#);
178 let body = json!({ "status": "operational", "database": false });
179 assert_health_expectations_resolve(&cfg, "demo", &body);
180 }
181
182 #[test]
183 #[should_panic(expected = "no targets.unknown.health.expect.json_fields")]
184 fn fails_when_target_absent() {
185 let dir = tempdir();
186 let cfg = write_config(&dir, r#""status" = "operational""#);
187 let body = json!({ "status": "operational" });
188 assert_health_expectations_resolve(&cfg, "unknown", &body);
189 }
190 }
191