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}