Skip to main content

tatara_process/
k8s_object_ref.rs

1//! `K8sObjectRef` — typed 3-slot `(kind, name, namespace)` cross-
2//! resource reference, and the composer that emits the canonical
3//! `{ "kind": …, "name": …, "namespace": … }` JSON pointer one K8s
4//! resource carries at a `sourceRef` / `chartRef` / equivalent slot
5//! to point at another K8s resource.
6//!
7//! Pre-lift the 3-slot `{kind, name, namespace}` shape was hand-
8//! authored across THREE production emit sites in
9//! `tatara-reconciler::render` past the ★★ PRIME-DIRECTIVE ≥ 2
10//! duplication threshold — the same JSON shape recurred three times
11//! at distinct callers pointing at three distinct K8s resource kinds
12//! (`GitRepository`, `OCIRepository`, `HelmRepository`):
13//!
14//! * `render_flux` — the `Kustomization.spec.sourceRef` block
15//!   pointing at a `GitRepository` (with `namespace` falling back to
16//!   `"flux-system"` when unspecified).
17//! * `render_aplicacao` — the `HelmRelease.spec.chartRef` block
18//!   pointing at an `OCIRepository` (its `kind` slot already sourced
19//!   through the [`crate::flux_resource::FluxResource::OCIRepository`]
20//!   closed-set variant's `.kind()`).
21//! * `render_aplicacao` — the `HelmRelease.spec.chart.spec.sourceRef`
22//!   block pointing at a `HelmRepository` (in `"flux-system"`).
23//!
24//! Every pre-lift site restated the same three `Value::String` slot
25//! insertions in the same key order — a caller who omitted one slot,
26//! swapped `"name"` and `"namespace"`, or added a stray fourth slot
27//! (`apiVersion` — a slot the K8s cross-reference form deliberately
28//! excludes because it is derived from `kind` by the owning
29//! controller) would surface only as a wire-time error at the K8s
30//! API server, not at the emit site. Post-lift each emit site
31//! composes ONE [`K8sObjectRef`] and calls [`Self::as_json`]; the
32//! 3-slot shape lives at ONE composer and rustc enforces every
33//! consumer stamps exactly the (kind, name, namespace) triple with
34//! no fourth-slot drift.
35//!
36//! Sibling to the same-axis substrate primitive
37//! [`crate::k8s_wire_identity::K8sWireIdentity`] on the K8s wire-
38//! form identity axis. [`K8sWireIdentity`] owns the
39//! `(apiVersion, kind)` pair a K8s resource carries at its TOP-LEVEL
40//! identity slots (its own `apiVersion` + `kind` keys); this
41//! primitive owns the `(kind, name, namespace)` triple a K8s
42//! resource carries in a NESTED reference slot (a `sourceRef` /
43//! `chartRef` pointing at another resource — `apiVersion` deliberately
44//! omitted because the owning controller derives it from `kind`).
45//! The projection [`K8sWireIdentity::object_ref`] bridges the two:
46//! any typed closed-set variant that projects a `K8sWireIdentity`
47//! (via [`crate::flux_resource::FluxResource::wire_identity`] or
48//! [`crate::routing_edge_resource::RoutingEdgeResource::wire_identity`])
49//! composes a `K8sObjectRef` mechanically without restating the
50//! `kind` slot at the reference site.
51//!
52//! Extension: a new cross-reference site the reconciler grows (a
53//! Flux `Alert.spec.providerRef` at a `Provider`, a Gateway API
54//! `HTTPRoute.spec.parentRefs[i]` at a `Gateway`, a Cloudflare CR
55//! that references a `Secret` by `sourceRef`) lands as ONE
56//! `.as_json()` call at the emit site; every axis' typed closed
57//! set inherits the composer through [`K8sWireIdentity::object_ref`]
58//! without a new primitive per axis.
59//!
60//! Theory grounding: THEORY.md §II.1 invariant 5 (composition
61//! preserves proofs — the three-slot cross-reference composition
62//! lives at ONE typed algebra composer here; a regression that
63//! drifted any one slot at ONE consumer would fail-loudly at this
64//! module's byte-shape pins rather than as silent wire-form skew
65//! at the K8s API server). THEORY.md §VI.1 (generation over
66//! composition — the 3-slot shape recurred at three hand-authored
67//! sites past the PRIME-DIRECTIVE ≥ 2 duplication trigger, and is
68//! lifted to ONE composer here).
69
70use serde_json::{Map, Value};
71
72use crate::k8s_wire_identity::K8sWireIdentity;
73
74/// K8s cross-resource reference — the 3-slot
75/// `{ "kind", "name", "namespace" }` pointer one K8s resource
76/// carries at a `sourceRef` / `chartRef` / equivalent nested slot
77/// to point at another K8s resource in the cluster.
78///
79/// The slot set is intentionally the 3-slot form (no `apiVersion`)
80/// used by FluxCD's `sourceRef` + `chartRef` slots and the K8s
81/// `TypedLocalObjectReference` / `TypedObjectReference` shapes: the
82/// owning controller derives the apiVersion from `kind` (typically
83/// by dispatch through its own resource registry), so an emit site
84/// that hand-stamped an `apiVersion` here would either be ignored
85/// (adding a fourth slot no controller reads) or silently take
86/// precedence over the controller's derivation and mis-route
87/// resolution. The composer at [`Self::as_json`] emits exactly the
88/// 3-slot shape — a regression that added or removed a slot would
89/// surface at this module's byte-shape pins.
90#[derive(Clone, Debug, PartialEq, Eq, Hash)]
91pub struct K8sObjectRef {
92    /// K8s wire-form `kind` string of the referenced resource — the
93    /// PascalCase identifier the owning controller matches against
94    /// its resource registry to derive the apiVersion + REST route.
95    pub kind: String,
96    /// K8s `metadata.name` of the referenced resource — the API-path
97    /// leaf segment.
98    pub name: String,
99    /// K8s `metadata.namespace` of the referenced resource. The K8s
100    /// cross-reference form treats this as a required slot even when
101    /// the reference lives in the same namespace as the referrer
102    /// (every pre-lift callsite in `tatara-reconciler::render`
103    /// stamped it explicitly), so this primitive carries it
104    /// unconditionally.
105    pub namespace: String,
106}
107
108impl K8sObjectRef {
109    /// Construct a `K8sObjectRef` from the (kind, name, namespace)
110    /// triple. Accepts any `Into<String>` at each slot so a caller
111    /// with `&str` / `String` / `Cow<'_, str>` composes without a
112    /// `.to_string()` per site.
113    pub fn new(
114        kind: impl Into<String>,
115        name: impl Into<String>,
116        namespace: impl Into<String>,
117    ) -> Self {
118        Self {
119            kind: kind.into(),
120            name: name.into(),
121            namespace: namespace.into(),
122        }
123    }
124
125    /// Emit as a 3-slot `{ "kind": …, "name": …, "namespace": … }`
126    /// JSON object — the exact shape every pre-lift `sourceRef` /
127    /// `chartRef` block in `tatara-reconciler::render` hand-authored
128    /// by inlining three `Value::String` slot insertions. Slot count
129    /// + slot keys are pinned by
130    /// [`tests::as_json_emits_exactly_three_slots_named_kind_name_namespace`]
131    /// so a regression that added or removed a slot (or renamed one
132    /// to camelCase drift) surfaces at the primitive rather than as
133    /// silent wire-form skew at every emit site.
134    pub fn as_json(&self) -> Value {
135        let mut m = Map::with_capacity(3);
136        m.insert("kind".to_string(), Value::String(self.kind.clone()));
137        m.insert("name".to_string(), Value::String(self.name.clone()));
138        m.insert(
139            "namespace".to_string(),
140            Value::String(self.namespace.clone()),
141        );
142        Value::Object(m)
143    }
144}
145
146impl K8sWireIdentity {
147    /// Bridge from the typed `(apiVersion, kind)` identity pair to
148    /// the typed `(kind, name, namespace)` cross-resource reference:
149    /// binds the identity's `kind` slot into the reference and
150    /// carries the caller-supplied `(name, namespace)` pair through.
151    ///
152    /// Every typed closed-set variant that projects a
153    /// `K8sWireIdentity` (via
154    /// [`crate::flux_resource::FluxResource::wire_identity`] or
155    /// [`crate::routing_edge_resource::RoutingEdgeResource::wire_identity`])
156    /// composes a `K8sObjectRef` mechanically through this projection
157    /// without restating the `kind` slot at the reference site. A
158    /// future closed-set inherits the same projection chain for free
159    /// through its own `wire_identity()` const.
160    ///
161    /// The projection intentionally DROPS `apiVersion` — the K8s
162    /// cross-reference form (Flux's `sourceRef` / `chartRef`, the
163    /// K8s `TypedObjectReference` shape) reads only `kind` and
164    /// leaves apiVersion resolution to the owning controller's own
165    /// resource registry. A caller that needs the full pair reaches
166    /// through the identity itself via [`Self::as_json`] or
167    /// [`Self::resource_json`], not through this projection.
168    pub fn object_ref(self, name: impl Into<String>, namespace: impl Into<String>) -> K8sObjectRef {
169        K8sObjectRef::new(self.kind, name, namespace)
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::flux_resource::FluxResource;
177    use crate::routing_edge_resource::RoutingEdgeResource;
178
179    #[test]
180    fn new_binds_slots_by_position() {
181        // Positional pin: `new(kind, name, namespace)` binds the
182        // three arguments to the three slots in the given order. A
183        // regression that swapped `name` and `namespace` — trivial
184        // with three `impl Into<String>` params — would surface here
185        // rather than as silently-swapped emits at every callsite.
186        let r = K8sObjectRef::new("Widget", "my-widget", "my-ns");
187        assert_eq!(r.kind, "Widget");
188        assert_eq!(r.name, "my-widget");
189        assert_eq!(r.namespace, "my-ns");
190    }
191
192    #[test]
193    fn new_accepts_str_and_owned_string_at_every_slot() {
194        // Composability pin: every slot accepts `&str` and `String`
195        // interchangeably. Guards against a future signature drift
196        // (e.g. a `&'static str` slot that broke a callsite passing
197        // `f.git_repository: String`).
198        let a = K8sObjectRef::new("K", "n", "ns");
199        let b = K8sObjectRef::new(String::from("K"), String::from("n"), String::from("ns"));
200        assert_eq!(a, b);
201    }
202
203    #[test]
204    fn as_json_emits_exactly_three_slots_named_kind_name_namespace() {
205        // Shape pin: the composer emits an object with EXACTLY the
206        // three slots `kind`, `name`, `namespace`. A regression that
207        // added an `apiVersion` slot (the wrong-form drift a
208        // hand-lift might introduce by copy-pasting from a top-level
209        // resource identity) would surface here rather than as
210        // silent controller mis-resolution at wire time. A regression
211        // that renamed any slot (e.g. camelCase drift to `Name`) or
212        // dropped one surfaces here as well.
213        let r = K8sObjectRef::new("Widget", "my-widget", "my-ns");
214        let v = r.as_json();
215        let obj = v.as_object().expect("as_json emits an Object");
216        assert_eq!(obj.len(), 3, "K8sObjectRef must emit exactly 3 slots");
217        assert_eq!(obj["kind"], "Widget");
218        assert_eq!(obj["name"], "my-widget");
219        assert_eq!(obj["namespace"], "my-ns");
220        assert!(
221            !obj.contains_key("apiVersion"),
222            "K8sObjectRef must not stamp an `apiVersion` slot — the K8s cross-\
223             reference form derives apiVersion from `kind` at the owning controller"
224        );
225    }
226
227    #[test]
228    fn as_json_is_a_pure_function_of_the_triple() {
229        // Purity pin: two identically-constructed refs emit
230        // byte-equal JSON, and one ref emits byte-equal JSON on
231        // repeated calls. Guards against a future implementation
232        // that lazily materialized a random ordering (would break a
233        // downstream `canonical_bytes` equality check at wire time).
234        let a = K8sObjectRef::new("K", "n", "ns");
235        let b = K8sObjectRef::new("K", "n", "ns");
236        assert_eq!(a.as_json(), b.as_json());
237        assert_eq!(a.as_json(), a.as_json());
238    }
239
240    #[test]
241    fn struct_equality_pins_the_triple_axis() {
242        // Two refs with the same (kind, name, namespace) compare
243        // equal; distinct triples do not. Guards against a future
244        // derive drift that skewed slot equivalence.
245        let a = K8sObjectRef::new("K", "n", "ns");
246        let b = K8sObjectRef::new("K", "n", "ns");
247        let diff_kind = K8sObjectRef::new("K2", "n", "ns");
248        let diff_name = K8sObjectRef::new("K", "n2", "ns");
249        let diff_ns = K8sObjectRef::new("K", "n", "ns2");
250        assert_eq!(a, b);
251        assert_ne!(a, diff_kind);
252        assert_ne!(a, diff_name);
253        assert_ne!(a, diff_ns);
254    }
255
256    #[test]
257    fn wire_identity_object_ref_binds_kind_from_the_identity() {
258        // Coherence pin: `K8sWireIdentity::object_ref` sources the
259        // reference's `kind` slot from the identity, not from the
260        // caller. A regression that stamped the caller's own kind
261        // (or the wrong slot from the identity) would break the
262        // whole reason to route through this projection — that a
263        // future closed-set variant's kind rename lands at ONE arm
264        // on the substrate and reaches every reference site
265        // mechanically.
266        let id = K8sWireIdentity::new("group.io/v1", "Widget");
267        let r = id.object_ref("my-widget", "my-ns");
268        assert_eq!(r.kind, "Widget");
269        assert_eq!(r.name, "my-widget");
270        assert_eq!(r.namespace, "my-ns");
271    }
272
273    #[test]
274    fn wire_identity_object_ref_drops_api_version() {
275        // The K8s cross-reference form has no `apiVersion` slot;
276        // `object_ref` must NOT plumb it through. A regression that
277        // widened the reference to a 4-slot form would surface at
278        // this pin AND at `as_json_emits_exactly_three_slots_named_kind_name_namespace`.
279        let id = K8sWireIdentity::new("group.io/v1", "Widget");
280        let r = id.object_ref("my-widget", "my-ns");
281        let obj = r.as_json();
282        let obj = obj.as_object().unwrap();
283        assert!(!obj.contains_key("apiVersion"));
284        assert_eq!(obj.len(), 3);
285    }
286
287    #[test]
288    fn flux_resource_composes_object_ref_through_wire_identity() {
289        // Chain pin: every `FluxResource` variant composes a
290        // `K8sObjectRef` through its `wire_identity()` projection.
291        // The reference's `kind` slot always agrees with the
292        // variant's `.kind()` — a regression at ONE variant's kind
293        // rename would surface here rather than at every consumer.
294        for v in FluxResource::ALL {
295            let r = v.wire_identity().object_ref("some-name", "some-ns");
296            assert_eq!(r.kind, v.kind());
297            assert_eq!(r.name, "some-name");
298            assert_eq!(r.namespace, "some-ns");
299        }
300    }
301
302    #[test]
303    fn routing_edge_resource_composes_object_ref_through_wire_identity() {
304        // Chain pin, sibling axis: every `RoutingEdgeResource`
305        // variant composes a `K8sObjectRef` through its
306        // `wire_identity()` projection — matching what the sibling
307        // `FluxResource` axis buys. A future routing-edge kind lands
308        // at ONE arm on the closed set and inherits the composer
309        // through the same chain.
310        for v in RoutingEdgeResource::ALL {
311            let r = v.wire_identity().object_ref("edge-name", "edge-ns");
312            assert_eq!(r.kind, v.kind());
313        }
314    }
315
316    #[test]
317    fn as_json_matches_pre_lift_flux_source_ref_hand_authored_shape() {
318        // Byte-shape pin against the pre-lift hand-authored
319        // `sourceRef` block at `tatara-reconciler::render::render_flux`
320        // (line 124-133 pre-lift) pointing at a `GitRepository`:
321        //   json!({
322        //       "kind": "GitRepository",
323        //       "name": f.git_repository,
324        //       "namespace": f.git_repository_namespace
325        //           .clone()
326        //           .unwrap_or_else(|| "flux-system".into()),
327        //   })
328        // Post-lift the same shape composes through this primitive.
329        // A regression that reshaped the emitted JSON at either the
330        // primitive or the callsite would surface at this pin.
331        let hand_authored = serde_json::json!({
332            "kind": "GitRepository",
333            "name": "flux-system",
334            "namespace": "flux-system",
335        });
336        let composed = K8sObjectRef::new("GitRepository", "flux-system", "flux-system").as_json();
337        assert_eq!(composed, hand_authored);
338    }
339
340    #[test]
341    fn as_json_matches_pre_lift_helm_chart_ref_hand_authored_shape() {
342        // Byte-shape pin against the pre-lift hand-authored
343        // `chartRef` block at
344        // `tatara-reconciler::render::render_aplicacao` (line 248-254
345        // pre-lift) pointing at an `OCIRepository`:
346        //   json!({
347        //       "kind": FluxResource::OCIRepository.kind(),
348        //       "name": name,
349        //       "namespace": ns,
350        //   })
351        let hand_authored = serde_json::json!({
352            "kind": FluxResource::OCIRepository.kind(),
353            "name": "ephemeral-demo",
354            "namespace": "demo-test",
355        });
356        let composed = FluxResource::OCIRepository
357            .wire_identity()
358            .object_ref("ephemeral-demo", "demo-test")
359            .as_json();
360        assert_eq!(composed, hand_authored);
361    }
362
363    #[test]
364    fn as_json_matches_pre_lift_helm_repository_source_ref_hand_authored_shape() {
365        // Byte-shape pin against the pre-lift hand-authored
366        // `sourceRef` block at
367        // `tatara-reconciler::render::render_aplicacao` (line 265-269
368        // pre-lift) pointing at a `HelmRepository`:
369        //   json!({
370        //       "kind": "HelmRepository",
371        //       "name": repo,
372        //       "namespace": "flux-system",
373        //   })
374        let hand_authored = serde_json::json!({
375            "kind": "HelmRepository",
376            "name": "pleme-io",
377            "namespace": "flux-system",
378        });
379        let composed = K8sObjectRef::new("HelmRepository", "pleme-io", "flux-system").as_json();
380        assert_eq!(composed, hand_authored);
381    }
382}