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::json_object::JsonMapStrExt;
73use crate::k8s_wire_identity::K8sWireIdentity;
74
75/// K8s cross-resource reference — the 3-slot
76/// `{ "kind", "name", "namespace" }` pointer one K8s resource
77/// carries at a `sourceRef` / `chartRef` / equivalent nested slot
78/// to point at another K8s resource in the cluster.
79///
80/// The slot set is intentionally the 3-slot form (no `apiVersion`)
81/// used by FluxCD's `sourceRef` + `chartRef` slots and the K8s
82/// `TypedLocalObjectReference` / `TypedObjectReference` shapes: the
83/// owning controller derives the apiVersion from `kind` (typically
84/// by dispatch through its own resource registry), so an emit site
85/// that hand-stamped an `apiVersion` here would either be ignored
86/// (adding a fourth slot no controller reads) or silently take
87/// precedence over the controller's derivation and mis-route
88/// resolution. The composer at [`Self::as_json`] emits exactly the
89/// 3-slot shape — a regression that added or removed a slot would
90/// surface at this module's byte-shape pins.
91#[derive(Clone, Debug, PartialEq, Eq, Hash)]
92pub struct K8sObjectRef {
93    /// K8s wire-form `kind` string of the referenced resource — the
94    /// PascalCase identifier the owning controller matches against
95    /// its resource registry to derive the apiVersion + REST route.
96    pub kind: String,
97    /// K8s `metadata.name` of the referenced resource — the API-path
98    /// leaf segment.
99    pub name: String,
100    /// K8s `metadata.namespace` of the referenced resource. The K8s
101    /// cross-reference form treats this as a required slot even when
102    /// the reference lives in the same namespace as the referrer
103    /// (every pre-lift callsite in `tatara-reconciler::render`
104    /// stamped it explicitly), so this primitive carries it
105    /// unconditionally.
106    pub namespace: String,
107}
108
109impl K8sObjectRef {
110    /// Construct a `K8sObjectRef` from the (kind, name, namespace)
111    /// triple. Accepts any `Into<String>` at each slot so a caller
112    /// with `&str` / `String` / `Cow<'_, str>` composes without a
113    /// `.to_string()` per site.
114    pub fn new(
115        kind: impl Into<String>,
116        name: impl Into<String>,
117        namespace: impl Into<String>,
118    ) -> Self {
119        Self {
120            kind: kind.into(),
121            name: name.into(),
122            namespace: namespace.into(),
123        }
124    }
125
126    /// Emit as a 3-slot `{ "kind": …, "name": …, "namespace": … }`
127    /// JSON object — the exact shape every pre-lift `sourceRef` /
128    /// `chartRef` block in `tatara-reconciler::render` hand-authored
129    /// by inlining three `Value::String` slot insertions. Slot count
130    /// + slot keys are pinned by
131    /// [`tests::as_json_emits_exactly_three_slots_named_kind_name_namespace`]
132    /// so a regression that added or removed a slot (or renamed one
133    /// to camelCase drift) surfaces at the primitive rather than as
134    /// silent wire-form skew at every emit site.
135    pub fn as_json(&self) -> Value {
136        let mut m = Map::with_capacity(3);
137        // All THREE slot writes route through the workspace-wide
138        // substrate owner `JsonMapStrExt::insert_str` for the
139        // `.insert(<k>.to_string(), Value::String(<v>.clone()))`
140        // shape; the composer here now stamps its (kind, name,
141        // namespace) triple through the ONE substrate primitive
142        // rather than restating the `Value::String` wrap three times.
143        m.insert_str("kind", self.kind.clone());
144        m.insert_str("name", self.name.clone());
145        m.insert_str("namespace", self.namespace.clone());
146        Value::Object(m)
147    }
148}
149
150impl K8sWireIdentity {
151    /// Bridge from the typed `(apiVersion, kind)` identity pair to
152    /// the typed `(kind, name, namespace)` cross-resource reference:
153    /// binds the identity's `kind` slot into the reference and
154    /// carries the caller-supplied `(name, namespace)` pair through.
155    ///
156    /// Every typed closed-set variant that projects a
157    /// `K8sWireIdentity` (via
158    /// [`crate::flux_resource::FluxResource::wire_identity`] or
159    /// [`crate::routing_edge_resource::RoutingEdgeResource::wire_identity`])
160    /// composes a `K8sObjectRef` mechanically through this projection
161    /// without restating the `kind` slot at the reference site. A
162    /// future closed-set inherits the same projection chain for free
163    /// through its own `wire_identity()` const.
164    ///
165    /// The projection intentionally DROPS `apiVersion` — the K8s
166    /// cross-reference form (Flux's `sourceRef` / `chartRef`, the
167    /// K8s `TypedObjectReference` shape) reads only `kind` and
168    /// leaves apiVersion resolution to the owning controller's own
169    /// resource registry. A caller that needs the full pair reaches
170    /// through the identity itself via [`Self::as_json`] or
171    /// [`Self::resource_json`], not through this projection.
172    pub fn object_ref(self, name: impl Into<String>, namespace: impl Into<String>) -> K8sObjectRef {
173        K8sObjectRef::new(self.kind, name, namespace)
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::flux_resource::FluxResource;
181    use crate::routing_edge_resource::RoutingEdgeResource;
182
183    #[test]
184    fn new_binds_slots_by_position() {
185        // Positional pin: `new(kind, name, namespace)` binds the
186        // three arguments to the three slots in the given order. A
187        // regression that swapped `name` and `namespace` — trivial
188        // with three `impl Into<String>` params — would surface here
189        // rather than as silently-swapped emits at every callsite.
190        let r = K8sObjectRef::new("Widget", "my-widget", "my-ns");
191        assert_eq!(r.kind, "Widget");
192        assert_eq!(r.name, "my-widget");
193        assert_eq!(r.namespace, "my-ns");
194    }
195
196    #[test]
197    fn new_accepts_str_and_owned_string_at_every_slot() {
198        // Composability pin: every slot accepts `&str` and `String`
199        // interchangeably. Guards against a future signature drift
200        // (e.g. a `&'static str` slot that broke a callsite passing
201        // `f.git_repository: String`).
202        let a = K8sObjectRef::new("K", "n", "ns");
203        let b = K8sObjectRef::new(String::from("K"), String::from("n"), String::from("ns"));
204        assert_eq!(a, b);
205    }
206
207    #[test]
208    fn as_json_emits_exactly_three_slots_named_kind_name_namespace() {
209        // Shape pin: the composer emits an object with EXACTLY the
210        // three slots `kind`, `name`, `namespace`. A regression that
211        // added an `apiVersion` slot (the wrong-form drift a
212        // hand-lift might introduce by copy-pasting from a top-level
213        // resource identity) would surface here rather than as
214        // silent controller mis-resolution at wire time. A regression
215        // that renamed any slot (e.g. camelCase drift to `Name`) or
216        // dropped one surfaces here as well.
217        let r = K8sObjectRef::new("Widget", "my-widget", "my-ns");
218        let v = r.as_json();
219        let obj = v.as_object().expect("as_json emits an Object");
220        assert_eq!(obj.len(), 3, "K8sObjectRef must emit exactly 3 slots");
221        assert_eq!(obj["kind"], "Widget");
222        assert_eq!(obj["name"], "my-widget");
223        assert_eq!(obj["namespace"], "my-ns");
224        assert!(
225            !obj.contains_key("apiVersion"),
226            "K8sObjectRef must not stamp an `apiVersion` slot — the K8s cross-\
227             reference form derives apiVersion from `kind` at the owning controller"
228        );
229    }
230
231    #[test]
232    fn as_json_is_a_pure_function_of_the_triple() {
233        // Purity pin: two identically-constructed refs emit
234        // byte-equal JSON, and one ref emits byte-equal JSON on
235        // repeated calls. Guards against a future implementation
236        // that lazily materialized a random ordering (would break a
237        // downstream `canonical_bytes` equality check at wire time).
238        let a = K8sObjectRef::new("K", "n", "ns");
239        let b = K8sObjectRef::new("K", "n", "ns");
240        assert_eq!(a.as_json(), b.as_json());
241        assert_eq!(a.as_json(), a.as_json());
242    }
243
244    #[test]
245    fn struct_equality_pins_the_triple_axis() {
246        // Two refs with the same (kind, name, namespace) compare
247        // equal; distinct triples do not. Guards against a future
248        // derive drift that skewed slot equivalence.
249        let a = K8sObjectRef::new("K", "n", "ns");
250        let b = K8sObjectRef::new("K", "n", "ns");
251        let diff_kind = K8sObjectRef::new("K2", "n", "ns");
252        let diff_name = K8sObjectRef::new("K", "n2", "ns");
253        let diff_ns = K8sObjectRef::new("K", "n", "ns2");
254        assert_eq!(a, b);
255        assert_ne!(a, diff_kind);
256        assert_ne!(a, diff_name);
257        assert_ne!(a, diff_ns);
258    }
259
260    #[test]
261    fn wire_identity_object_ref_binds_kind_from_the_identity() {
262        // Coherence pin: `K8sWireIdentity::object_ref` sources the
263        // reference's `kind` slot from the identity, not from the
264        // caller. A regression that stamped the caller's own kind
265        // (or the wrong slot from the identity) would break the
266        // whole reason to route through this projection — that a
267        // future closed-set variant's kind rename lands at ONE arm
268        // on the substrate and reaches every reference site
269        // mechanically.
270        let id = K8sWireIdentity::new("group.io/v1", "Widget");
271        let r = id.object_ref("my-widget", "my-ns");
272        assert_eq!(r.kind, "Widget");
273        assert_eq!(r.name, "my-widget");
274        assert_eq!(r.namespace, "my-ns");
275    }
276
277    #[test]
278    fn wire_identity_object_ref_drops_api_version() {
279        // The K8s cross-reference form has no `apiVersion` slot;
280        // `object_ref` must NOT plumb it through. A regression that
281        // widened the reference to a 4-slot form would surface at
282        // this pin AND at `as_json_emits_exactly_three_slots_named_kind_name_namespace`.
283        let id = K8sWireIdentity::new("group.io/v1", "Widget");
284        let r = id.object_ref("my-widget", "my-ns");
285        let obj = r.as_json();
286        let obj = obj.as_object().unwrap();
287        assert!(!obj.contains_key("apiVersion"));
288        assert_eq!(obj.len(), 3);
289    }
290
291    #[test]
292    fn flux_resource_composes_object_ref_through_wire_identity() {
293        // Chain pin: every `FluxResource` variant composes a
294        // `K8sObjectRef` through its `wire_identity()` projection.
295        // The reference's `kind` slot always agrees with the
296        // variant's `.kind()` — a regression at ONE variant's kind
297        // rename would surface here rather than at every consumer.
298        for v in FluxResource::ALL {
299            let r = v.wire_identity().object_ref("some-name", "some-ns");
300            assert_eq!(r.kind, v.kind());
301            assert_eq!(r.name, "some-name");
302            assert_eq!(r.namespace, "some-ns");
303        }
304    }
305
306    #[test]
307    fn routing_edge_resource_composes_object_ref_through_wire_identity() {
308        // Chain pin, sibling axis: every `RoutingEdgeResource`
309        // variant composes a `K8sObjectRef` through its
310        // `wire_identity()` projection — matching what the sibling
311        // `FluxResource` axis buys. A future routing-edge kind lands
312        // at ONE arm on the closed set and inherits the composer
313        // through the same chain.
314        for v in RoutingEdgeResource::ALL {
315            let r = v.wire_identity().object_ref("edge-name", "edge-ns");
316            assert_eq!(r.kind, v.kind());
317        }
318    }
319
320    #[test]
321    fn as_json_matches_pre_lift_flux_source_ref_hand_authored_shape() {
322        // Byte-shape pin against the pre-lift hand-authored
323        // `sourceRef` block at `tatara-reconciler::render::render_flux`
324        // (line 124-133 pre-lift) pointing at a `GitRepository`:
325        //   json!({
326        //       "kind": "GitRepository",
327        //       "name": f.git_repository,
328        //       "namespace": f.git_repository_namespace
329        //           .clone()
330        //           .unwrap_or_else(|| "flux-system".into()),
331        //   })
332        // Post-lift the same shape composes through this primitive.
333        // A regression that reshaped the emitted JSON at either the
334        // primitive or the callsite would surface at this pin.
335        let hand_authored = serde_json::json!({
336            "kind": "GitRepository",
337            "name": "flux-system",
338            "namespace": "flux-system",
339        });
340        let composed = K8sObjectRef::new("GitRepository", "flux-system", "flux-system").as_json();
341        assert_eq!(composed, hand_authored);
342    }
343
344    #[test]
345    fn as_json_matches_pre_lift_helm_chart_ref_hand_authored_shape() {
346        // Byte-shape pin against the pre-lift hand-authored
347        // `chartRef` block at
348        // `tatara-reconciler::render::render_aplicacao` (line 248-254
349        // pre-lift) pointing at an `OCIRepository`:
350        //   json!({
351        //       "kind": FluxResource::OCIRepository.kind(),
352        //       "name": name,
353        //       "namespace": ns,
354        //   })
355        let hand_authored = serde_json::json!({
356            "kind": FluxResource::OCIRepository.kind(),
357            "name": "ephemeral-demo",
358            "namespace": "demo-test",
359        });
360        let composed = FluxResource::OCIRepository
361            .wire_identity()
362            .object_ref("ephemeral-demo", "demo-test")
363            .as_json();
364        assert_eq!(composed, hand_authored);
365    }
366
367    #[test]
368    fn as_json_matches_pre_lift_helm_repository_source_ref_hand_authored_shape() {
369        // Byte-shape pin against the pre-lift hand-authored
370        // `sourceRef` block at
371        // `tatara-reconciler::render::render_aplicacao` (line 265-269
372        // pre-lift) pointing at a `HelmRepository`:
373        //   json!({
374        //       "kind": "HelmRepository",
375        //       "name": repo,
376        //       "namespace": "flux-system",
377        //   })
378        let hand_authored = serde_json::json!({
379            "kind": "HelmRepository",
380            "name": "pleme-io",
381            "namespace": "flux-system",
382        });
383        let composed = K8sObjectRef::new("HelmRepository", "pleme-io", "flux-system").as_json();
384        assert_eq!(composed, hand_authored);
385    }
386
387    #[test]
388    fn as_json_composes_through_json_map_str_ext_insert_str_bytewise() {
389        // Substrate-composition coherence pin: `K8sObjectRef::as_json`
390        // routes each of its three slot writes through the workspace-
391        // wide substrate owner `JsonMapStrExt::insert_str` for the
392        // `.insert(<k>.to_string(), Value::String(<v>.clone()))`
393        // string-slot write shape. This pin binds the composition at
394        // substrate granularity — a regression that re-inlined any of
395        // the three `Value::String(<v>.clone())` slot writes (bypassing
396        // the substrate owner opened at 848e16f) would surface here
397        // rather than as silent breakage of the "one substrate owner
398        // per slot-write shape" invariant. Sweep the same three
399        // callsite shapes the pre-lift render sites hand-authored
400        // (GitRepository / OCIRepository / HelmRepository sourceRef /
401        // chartRef pointers) so the pin covers each pre-lift consumer.
402        use crate::json_object::JsonMapStrExt;
403        for r in [
404            K8sObjectRef::new("GitRepository", "flake-src", "flux-system"),
405            K8sObjectRef::new("OCIRepository", "ephemeral-demo", "demo-test"),
406            K8sObjectRef::new("HelmRepository", "pleme-io", "flux-system"),
407        ] {
408            let via_primitive = r.as_json();
409
410            let mut hand = Map::with_capacity(3);
411            hand.insert_str("kind", r.kind.clone());
412            hand.insert_str("name", r.name.clone());
413            hand.insert_str("namespace", r.namespace.clone());
414            let via_hand = Value::Object(hand);
415
416            assert_eq!(
417                via_primitive, via_hand,
418                "K8sObjectRef::as_json must compose byte-identically to an \
419                 `insert_str`-authored (kind, name, namespace) triple for {r:?}",
420            );
421        }
422    }
423}