1use std::collections::BTreeSet;
9
10use serde_json::Value;
11
12use crate::normalize::Normalizer;
13use crate::observe::{DiffError, Observation};
14use crate::op::{OpKind, Operation};
15use crate::path::JsonPath;
16use crate::target::DiffTarget;
17use crate::verdict::{Divergence, Gvr, Severity, Side, Verdict};
18
19pub async fn run(
27 op: &Operation,
28 engenho: &dyn DiffTarget,
29 k3s: &dyn DiffTarget,
30 norm: &Normalizer,
31) -> Result<Verdict, DiffError> {
32 let eng_obs = op_observe(op, engenho).await?;
33 let k3s_obs = op_observe(op, k3s).await?;
34 let divs = diff_observations(op, &eng_obs, &k3s_obs, norm);
35 Ok(if divs.is_empty() {
36 Verdict::parity()
37 } else {
38 Verdict::Divergent(divs)
39 })
40}
41
42pub async fn run_strict(
49 op: &Operation,
50 engenho: &dyn DiffTarget,
51 k3s: &dyn DiffTarget,
52 norm: &Normalizer,
53) -> Result<(Vec<Divergence>, Vec<Divergence>), DiffError> {
54 let eng_obs = op_observe(op, engenho).await?;
55 let k3s_obs = op_observe(op, k3s).await?;
56 let hard = diff_observations(op, &eng_obs, &k3s_obs, norm);
57 let cosmetic = cosmetic_divergences(op, &eng_obs, &k3s_obs, &hard);
58 Ok((hard, cosmetic))
59}
60
61async fn op_observe(op: &Operation, target: &dyn DiffTarget) -> Result<Observation, DiffError> {
62 target
63 .raw(
64 op.method,
65 &op.path,
66 op.body_bytes().as_deref(),
67 op.content_type.as_deref(),
68 )
69 .await
70}
71
72#[must_use]
74pub fn diff_observations(
75 op: &Operation,
76 eng: &Observation,
77 k3s: &Observation,
78 norm: &Normalizer,
79) -> Vec<Divergence> {
80 if eng.status != k3s.status {
82 return status_divergence(op, eng.status, k3s.status);
83 }
84 if !eng.is_success() {
86 return Vec::new();
87 }
88 match op.kind {
90 OpKind::Object => diff_object_focused(
91 &norm.apply(&eng.json),
92 &norm.apply(&k3s.json),
93 op.focus.as_ref(),
94 Severity::Hard,
95 ),
96 OpKind::List => diff_list(&eng.json, &k3s.json, norm, Severity::Hard),
97 OpKind::Discovery => diff_discovery(&eng.json, &k3s.json),
98 }
99}
100
101fn diff_object_focused(
108 eng: &Value,
109 k3s: &Value,
110 focus: Option<&JsonPath>,
111 sev: Severity,
112) -> Vec<Divergence> {
113 match focus {
114 None => diff_object(eng, k3s, sev),
115 Some(p) => {
116 let en = p.get(eng).cloned().unwrap_or(Value::Null);
117 let kn = p.get(k3s).cloned().unwrap_or(Value::Null);
118 diff_object(&en, &kn, sev)
121 .into_iter()
122 .map(|d| d.reroot(p))
123 .collect()
124 }
125 }
126}
127
128#[must_use]
132fn cosmetic_divergences(
133 op: &Operation,
134 eng: &Observation,
135 k3s: &Observation,
136 hard: &[Divergence],
137) -> Vec<Divergence> {
138 if eng.status != k3s.status || !eng.is_success() {
139 return Vec::new();
140 }
141 let raw = match op.kind {
142 OpKind::Object => {
143 diff_object_focused(&eng.json, &k3s.json, op.focus.as_ref(), Severity::Cosmetic)
144 }
145 OpKind::List => diff_list_raw(&eng.json, &k3s.json, Severity::Cosmetic),
146 OpKind::Discovery => Vec::new(),
147 };
148 let hard_sigs: BTreeSet<String> = hard.iter().map(Divergence::signature).collect();
149 raw.into_iter()
150 .filter(|d| !hard_sigs.contains(&d.signature()))
151 .collect()
152}
153
154fn status_divergence(op: &Operation, eng: u16, k3s: u16) -> Vec<Divergence> {
155 let eng_ok = (200..300).contains(&eng);
156 let k3s_ok = (200..300).contains(&k3s);
157 if let Some((gvr, verb)) = &op.verb_probe
159 && eng_ok != k3s_ok
160 {
161 let present_on = if k3s_ok { Side::K3s } else { Side::Engenho };
162 return vec![Divergence::MissingVerb {
163 gvr: gvr.clone(),
164 verb: *verb,
165 present_on,
166 }];
167 }
168 if eng == 404 && k3s_ok {
170 return vec![Divergence::MissingResource {
171 path: JsonPath::root(),
172 present_on: Side::K3s,
173 }];
174 }
175 if k3s == 404 && eng_ok {
176 return vec![Divergence::MissingResource {
177 path: JsonPath::root(),
178 present_on: Side::Engenho,
179 }];
180 }
181 vec![Divergence::StatusCodeDiff {
182 op: op.id.clone(),
183 method: op.method,
184 engenho: eng,
185 k3s,
186 }]
187}
188
189#[must_use]
199pub fn diff_object(eng: &Value, k3s: &Value, sev: Severity) -> Vec<Divergence> {
200 let mut out = Vec::new();
201 walk(JsonPath::root(), eng, k3s, sev, &mut out);
202 out
203}
204
205fn walk(path: JsonPath, eng: &Value, k3s: &Value, sev: Severity, out: &mut Vec<Divergence>) {
206 match (eng, k3s) {
207 (Value::Object(e), Value::Object(k)) => {
208 for (key, kv) in k {
209 match e.get(key) {
210 None => out.push(Divergence::MissingDefault {
211 path: path.clone().key(key.clone()),
212 k3s_value: kv.clone(),
213 severity: sev,
214 }),
215 Some(ev) => walk(path.clone().key(key.clone()), ev, kv, sev, out),
216 }
217 }
218 for (key, ev) in e {
219 if !k.contains_key(key) {
220 out.push(Divergence::ExtraField {
221 path: path.clone().key(key.clone()),
222 side: Side::Engenho,
223 value: ev.clone(),
224 severity: sev,
225 });
226 }
227 }
228 }
229 (Value::Array(e), Value::Array(k)) => {
230 if e.len() == k.len() {
231 for (i, (ev, kv)) in e.iter().zip(k.iter()).enumerate() {
232 walk(path.clone().index(i), ev, kv, sev, out);
233 }
234 } else {
235 out.push(Divergence::FieldDiff {
236 path,
237 engenho: eng.clone(),
238 k3s: k3s.clone(),
239 severity: sev,
240 });
241 }
242 }
243 (e, k) if e == k => {}
244 (e, k) => out.push(Divergence::FieldDiff {
245 path,
246 engenho: e.clone(),
247 k3s: k.clone(),
248 severity: sev,
249 }),
250 }
251}
252
253fn diff_list(eng: &Value, k3s: &Value, norm: &Normalizer, sev: Severity) -> Vec<Divergence> {
257 let mut out = Vec::new();
258 envelope_check(eng, k3s, sev, &mut out);
260 let eng_items = items_by_name(eng);
261 let k3s_items = items_by_name(k3s);
262 let names: BTreeSet<&String> = eng_items.keys().chain(k3s_items.keys()).collect();
263 for name in names {
264 match (eng_items.get(name), k3s_items.get(name)) {
265 (Some(e), Some(k)) => {
266 out.extend(diff_object(&norm.apply(e), &norm.apply(k), sev));
267 }
268 (None, Some(_)) => out.push(Divergence::MissingResource {
269 path: JsonPath::root().key("items").key((*name).clone()),
270 present_on: Side::K3s,
271 }),
272 (Some(_), None) => out.push(Divergence::MissingResource {
273 path: JsonPath::root().key("items").key((*name).clone()),
274 present_on: Side::Engenho,
275 }),
276 (None, None) => {}
277 }
278 }
279 out
280}
281
282fn diff_list_raw(eng: &Value, k3s: &Value, sev: Severity) -> Vec<Divergence> {
285 let mut out = Vec::new();
286 let eng_items = items_by_name(eng);
287 let k3s_items = items_by_name(k3s);
288 for (name, e) in &eng_items {
289 if let Some(k) = k3s_items.get(name) {
290 out.extend(diff_object(e, k, sev));
291 }
292 }
293 out
294}
295
296fn envelope_check(eng: &Value, k3s: &Value, sev: Severity, out: &mut Vec<Divergence>) {
297 for field in ["kind", "apiVersion"] {
298 let ev = eng.get(field);
299 let kv = k3s.get(field);
300 if ev != kv {
301 match (ev, kv) {
302 (None, Some(k)) => out.push(Divergence::MissingDefault {
303 path: JsonPath::root().key(field),
304 k3s_value: k.clone(),
305 severity: sev,
306 }),
307 (Some(e), None) => out.push(Divergence::ExtraField {
308 path: JsonPath::root().key(field),
309 side: Side::Engenho,
310 value: e.clone(),
311 severity: sev,
312 }),
313 (Some(e), Some(k)) => out.push(Divergence::FieldDiff {
314 path: JsonPath::root().key(field),
315 engenho: e.clone(),
316 k3s: k.clone(),
317 severity: sev,
318 }),
319 (None, None) => {}
320 }
321 }
322 }
323}
324
325fn items_by_name(list: &Value) -> std::collections::BTreeMap<String, Value> {
326 let mut m = std::collections::BTreeMap::new();
327 if let Some(items) = list.get("items").and_then(Value::as_array) {
328 for it in items {
329 let name = it
330 .get("metadata")
331 .and_then(|md| md.get("name"))
332 .and_then(Value::as_str)
333 .unwrap_or_default()
334 .to_string();
335 m.insert(name, it.clone());
336 }
337 }
338 m
339}
340
341fn diff_discovery(eng: &Value, k3s: &Value) -> Vec<Divergence> {
344 let mut out = Vec::new();
345 let eng_res = resources_by_name(eng);
346 let k3s_res = resources_by_name(k3s);
347 for name in ["configmaps", "namespaces"] {
348 match (eng_res.get(name), k3s_res.get(name)) {
349 (Some(e), Some(k)) => diff_resource_entry(name, e, k, &mut out),
350 (None, Some(_)) => out.push(Divergence::MissingResource {
351 path: JsonPath::root().key("resources").key(name),
352 present_on: Side::K3s,
353 }),
354 (Some(_), None) => out.push(Divergence::MissingResource {
355 path: JsonPath::root().key("resources").key(name),
356 present_on: Side::Engenho,
357 }),
358 (None, None) => {}
359 }
360 }
361 for sub in ["namespaces/status", "namespaces/finalize"] {
363 let on_eng = eng_res.contains_key(sub);
364 let on_k3s = k3s_res.contains_key(sub);
365 if on_k3s && !on_eng {
366 out.push(Divergence::MissingSubresource {
367 gvr: Gvr {
368 group: String::new(),
369 version: "v1".into(),
370 resource: "namespaces".into(),
371 },
372 sub: sub.rsplit('/').next().unwrap_or(sub).to_string(),
373 present_on: Side::K3s,
374 });
375 }
376 }
377 out
378}
379
380fn resources_by_name(list: &Value) -> std::collections::BTreeMap<String, Value> {
381 let mut m = std::collections::BTreeMap::new();
382 if let Some(resources) = list.get("resources").and_then(Value::as_array) {
383 for r in resources {
384 if let Some(name) = r.get("name").and_then(Value::as_str) {
385 m.insert(name.to_string(), r.clone());
386 }
387 }
388 }
389 m
390}
391
392fn diff_resource_entry(name: &str, eng: &Value, k3s: &Value, out: &mut Vec<Divergence>) {
393 let e_ns = eng.get("namespaced").and_then(Value::as_bool);
395 let k_ns = k3s.get("namespaced").and_then(Value::as_bool);
396 if e_ns != k_ns {
397 let mut detail = String::from(name);
398 detail.push_str(": namespaced engenho=");
399 detail.push_str(&fmt_opt_bool(e_ns));
400 detail.push_str(" k3s=");
401 detail.push_str(&fmt_opt_bool(k_ns));
402 out.push(Divergence::DiscoveryDiff {
403 group: String::new(),
404 version: "v1".into(),
405 detail,
406 });
407 }
408 let e_verbs = str_set(eng.get("verbs"));
410 let k_verbs = str_set(k3s.get("verbs"));
411 let missing: Vec<&String> = k_verbs.difference(&e_verbs).collect();
412 let extra: Vec<&String> = e_verbs.difference(&k_verbs).collect();
413 if !missing.is_empty() {
414 let mut detail = String::from(name);
415 detail.push_str(": engenho missing verbs ");
416 detail.push_str(&join_refs(&missing));
417 out.push(Divergence::DiscoveryDiff {
418 group: String::new(),
419 version: "v1".into(),
420 detail,
421 });
422 }
423 if !extra.is_empty() {
424 let mut detail = String::from(name);
425 detail.push_str(": engenho extra verbs ");
426 detail.push_str(&join_refs(&extra));
427 out.push(Divergence::DiscoveryDiff {
428 group: String::new(),
429 version: "v1".into(),
430 detail,
431 });
432 }
433 let e_short = str_set(eng.get("shortNames"));
435 let k_short = str_set(k3s.get("shortNames"));
436 if e_short != k_short {
437 let mut detail = String::from(name);
438 detail.push_str(": shortNames differ (engenho ");
439 detail.push_str(&join_set(&e_short));
440 detail.push_str(", k3s ");
441 detail.push_str(&join_set(&k_short));
442 detail.push(')');
443 out.push(Divergence::DiscoveryDiff {
444 group: String::new(),
445 version: "v1".into(),
446 detail,
447 });
448 }
449}
450
451fn fmt_opt_bool(b: Option<bool>) -> String {
452 match b {
453 Some(true) => "true".into(),
454 Some(false) => "false".into(),
455 None => "unset".into(),
456 }
457}
458
459fn str_set(v: Option<&Value>) -> BTreeSet<String> {
460 v.and_then(Value::as_array)
461 .map(|a| {
462 a.iter()
463 .filter_map(Value::as_str)
464 .map(str::to_string)
465 .collect()
466 })
467 .unwrap_or_default()
468}
469
470fn join_refs(items: &[&String]) -> String {
471 let mut out = String::from("[");
472 for (i, s) in items.iter().enumerate() {
473 if i > 0 {
474 out.push_str(", ");
475 }
476 out.push_str(s);
477 }
478 out.push(']');
479 out
480}
481
482fn join_set(items: &BTreeSet<String>) -> String {
483 let refs: Vec<&String> = items.iter().collect();
484 join_refs(&refs)
485}
486
487impl Verdict {
489 pub(crate) fn parity() -> Self {
490 Self::Parity(crate::verdict::ParityWitness::new())
491 }
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use crate::normalize::volatile_meta;
498 use serde_json::json;
499
500 fn obs(status: u16, json: Value) -> Observation {
501 Observation {
502 status,
503 content_type: Some("application/json".into()),
504 json,
505 }
506 }
507
508 fn get_cm() -> Operation {
509 Operation::get_configmap("ns", "cm")
510 }
511
512 #[test]
513 fn identical_objects_are_parity() {
514 let norm = volatile_meta();
515 let body = json!({"kind": "ConfigMap", "metadata": {"name": "cm"}, "data": {"a": "b"}});
516 let d = diff_observations(&get_cm(), &obs(200, body.clone()), &obs(200, body), &norm);
517 assert!(d.is_empty(), "identical bodies must be parity, got {d:?}");
518 }
519
520 #[test]
521 fn masked_fields_do_not_diverge() {
522 let norm = volatile_meta();
523 let eng = json!({"metadata": {"name": "cm", "resourceVersion": "7"}, "data": {"a": "b"}});
524 let k3s = json!({"metadata": {"name": "cm", "resourceVersion": "90210", "uid": "abc",
525 "creationTimestamp": "2026-01-01T00:00:00Z"}, "data": {"a": "b"}});
526 let d = diff_observations(&get_cm(), &obs(200, eng), &obs(200, k3s), &norm);
527 assert!(d.is_empty(), "volatile meta must be masked, got {d:?}");
528 }
529
530 #[test]
531 fn missing_default_and_extra_field_detected() {
532 let norm = volatile_meta();
533 let eng = json!({"metadata": {"name": "ns", "finalizers": ["kubernetes"]}});
535 let k3s = json!({"metadata": {"name": "ns", "labels": {"kubernetes.io/metadata.name": "ns"}},
536 "spec": {"finalizers": ["kubernetes"]}});
537 let d = diff_observations(
538 &Operation::get_namespace("ns"),
539 &obs(200, eng),
540 &obs(200, k3s),
541 &norm,
542 );
543 let sigs: BTreeSet<String> = d.iter().map(Divergence::signature).collect();
544 assert!(
545 sigs.contains("MissingDefault:.spec"),
546 "want MissingDefault .spec, got {sigs:?}"
547 );
548 assert!(sigs.contains("MissingDefault:.metadata.labels"));
549 assert!(sigs.contains("ExtraField:.metadata.finalizers:engenho"));
550 }
551
552 #[test]
553 fn put_verb_probe_becomes_missing_verb() {
554 let norm = volatile_meta();
555 let op = Operation::replace_configmap("ns", "cm", json!({}));
556 let d = diff_observations(&op, &obs(400, json!({})), &obs(200, json!({})), &norm);
558 assert_eq!(d.len(), 1);
559 assert_eq!(d[0].signature(), "MissingVerb:v1/configmaps:update:k3s");
560 assert_eq!(d[0].owning_crate(), "engenho-apiserver");
561 }
562
563 #[test]
564 fn agreeing_status_is_parity() {
565 let norm = volatile_meta();
566 let d = diff_observations(&get_cm(), &obs(404, json!({})), &obs(404, json!({})), &norm);
568 assert!(d.is_empty());
569 let op = Operation::merge_patch_configmap("ns", "cm", json!({}));
571 let d = diff_observations(&op, &obs(500, json!({})), &obs(200, json!({})), &norm);
572 assert_eq!(d.len(), 1);
573 assert!(matches!(d[0], Divergence::StatusCodeDiff { .. }));
574 }
575
576 #[test]
577 fn discovery_missing_verbs_detected() {
578 let eng = json!({"resources": [
579 {"name": "configmaps", "namespaced": true, "kind": "ConfigMap", "verbs": ["get", "list", "create"]},
580 {"name": "namespaces", "namespaced": false, "kind": "Namespace", "verbs": ["get", "list"]},
581 ]});
582 let k3s = json!({"resources": [
583 {"name": "configmaps", "namespaced": true, "kind": "ConfigMap", "verbs": ["get", "list", "create", "update", "patch", "delete"], "shortNames": ["cm"]},
584 {"name": "namespaces", "namespaced": false, "kind": "Namespace", "verbs": ["get", "list"], "shortNames": ["ns"]},
585 {"name": "namespaces/status", "namespaced": false, "kind": "Namespace"},
586 ]});
587 let op = Operation::discovery_core_v1();
588 let d = diff_observations(&op, &obs(200, eng), &obs(200, k3s), &Normalizer::new());
589 let sigs: Vec<String> = d.iter().map(Divergence::signature).collect();
590 assert!(
591 sigs.iter().any(|s| s.contains("engenho missing verbs")),
592 "want a missing-verbs discovery diff, got {sigs:?}"
593 );
594 assert!(
595 d.iter()
596 .any(|x| matches!(x, Divergence::MissingSubresource { .. })),
597 "want namespaces/status subresource gap, got {d:?}"
598 );
599 }
600
601 #[tokio::test]
604 async fn two_mock_clients_feed_the_differ() {
605 use engenho_kube_client::mock::MockKubeClient;
606 use engenho_types::client::KubeClient;
607
608 let a = MockKubeClient::new();
609 let b = MockKubeClient::new();
610 let pod = toy::pod("p", "default", "img:1");
611 let ca = a.create(&pod).await.unwrap();
612 let cb = b.create(&pod).await.unwrap();
613 let norm = volatile_meta();
614 let d = diff_observations(
615 &get_cm(),
616 &obs(200, serde_json::to_value(&ca).unwrap()),
617 &obs(200, serde_json::to_value(&cb).unwrap()),
618 &norm,
619 );
620 assert!(
621 d.is_empty(),
622 "identical mock objects must be parity, got {d:?}"
623 );
624
625 let cb2 = b.create(&toy::pod("p2", "default", "img:2")).await.unwrap();
627 let ca2 = a.create(&toy::pod("p2", "default", "img:1")).await.unwrap();
628 let d2 = diff_observations(
629 &get_cm(),
630 &obs(200, serde_json::to_value(&ca2).unwrap()),
631 &obs(200, serde_json::to_value(&cb2).unwrap()),
632 &norm,
633 );
634 assert!(
635 d2.iter().any(|x| matches!(x, Divergence::FieldDiff { .. })),
636 "want a FieldDiff on the mutated image, got {d2:?}"
637 );
638 }
639
640 mod toy {
642 use engenho_types::kind::{GroupVersionKind, GroupVersionResource, KubeResource, Scope};
643 use serde::{Deserialize, Serialize};
644
645 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
646 pub struct ToyPod {
647 pub metadata: engenho_types::meta::ObjectMeta,
648 pub spec: ToySpec,
649 }
650 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
651 pub struct ToySpec {
652 pub image: String,
653 }
654 impl KubeResource for ToyPod {
655 const GVK: GroupVersionKind = GroupVersionKind {
656 group: "",
657 version: "v1",
658 kind: "ToyPod",
659 };
660 const GVR: GroupVersionResource = GroupVersionResource {
661 group: "",
662 version: "v1",
663 resource: "toypods",
664 };
665 const SCOPE: Scope = Scope::Namespaced;
666 fn name(&self) -> std::borrow::Cow<'_, str> {
667 self.metadata.name.as_str().into()
668 }
669 fn namespace(&self) -> Option<std::borrow::Cow<'_, str>> {
670 self.metadata.namespace.as_deref().map(Into::into)
671 }
672 fn resource_version(&self) -> Option<std::borrow::Cow<'_, str>> {
673 if self.metadata.resource_version.is_empty() {
674 None
675 } else {
676 Some(self.metadata.resource_version.as_str().into())
677 }
678 }
679 }
680 pub fn pod(name: &str, ns: &str, image: &str) -> ToyPod {
681 ToyPod {
682 metadata: engenho_types::meta::ObjectMeta {
683 name: name.into(),
684 namespace: Some(ns.into()),
685 ..Default::default()
686 },
687 spec: ToySpec {
688 image: image.into(),
689 },
690 }
691 }
692 }
693}