1use std::collections::BTreeSet;
29
30use serde_json::Value;
31
32use crate::cotejo::diff_object;
33use crate::normalize::Normalizer;
34use crate::verdict::{Divergence, Severity, Side};
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
42pub enum RuntimeOp {
43 Create,
45 Start,
47 State,
49 Kill,
51 Delete,
53}
54
55impl RuntimeOp {
56 #[must_use]
58 pub fn as_str(self) -> &'static str {
59 match self {
60 Self::Create => "create",
61 Self::Start => "start",
62 Self::State => "state",
63 Self::Kill => "kill",
64 Self::Delete => "delete",
65 }
66 }
67}
68
69#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct RuntimeObservation {
72 pub op: RuntimeOp,
74 pub exit_code: Option<i32>,
76 pub stdout: Vec<u8>,
78 pub stderr: Vec<u8>,
84}
85
86impl RuntimeObservation {
87 #[must_use]
92 pub fn state_json(&self) -> Option<Value> {
93 if self.op != RuntimeOp::State {
94 return None;
95 }
96 serde_json::from_slice(&self.stdout).ok()
97 }
98
99 #[must_use]
101 pub fn is_success(&self) -> bool {
102 self.exit_code == Some(0)
103 }
104}
105
106#[must_use]
117pub fn state_normalizer() -> Normalizer {
118 use crate::normalize::Mask;
119 use crate::path::JsonPath;
120 let mut n = Normalizer::default();
121 for key in [
122 "pid",
124 "bundle",
125 "systemd-scope",
127 "owner",
128 "created",
129 "annotations",
131 ] {
132 n = n.with(Mask::Drop(JsonPath::parse(key)));
133 }
134 n
135}
136
137#[must_use]
142pub fn diff_runtime_op(
143 reference: &RuntimeObservation,
144 under_test: &RuntimeObservation,
145 norm: &Normalizer,
146) -> Vec<Divergence> {
147 use crate::path::JsonPath;
148
149 assert_eq!(reference.op, under_test.op, "compare like with like");
150 let op = reference.op;
151
152 if reference.exit_code != under_test.exit_code {
156 return vec![Divergence::FieldDiff {
157 path: JsonPath::parse(&format!("{}.exitCode", op.as_str())),
158 engenho: serde_json::json!(under_test.exit_code),
159 k3s: serde_json::json!(reference.exit_code),
160 severity: Severity::Hard,
161 }];
162 }
163 if !reference.is_success() {
165 return Vec::new();
166 }
167
168 if op == RuntimeOp::State {
170 return match (reference.state_json(), under_test.state_json()) {
171 (Some(r), Some(u)) => diff_object(&norm.apply(&u), &norm.apply(&r), Severity::Hard),
172 (r, u) => vec![Divergence::FieldDiff {
173 path: JsonPath::parse("state.document"),
174 engenho: serde_json::json!(if u.is_some() { "json" } else { "not json" }),
175 k3s: serde_json::json!(if r.is_some() { "json" } else { "not json" }),
176 severity: Severity::Hard,
177 }],
178 };
179 }
180
181 let mut out = Vec::new();
185 if reference.stdout != under_test.stdout {
186 out.push(Divergence::FieldDiff {
187 path: JsonPath::parse(&format!("{}.stdout", op.as_str())),
188 engenho: serde_json::json!(String::from_utf8_lossy(&under_test.stdout)),
189 k3s: serde_json::json!(String::from_utf8_lossy(&reference.stdout)),
190 severity: Severity::Hard,
191 });
192 }
193 if reference.stderr.is_empty() != under_test.stderr.is_empty() {
194 out.push(Divergence::FieldDiff {
195 path: JsonPath::parse(&format!("{}.stderr", op.as_str())),
196 engenho: serde_json::json!(if under_test.stderr.is_empty() {
197 "empty"
198 } else {
199 "non-empty"
200 }),
201 k3s: serde_json::json!(if reference.stderr.is_empty() {
202 "empty"
203 } else {
204 "non-empty"
205 }),
206 severity: Severity::Hard,
207 });
208 }
209 out
210}
211
212#[must_use]
217pub fn diff_runtime_run(
218 reference: &[RuntimeObservation],
219 under_test: &[RuntimeObservation],
220 norm: &Normalizer,
221) -> Vec<Divergence> {
222 use crate::path::JsonPath;
223
224 let r_ops: BTreeSet<RuntimeOp> = reference.iter().map(|o| o.op).collect();
225 let u_ops: BTreeSet<RuntimeOp> = under_test.iter().map(|o| o.op).collect();
226 if r_ops != u_ops {
227 return vec![Divergence::FieldDiff {
228 path: JsonPath::parse("lifecycle.subcommands"),
229 engenho: serde_json::json!(format!("{u_ops:?}")),
230 k3s: serde_json::json!(format!("{r_ops:?}")),
231 severity: Severity::Hard,
232 }];
233 }
234 let mut out = Vec::new();
237 for op in r_ops {
238 let r = reference.iter().find(|o| o.op == op).expect("present");
239 let u = under_test.iter().find(|o| o.op == op).expect("present");
240 out.extend(diff_runtime_op(r, u, norm));
241 }
242 out
243}
244
245#[must_use]
250pub fn reference_side() -> Side {
251 Side::K3s
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 fn obs(op: RuntimeOp, code: i32, stdout: &str) -> RuntimeObservation {
259 RuntimeObservation {
260 op,
261 exit_code: Some(code),
262 stdout: stdout.as_bytes().to_vec(),
263 stderr: Vec::new(),
264 }
265 }
266
267 fn state(json: &str) -> RuntimeObservation {
268 obs(RuntimeOp::State, 0, json)
269 }
270
271 #[test]
272 fn the_same_run_against_itself_is_parity() {
273 let run = vec![
275 obs(RuntimeOp::Create, 0, ""),
276 obs(RuntimeOp::Start, 0, ""),
277 state(r#"{"ociVersion":"1.0.2","id":"c","status":"running","pid":1,"bundle":"/a"}"#),
278 obs(RuntimeOp::Delete, 0, ""),
279 ];
280 assert!(diff_runtime_run(&run, &run, &state_normalizer()).is_empty());
281 }
282
283 #[test]
284 fn a_runtime_that_does_nothing_diverges_naming_the_field() {
285 let good = vec![obs(RuntimeOp::Create, 0, "")];
288 let dead = vec![obs(RuntimeOp::Create, 1, "")];
289 let d = diff_runtime_run(&good, &dead, &state_normalizer());
290 assert_eq!(d.len(), 1, "{d:?}");
291 assert!(
292 format!("{:?}", d[0]).contains("exitCode"),
293 "the divergence must NAME the field: {:?}",
294 d[0]
295 );
296 }
297
298 #[test]
299 fn per_run_and_runtime_private_state_keys_are_masked() {
300 let crun = state(
304 r#"{"ociVersion":"1.0.2","id":"c","status":"running","pid":11,
305 "bundle":"/x","systemd-scope":"s","owner":"root","created":"t1"}"#,
306 );
307 let runc = state(
308 r#"{"ociVersion":"1.0.2","id":"c","status":"running","pid":22,
309 "bundle":"/y","annotations":{"a":"b"}}"#,
310 );
311 assert!(
312 diff_runtime_run(&[crun], &[runc], &state_normalizer()).is_empty(),
313 "only the spec-defined fields may be compared"
314 );
315 }
316
317 #[test]
318 fn a_real_state_difference_is_still_caught_through_the_mask() {
319 let a = state(r#"{"ociVersion":"1.0.2","id":"c","status":"running","pid":1}"#);
322 let b = state(r#"{"ociVersion":"1.0.2","id":"c","status":"stopped","pid":2}"#);
323 let d = diff_runtime_run(&[a], &[b], &state_normalizer());
324 assert!(!d.is_empty(), "a status difference must survive the mask");
325 assert!(format!("{d:?}").contains("status"), "{d:?}");
326 }
327
328 #[test]
329 fn a_short_run_is_a_divergence_not_a_silent_pass() {
330 let full = vec![obs(RuntimeOp::Create, 0, ""), state(r#"{"id":"c"}"#)];
333 let short = vec![obs(RuntimeOp::Create, 0, "")];
334 let d = diff_runtime_run(&full, &short, &state_normalizer());
335 assert_eq!(d.len(), 1);
336 assert!(format!("{d:?}").contains("subcommands"), "{d:?}");
337 }
338
339 #[test]
340 fn stderr_is_compared_for_emptiness_not_for_prose() {
341 let mut a = obs(RuntimeOp::Kill, 0, "");
344 let mut b = obs(RuntimeOp::Kill, 0, "");
345 a.stderr = b"container not running\n".to_vec();
346 b.stderr = b"the container is not running\n".to_vec();
347 assert!(diff_runtime_run(&[a.clone()], &[b], &state_normalizer()).is_empty());
348 let quiet = obs(RuntimeOp::Kill, 0, "");
350 assert!(!diff_runtime_run(&[a], &[quiet], &state_normalizer()).is_empty());
351 }
352}