Skip to main content

max / makenotwork

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