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}