Skip to main content

engenho_diff/
cotejo.rs

1//! `cotejo` (Portuguese: *a collation / side-by-side comparison*) — the diff
2//! interpreter. It executes an [`Operation`] against two [`DiffTarget`]s and
3//! folds the paired [`Observation`]s through the [`Normalizer`] into a typed
4//! [`Verdict`]. The core differ ([`diff_observations`]) is a pure function
5//! over two `Observation`s + a `Normalizer` — unit-testable with zero
6//! clusters.
7
8use 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
19/// Execute `op` on both targets and diff the results (HARD divergences
20/// only — the masked comparison). `Parity` is returned iff the divergence
21/// set is empty (proof-by-absence).
22///
23/// # Errors
24///
25/// Propagates a [`DiffError`] if either target's transport fails.
26pub 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
42/// Like [`run`] but also returns the masked/cosmetic divergences (strict
43/// mode) so the masks stay auditable. Returns `(hard, cosmetic)`.
44///
45/// # Errors
46///
47/// Propagates a [`DiffError`] if either target's transport fails.
48pub 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/// The pure differ: HARD divergences between two observations under `norm`.
73#[must_use]
74pub fn diff_observations(
75    op: &Operation,
76    eng: &Observation,
77    k3s: &Observation,
78    norm: &Normalizer,
79) -> Vec<Divergence> {
80    // 1. Status-code layer — a mismatch short-circuits body diffing.
81    if eng.status != k3s.status {
82        return status_divergence(op, eng.status, k3s.status);
83    }
84    // Both sides agree on a non-success status ⇒ they agree (e.g. both 404).
85    if !eng.is_success() {
86        return Vec::new();
87    }
88    // 2. Body layer — dispatch on the surface class.
89    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
101/// Diff two (already-normalized) objects, optionally restricted to the
102/// subtree at `focus`. FOCUS mode isolates a field-level invariant from a
103/// kind's unrelated server-side-defaulting divergence: the diff runs on the
104/// focused subtree of BOTH sides (an absent focus on one side surfaces
105/// naturally as a `MissingDefault`/`ExtraField` at the focus root). Without a
106/// focus it is exactly [`diff_object`] over the whole object.
107fn 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            // Re-root the divergence paths at the focus so the report + ratchet
119            // signature name the real location, not `.`.
120            diff_object(&en, &kn, sev)
121                .into_iter()
122                .map(|d| d.reroot(p))
123                .collect()
124        }
125    }
126}
127
128/// Masked (cosmetic) divergences: those present in the RAW comparison but
129/// suppressed by the normalizer. Surfaced so a mask can never silently hide
130/// a real gap.
131#[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    // A verb probe whose two sides disagree on success ⇒ a refused verb.
158    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    // A 404-vs-success (no verb probe) ⇒ a missing resource.
169    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/// Recursive object differ. `k3s` is the oracle: a key on k3s that engenho
190/// lacks is a `MissingDefault`; a key engenho adds that k3s lacks is an
191/// `ExtraField`; a shared key with differing values is a `FieldDiff`.
192/// Diff two already-normalized JSON objects.
193///
194/// Public because the OCI-runtime differential ([`crate::butai`]) compares
195/// `state` documents with exactly these semantics and must not grow a second
196/// recursive JSON differ that can disagree with this one about what counts as
197/// a difference.
198#[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
253/// List differ: match items by `metadata.name`, normalize + diff each, and
254/// flag names present on only one side. Isolates the comparison from
255/// cluster-auto-created objects.
256fn diff_list(eng: &Value, k3s: &Value, norm: &Normalizer, sev: Severity) -> Vec<Divergence> {
257    let mut out = Vec::new();
258    // Envelope kind/apiVersion.
259    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
282/// Raw (unmasked) list diff for the cosmetic pass — per-item without
283/// normalization.
284fn 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
341/// Discovery differ — compares the `configmaps` and `namespaces` entries in
342/// a core/v1 `APIResourceList`, plus the namespace subresources.
343fn 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    // Namespace subresources k3s exposes.
362    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    // namespaced flag.
394    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    // verb set differences.
409    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    // shortNames set differences (informational).
434    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
487/// The single crate-internal Parity constructor — see [`Verdict`].
488impl 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        // k3s has spec.finalizers; engenho has metadata.finalizers instead.
534        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        // engenho 400 (router refuses PUT), k3s 200.
557        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        // Both 404 ⇒ they agree, no divergence.
567        let d = diff_observations(&get_cm(), &obs(404, json!({})), &obs(404, json!({})), &norm);
568        assert!(d.is_empty());
569        // Different status ⇒ StatusCodeDiff.
570        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    // Two MockKubeClient instances (zero clusters) exercised through the
602    // differ: identical objects ⇒ parity, a mutated field ⇒ FieldDiff.
603    #[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        // Mutate b's image ⇒ a real FieldDiff at .spec.image.
626        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    /// A minimal typed resource for the two-mock-clients test.
641    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}