Skip to main content

engenho_diff/
op.rs

1//! The stimulus half of the harness — a typed [`Operation`] fired at both
2//! targets. Constructors build the K8s REST path from typed segments (a
3//! [`RestPath`], rendered via `Display`), never a `format!()`-composed URL.
4
5use serde_json::{Value, json};
6
7use crate::observe::HttpMethod;
8use crate::path::JsonPath;
9use crate::verdict::{Gvr, Verb};
10
11/// What KIND of surface an operation exercises — object CRUD, a list
12/// collection, or the discovery index. The differ dispatches on this.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum OpKind {
15    /// A single object round-trip (create/get/patch/replace/delete).
16    Object,
17    /// A collection LIST.
18    List,
19    /// A discovery index (`/api/v1`, `/apis/<g>/<v>`).
20    Discovery,
21}
22
23/// A typed REST path builder — segments in, a `/`-joined path out. Keeps
24/// path construction typed (no `format!()` of URL syntax, ★★ TYPED EMISSION).
25#[derive(Clone, Debug, Default)]
26pub struct RestPath(Vec<String>);
27
28impl RestPath {
29    #[must_use]
30    pub fn new() -> Self {
31        Self::default()
32    }
33
34    #[must_use]
35    pub fn seg(mut self, s: impl Into<String>) -> Self {
36        self.0.push(s.into());
37        self
38    }
39
40    /// Render to a leading-slash path string.
41    #[must_use]
42    pub fn render(&self) -> String {
43        let mut out = String::new();
44        for s in &self.0 {
45            out.push('/');
46            out.push_str(s);
47        }
48        out
49    }
50}
51
52/// One authored operation.
53#[derive(Clone, Debug)]
54pub struct Operation {
55    /// Stable identifier (report + status-diff signature).
56    pub id: String,
57    /// The HTTP verb.
58    pub method: HttpMethod,
59    /// The REST path (already rendered, incl. any query string).
60    pub path: String,
61    /// Request body, if any.
62    pub body: Option<Value>,
63    /// `Content-Type` header, if any.
64    pub content_type: Option<String>,
65    /// When set, a status mismatch on this op is reported as a
66    /// [`Divergence::MissingVerb`](crate::verdict::Divergence::MissingVerb)
67    /// (one side supports the verb, the other refuses) rather than a bare
68    /// status-code diff.
69    pub verb_probe: Option<(Gvr, Verb)>,
70    /// The surface class.
71    pub kind: OpKind,
72    /// When set (for [`OpKind::Object`]), the differ restricts the object
73    /// comparison to the subtree at this path — a FIELD-LEVEL probe. It
74    /// isolates a targeted invariant (e.g. "a `/status` PUT left
75    /// `spec.containers[0].image` unchanged") from a kind's unrelated
76    /// server-side-defaulting divergence, so a Pod field-diff need not carry
77    /// the whole (heavily-defaulted) `PodSpec` into the ratchet.
78    pub focus: Option<JsonPath>,
79}
80
81fn configmaps_gvr() -> Gvr {
82    Gvr {
83        group: String::new(),
84        version: "v1".into(),
85        resource: "configmaps".into(),
86    }
87}
88
89impl Operation {
90    // ── internal builders (ONE construction shape; ★ standardize) ────────
91    //
92    // Every public constructor routes through `object`/`list`/`discovery` +
93    // the `with_*` builders, so a new field (like `focus`) is added in ONE
94    // place, not fanned across a dozen struct literals.
95
96    fn object(id: String, method: HttpMethod, path: String) -> Self {
97        Self {
98            id,
99            method,
100            path,
101            body: None,
102            content_type: None,
103            verb_probe: None,
104            kind: OpKind::Object,
105            focus: None,
106        }
107    }
108
109    #[must_use]
110    fn with_body(mut self, body: Value, content_type: &str) -> Self {
111        self.body = Some(body);
112        self.content_type = Some(content_type.into());
113        self
114    }
115
116    #[must_use]
117    fn with_kind(mut self, kind: OpKind) -> Self {
118        self.kind = kind;
119        self
120    }
121
122    #[must_use]
123    fn with_verb_probe(mut self, gvr: Gvr, verb: Verb) -> Self {
124        self.verb_probe = Some((gvr, verb));
125        self
126    }
127
128    /// Restrict the object diff to the subtree at `focus` (a field-level
129    /// probe). Builder style; see [`Operation::focus`].
130    #[must_use]
131    pub fn with_focus(mut self, focus: JsonPath) -> Self {
132        self.focus = Some(focus);
133        self
134    }
135
136    /// `/api/v1/namespaces/<ns>/<plural>` — the namespaced collection path.
137    fn ns_collection(ns: &str, plural: &str) -> RestPath {
138        RestPath::new()
139            .seg("api")
140            .seg("v1")
141            .seg("namespaces")
142            .seg(ns)
143            .seg(plural)
144    }
145
146    fn ns_configmaps(ns: &str) -> RestPath {
147        Self::ns_collection(ns, "configmaps")
148    }
149
150    fn ident(prefix: &str, name: &str) -> String {
151        let mut id = String::from(prefix);
152        id.push_str(name);
153        id
154    }
155
156    /// POST a new Namespace.
157    #[must_use]
158    pub fn create_namespace(name: &str) -> Self {
159        Self::object(
160            Self::ident("create_namespace/", name),
161            HttpMethod::Post,
162            RestPath::new()
163                .seg("api")
164                .seg("v1")
165                .seg("namespaces")
166                .render(),
167        )
168        .with_body(
169            json!({
170                "apiVersion": "v1",
171                "kind": "Namespace",
172                "metadata": {"name": name},
173            }),
174            "application/json",
175        )
176    }
177
178    /// GET a Namespace (where server-side defaulting divergences surface).
179    #[must_use]
180    pub fn get_namespace(name: &str) -> Self {
181        Self::object(
182            Self::ident("get_namespace/", name),
183            HttpMethod::Get,
184            RestPath::new()
185                .seg("api")
186                .seg("v1")
187                .seg("namespaces")
188                .seg(name)
189                .render(),
190        )
191    }
192
193    /// POST a new `ConfigMap` with the given `data`.
194    #[must_use]
195    pub fn create_configmap(ns: &str, name: &str, data: Value) -> Self {
196        Self::object(
197            Self::ident("create_configmap/", name),
198            HttpMethod::Post,
199            Self::ns_configmaps(ns).render(),
200        )
201        .with_body(
202            json!({
203                "apiVersion": "v1",
204                "kind": "ConfigMap",
205                "metadata": {"name": name},
206                "data": data,
207            }),
208            "application/json",
209        )
210    }
211
212    /// GET a `ConfigMap`.
213    #[must_use]
214    pub fn get_configmap(ns: &str, name: &str) -> Self {
215        Self::object(
216            Self::ident("get_configmap/", name),
217            HttpMethod::Get,
218            Self::ns_configmaps(ns).seg(name).render(),
219        )
220    }
221
222    /// POST a `ConfigMap` carrying `labels` (the selector-test vehicle). The
223    /// labels drive `labelSelector` matching; the trivial `data` keeps the
224    /// object valid.
225    #[must_use]
226    pub fn create_labeled_configmap(ns: &str, name: &str, labels: &[(&str, &str)]) -> Self {
227        let labels: serde_json::Map<String, Value> = labels
228            .iter()
229            .map(|(k, v)| ((*k).to_string(), Value::String((*v).to_string())))
230            .collect();
231        Self::object(
232            Self::ident("create_configmap/", name),
233            HttpMethod::Post,
234            Self::ns_configmaps(ns).render(),
235        )
236        .with_body(
237            json!({
238                "apiVersion": "v1",
239                "kind": "ConfigMap",
240                "metadata": {"name": name, "labels": Value::Object(labels)},
241                "data": {"a": "b"},
242            }),
243            "application/json",
244        )
245    }
246
247    /// LIST `ConfigMaps` filtered to a single name via `fieldSelector`
248    /// (isolates the diff from cluster-auto-created objects like
249    /// `kube-root-ca.crt`). The `=` in the selector is percent-encoded.
250    #[must_use]
251    pub fn list_configmaps(ns: &str, name_filter: &str) -> Self {
252        let mut path = Self::ns_configmaps(ns).render();
253        path.push_str("?fieldSelector=metadata.name%3D");
254        path.push_str(name_filter);
255        Self::object(
256            Self::ident("list_configmaps/", name_filter),
257            HttpMethod::Get,
258            path,
259        )
260        .with_kind(OpKind::List)
261    }
262
263    /// PATCH a `ConfigMap` (JSON merge patch).
264    #[must_use]
265    pub fn merge_patch_configmap(ns: &str, name: &str, patch: Value) -> Self {
266        Self::object(
267            Self::ident("merge_patch_configmap/", name),
268            HttpMethod::Patch,
269            Self::ns_configmaps(ns).seg(name).render(),
270        )
271        .with_body(patch, "application/merge-patch+json")
272    }
273
274    /// PUT (replace) a `ConfigMap` — the sharp verb probe. engenho's router
275    /// refuses PUT on the main object (400); k3s returns 200. A status
276    /// mismatch here becomes `MissingVerb{Update, present_on: K3s}`.
277    #[must_use]
278    pub fn replace_configmap(ns: &str, name: &str, body: Value) -> Self {
279        Self::object(
280            Self::ident("replace_configmap/", name),
281            HttpMethod::Put,
282            Self::ns_configmaps(ns).seg(name).render(),
283        )
284        .with_body(body, "application/json")
285        .with_verb_probe(configmaps_gvr(), Verb::Update)
286    }
287
288    /// DELETE a `ConfigMap`.
289    #[must_use]
290    pub fn delete_configmap(ns: &str, name: &str) -> Self {
291        Self::object(
292            Self::ident("delete_configmap/", name),
293            HttpMethod::Delete,
294            Self::ns_configmaps(ns).seg(name).render(),
295        )
296    }
297
298    /// DELETE a Namespace (cleanup on the shared k3s cluster).
299    #[must_use]
300    pub fn delete_namespace(name: &str) -> Self {
301        Self::object(
302            Self::ident("delete_namespace/", name),
303            HttpMethod::Delete,
304            RestPath::new()
305                .seg("api")
306                .seg("v1")
307                .seg("namespaces")
308                .seg(name)
309                .render(),
310        )
311    }
312
313    /// GET the core/v1 discovery index.
314    #[must_use]
315    pub fn discovery_core_v1() -> Self {
316        Self::object(
317            "discovery/api/v1".into(),
318            HttpMethod::Get,
319            RestPath::new().seg("api").seg("v1").render(),
320        )
321        .with_kind(OpKind::Discovery)
322    }
323
324    // ── PATCH matrix (merge / strategic / json) over any kind ────────────
325
326    /// PATCH an object with an `application/merge-patch+json` body (RFC 7386).
327    #[must_use]
328    pub fn merge_patch(ns: &str, plural: &str, name: &str, patch: Value) -> Self {
329        Self::object(
330            Self::ident("merge_patch/", name),
331            HttpMethod::Patch,
332            Self::ns_collection(ns, plural).seg(name).render(),
333        )
334        .with_body(patch, "application/merge-patch+json")
335    }
336
337    /// PATCH an object with an `application/strategic-merge-patch+json` body
338    /// (K8s strategic merge — list-merge-by-key, `$patch: delete`,
339    /// null-removes-key).
340    #[must_use]
341    pub fn strategic_patch(ns: &str, plural: &str, name: &str, patch: Value) -> Self {
342        Self::object(
343            Self::ident("strategic_patch/", name),
344            HttpMethod::Patch,
345            Self::ns_collection(ns, plural).seg(name).render(),
346        )
347        .with_body(patch, "application/strategic-merge-patch+json")
348    }
349
350    /// PATCH an object with an `application/json-patch+json` body (RFC 6902 —
351    /// an op array).
352    #[must_use]
353    pub fn json_patch(ns: &str, plural: &str, name: &str, ops: Value) -> Self {
354        Self::object(
355            Self::ident("json_patch/", name),
356            HttpMethod::Patch,
357            Self::ns_collection(ns, plural).seg(name).render(),
358        )
359        .with_body(ops, "application/json-patch+json")
360    }
361
362    /// GET an object of any kind.
363    #[must_use]
364    pub fn get(ns: &str, plural: &str, name: &str) -> Self {
365        Self::object(
366            Self::ident("get/", name),
367            HttpMethod::Get,
368            Self::ns_collection(ns, plural).seg(name).render(),
369        )
370    }
371
372    // ── Pod (the strategic-list-merge + status-subresource vehicle) ──────
373
374    /// POST a minimal single-container Pod (container named `c`).
375    #[must_use]
376    pub fn create_pod(ns: &str, name: &str, image: &str) -> Self {
377        Self::create_pod_containers(ns, name, &[("c", image)])
378    }
379
380    /// POST a Pod with the given `(container-name, image)` list — the
381    /// vehicle for the strategic list-merge-by-key probe (patch ONE container
382    /// by `name`, prove the others survive).
383    #[must_use]
384    pub fn create_pod_containers(ns: &str, name: &str, containers: &[(&str, &str)]) -> Self {
385        let containers: Vec<Value> = containers
386            .iter()
387            .map(|(n, img)| json!({"name": n, "image": img}))
388            .collect();
389        Self::object(
390            Self::ident("create_pod/", name),
391            HttpMethod::Post,
392            Self::ns_collection(ns, "pods").render(),
393        )
394        .with_body(
395            json!({
396                "apiVersion": "v1",
397                "kind": "Pod",
398                "metadata": {"name": name},
399                "spec": {"containers": containers},
400            }),
401            "application/json",
402        )
403    }
404
405    /// DELETE a Pod (force, no grace, best-effort cleanup on the oracle).
406    #[must_use]
407    pub fn delete_pod(ns: &str, name: &str) -> Self {
408        let mut path = Self::ns_collection(ns, "pods").seg(name).render();
409        path.push_str("?gracePeriodSeconds=0");
410        Self::object(Self::ident("delete_pod/", name), HttpMethod::Delete, path)
411    }
412
413    // ── status subresource ───────────────────────────────────────────────
414
415    /// GET the `/status` subresource of an object.
416    #[must_use]
417    pub fn get_status(ns: &str, plural: &str, name: &str) -> Self {
418        Self::object(
419            Self::ident("get_status/", name),
420            HttpMethod::Get,
421            Self::ns_collection(ns, plural)
422                .seg(name)
423                .seg("status")
424                .render(),
425        )
426    }
427
428    /// PUT the `/status` subresource of an object.
429    #[must_use]
430    pub fn put_status(ns: &str, plural: &str, name: &str, body: Value) -> Self {
431        Self::object(
432            Self::ident("put_status/", name),
433            HttpMethod::Put,
434            Self::ns_collection(ns, plural)
435                .seg(name)
436                .seg("status")
437                .render(),
438        )
439        .with_body(body, "application/json")
440    }
441
442    /// PUT (replace) the MAIN object of any kind.
443    #[must_use]
444    pub fn replace(ns: &str, plural: &str, name: &str, body: Value) -> Self {
445        Self::object(
446            Self::ident("replace/", name),
447            HttpMethod::Put,
448            Self::ns_collection(ns, plural).seg(name).render(),
449        )
450        .with_body(body, "application/json")
451    }
452
453    // ── LIST with a selector (label + field) ─────────────────────────────
454
455    /// LIST a namespaced collection with an optional `labelSelector` and/or
456    /// `fieldSelector`. Both selector strings are percent-encoded through the
457    /// typed [`pct_encode`] (never a raw `format!()` of the query), so the
458    /// full k8s selector grammar (`in (a,b)`, `!k`, `k!=v`) round-trips on the
459    /// wire.
460    #[must_use]
461    pub fn list_selector(
462        ns: &str,
463        plural: &str,
464        id_tag: &str,
465        label_selector: Option<&str>,
466        field_selector: Option<&str>,
467    ) -> Self {
468        let mut path = Self::ns_collection(ns, plural).render();
469        let mut sep = '?';
470        if let Some(ls) = label_selector {
471            path.push(sep);
472            path.push_str("labelSelector=");
473            path.push_str(&pct_encode(ls));
474            sep = '&';
475        }
476        if let Some(fs) = field_selector {
477            path.push(sep);
478            path.push_str("fieldSelector=");
479            path.push_str(&pct_encode(fs));
480        }
481        Self::object(Self::ident("list_selector/", id_tag), HttpMethod::Get, path)
482            .with_kind(OpKind::List)
483    }
484
485    /// The body serialized to bytes, if present.
486    #[must_use]
487    pub fn body_bytes(&self) -> Option<Vec<u8>> {
488        self.body
489            .as_ref()
490            .map(|b| serde_json::to_vec(b).unwrap_or_default())
491    }
492}
493
494/// Percent-encode a query-parameter VALUE per RFC 3986 (encode everything
495/// outside the unreserved set). Typed byte-wise encoder — NOT a `format!()`
496/// of the query string; the hex digits come from a fixed table.
497#[must_use]
498pub fn pct_encode(s: &str) -> String {
499    const HEX: &[u8; 16] = b"0123456789ABCDEF";
500    let mut out = String::with_capacity(s.len());
501    for &b in s.as_bytes() {
502        match b {
503            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
504                out.push(b as char);
505            }
506            _ => {
507                out.push('%');
508                out.push(HEX[(b >> 4) as usize] as char);
509                out.push(HEX[(b & 0x0f) as usize] as char);
510            }
511        }
512    }
513    out
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    #[test]
521    fn paths_render_typed() {
522        assert_eq!(Operation::create_namespace("x").path, "/api/v1/namespaces");
523        assert_eq!(
524            Operation::get_configmap("ns", "cm").path,
525            "/api/v1/namespaces/ns/configmaps/cm"
526        );
527        assert_eq!(
528            Operation::list_configmaps("ns", "cm").path,
529            "/api/v1/namespaces/ns/configmaps?fieldSelector=metadata.name%3Dcm"
530        );
531    }
532
533    #[test]
534    fn replace_carries_verb_probe() {
535        let op = Operation::replace_configmap("ns", "cm", json!({}));
536        let (gvr, verb) = op.verb_probe.expect("replace is a verb probe");
537        assert_eq!(gvr.resource, "configmaps");
538        assert_eq!(verb, Verb::Update);
539    }
540
541    #[test]
542    fn patch_variants_carry_the_content_type() {
543        assert_eq!(
544            Operation::merge_patch("ns", "configmaps", "cm", json!({})).content_type,
545            Some("application/merge-patch+json".into())
546        );
547        assert_eq!(
548            Operation::strategic_patch("ns", "pods", "p", json!({})).content_type,
549            Some("application/strategic-merge-patch+json".into())
550        );
551        assert_eq!(
552            Operation::json_patch("ns", "pods", "p", json!([])).content_type,
553            Some("application/json-patch+json".into())
554        );
555    }
556
557    #[test]
558    fn status_paths_render() {
559        assert_eq!(
560            Operation::get_status("ns", "pods", "p").path,
561            "/api/v1/namespaces/ns/pods/p/status"
562        );
563        assert_eq!(
564            Operation::put_status("ns", "pods", "p", json!({})).path,
565            "/api/v1/namespaces/ns/pods/p/status"
566        );
567    }
568
569    #[test]
570    fn selector_query_is_percent_encoded() {
571        let op =
572            Operation::list_selector("ns", "configmaps", "in", Some("tier in (web,api)"), None);
573        assert_eq!(
574            op.path,
575            "/api/v1/namespaces/ns/configmaps?labelSelector=tier%20in%20%28web%2Capi%29"
576        );
577        assert_eq!(op.kind, OpKind::List);
578        let both = Operation::list_selector(
579            "ns",
580            "configmaps",
581            "ne",
582            Some("a!=b"),
583            Some("metadata.name=x"),
584        );
585        assert_eq!(
586            both.path,
587            "/api/v1/namespaces/ns/configmaps?labelSelector=a%21%3Db&fieldSelector=metadata.name%3Dx"
588        );
589    }
590
591    #[test]
592    fn focus_is_carried() {
593        let op = Operation::get("ns", "pods", "p")
594            .with_focus(JsonPath::parse("spec.containers[0].image"));
595        assert_eq!(
596            op.focus.map(|p| p.to_string()),
597            Some(".spec.containers[0].image".to_string())
598        );
599    }
600}