Skip to main content

max / makenotwork

6.6 KB · 193 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 #![warn(missing_docs)]
37
38 use std::path::Path;
39
40 /// Assert that every `json_fields` entry under `targets.<target>.health.expect`
41 /// in the PoM config at `pom_config_path` resolves to its expected value when
42 /// walked against `body`.
43 ///
44 /// Panics with a multi-line diagnostic on drift; returns silently on success.
45 pub fn assert_health_expectations_resolve(
46 pom_config_path: impl AsRef<Path>,
47 target: &str,
48 body: &serde_json::Value,
49 ) {
50 let path = pom_config_path.as_ref();
51 let raw = std::fs::read_to_string(path)
52 .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
53 let cfg: toml::Table = raw
54 .parse()
55 .unwrap_or_else(|e| panic!("failed to parse {}: {e}", path.display()));
56
57 let json_fields = cfg
58 .get("targets")
59 .and_then(|t| t.get(target))
60 .and_then(|t| t.get("health"))
61 .and_then(|h| h.get("expect"))
62 .and_then(|e| e.get("json_fields"))
63 .and_then(|f| f.as_table())
64 .unwrap_or_else(|| {
65 panic!(
66 "{} has no targets.{target}.health.expect.json_fields",
67 path.display()
68 )
69 });
70
71 let mut failures = Vec::new();
72 for (key, expected) in json_fields {
73 let expected_str = expected
74 .as_str()
75 .map_or_else(|| expected.to_string(), std::string::ToString::to_string);
76
77 match resolve_json_path(body, key) {
78 None => failures.push(format!(
79 " json field \"{key}\" missing from response (expected \"{expected_str}\")",
80 )),
81 Some(actual) => {
82 let actual_str = match actual {
83 serde_json::Value::String(s) => s.clone(),
84 other => other.to_string(),
85 };
86 if actual_str != expected_str {
87 failures.push(format!(
88 " json field \"{key}\": PoM expects \"{expected_str}\", response yields \"{actual_str}\"",
89 ));
90 }
91 }
92 }
93 }
94
95 assert!(
96 failures.is_empty(),
97 "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 `{}`.",
98 failures.len(),
99 failures.join("\n"),
100 path.display(),
101 );
102 }
103
104 /// Walk a dot-separated JSON path. Mirrors PoM's `resolve_json_path` exactly
105 /// so this helper's path semantics match what runs against prod.
106 fn resolve_json_path<'a>(
107 value: &'a serde_json::Value,
108 path: &str,
109 ) -> Option<&'a serde_json::Value> {
110 let mut current = value;
111 for key in path.split('.') {
112 current = current.get(key)?;
113 }
114 Some(current)
115 }
116
117 #[cfg(test)]
118 mod tests {
119 use super::*;
120 use serde_json::json;
121
122 fn write_config(dir: &std::path::Path, fields: &str) -> std::path::PathBuf {
123 let path = dir.join("pom.toml");
124 let content = format!(
125 "[targets.demo.health.expect]\nstatus_code = 200\njson_fields = {{ {fields} }}\n"
126 );
127 std::fs::write(&path, content).unwrap();
128 path
129 }
130
131 fn tempdir() -> std::path::PathBuf {
132 let p = std::env::temp_dir().join(format!(
133 "pom-contract-test-{}-{}",
134 std::process::id(),
135 std::time::SystemTime::now()
136 .duration_since(std::time::UNIX_EPOCH)
137 .unwrap()
138 .as_nanos()
139 ));
140 std::fs::create_dir_all(&p).unwrap();
141 p
142 }
143
144 #[test]
145 fn passes_when_all_fields_resolve() {
146 let dir = tempdir();
147 let cfg = write_config(&dir, r#""status" = "operational", "database" = "true""#);
148 let body = json!({ "status": "operational", "database": true });
149 assert_health_expectations_resolve(&cfg, "demo", &body);
150 }
151
152 #[test]
153 fn passes_with_nested_path() {
154 let dir = tempdir();
155 let cfg = write_config(
156 &dir,
157 r#""status" = "operational", "checks.database" = "true""#,
158 );
159 let body = json!({ "status": "operational", "checks": { "database": true } });
160 assert_health_expectations_resolve(&cfg, "demo", &body);
161 }
162
163 #[test]
164 #[should_panic(expected = "json field \"checks.git_storage\" missing")]
165 fn fails_when_field_missing() {
166 let dir = tempdir();
167 let cfg = write_config(
168 &dir,
169 r#""status" = "operational", "checks.git_storage" = "true""#,
170 );
171 let body = json!({ "status": "operational", "checks": { "database": true } });
172 assert_health_expectations_resolve(&cfg, "demo", &body);
173 }
174
175 #[test]
176 #[should_panic(expected = "PoM expects \"true\", response yields \"false\"")]
177 fn fails_when_value_mismatches() {
178 let dir = tempdir();
179 let cfg = write_config(&dir, r#""status" = "operational", "database" = "true""#);
180 let body = json!({ "status": "operational", "database": false });
181 assert_health_expectations_resolve(&cfg, "demo", &body);
182 }
183
184 #[test]
185 #[should_panic(expected = "no targets.unknown.health.expect.json_fields")]
186 fn fails_when_target_absent() {
187 let dir = tempdir();
188 let cfg = write_config(&dir, r#""status" = "operational""#);
189 let body = json!({ "status": "operational" });
190 assert_health_expectations_resolve(&cfg, "unknown", &body);
191 }
192 }
193