Skip to main content

tatara_process/
routing.rs

1//! `RoutingSpec` — declared DNS + Ingress edges this Process exposes.
2//!
3//! The substrate move: every Process can declare hostnames at which
4//! it answers. The reconciler emits one `networking.k8s.io/v1`
5//! Ingress + one `externaldns.k8s.io/v1alpha1` DNSEndpoint per
6//! entry, owned by the Process via ownerRefs (cascade-delete on
7//! Reaped). DNS records are declarative — the Process IS the source
8//! of truth for `${app}.${eph_id}.${cluster}.${location}.${domain}`.
9//!
10//! Two hostname forms:
11//!
12//! 1. **Per-instance** — `${app}.${eph_id}.${cluster}.${loc}.${domain}`.
13//!    The `eph_id` segment is the `hostnames[i].instance` value when
14//!    set, or the BLAKE3:8 short-hash of the Process's canonical
15//!    spec when unset. Stable for the lifetime of the spec; new
16//!    spec content ⇒ new hash ⇒ new slot.
17//!
18//! 2. **Stable claim** — `${app}.${cluster}.${loc}.${domain}` (no
19//!    `eph_id` segment). Emitted iff `stable_name_claim: true` AND
20//!    this Process currently holds the ProcessTable.claims entry
21//!    for `(cluster, app)`. The claim arbiter handles atomic
22//!    transfer when the holder fails.
23//!
24//! Lisp authoring:
25//! ```lisp
26//! :routing (:hostnames ((:app "api" :instance "demo-prod")
27//!                       (:app "gateway"))
28//!           :backend   (:service "demo-app-gateway"
29//!                       :port    8000)
30//!           :stable-name-claim #t
31//!           :priority           100)
32//! ```
33
34use schemars::JsonSchema;
35use serde::{Deserialize, Serialize};
36use std::collections::BTreeMap;
37use tatara_lisp::DeriveTataraDomain;
38
39/// Declared external edges (DNS + Ingress) this Process exposes.
40///
41/// Optional on `ProcessSpec` — None means the Process is in-cluster-
42/// only, matching today's default behavior. The reconciler only
43/// emits routing artifacts when this slot is populated.
44#[derive(DeriveTataraDomain, Clone, Debug, Serialize, Deserialize, JsonSchema)]
45#[serde(rename_all = "camelCase")]
46#[tatara(keyword = "defrouting")]
47pub struct RoutingSpec {
48    /// Hostnames this Process answers on. Empty list is legal but
49    /// nonsensical (no Ingress, no DNS) — operators should drop the
50    /// `routing` slot entirely instead. The reconciler warns on
51    /// empty hostnames.
52    #[serde(default)]
53    pub hostnames: Vec<RoutingHostname>,
54
55    /// Single backend Service every hostname routes to. Per-hostname
56    /// backends are a future extension; v1 keeps the simple shape.
57    pub backend: RoutingBackend,
58
59    /// When true, additionally emit the *unprefixed* form of every
60    /// hostname (`${app}.${cluster}.${loc}.${domain}` — no
61    /// `eph_id` segment) iff this Process currently holds the
62    /// ProcessTable claim for `(cluster, app)`. At most one Process
63    /// per (cluster, app) holds the claim.
64    #[serde(default)]
65    pub stable_name_claim: bool,
66
67    /// Claim arbitration priority. Higher wins. Ties broken by
68    /// oldest `creationTimestamp`. Negative values legal (signals
69    /// "prefer not to hold the claim"). Default 0.
70    #[serde(default)]
71    pub priority: i32,
72}
73
74/// One entry in `RoutingSpec.hostnames`.
75///
76/// Emitted FQDN: `${app}.${ephemeral_id}.${cluster}.${location}.${domain}`
77/// where:
78/// * `app` and (optional) `instance` come from this struct;
79/// * `cluster` falls back to reconciler-config when unset;
80/// * `location` and `domain` are reconciler-config (from
81///   `nix/lib/fleet-domains.nix`).
82#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
83#[serde(rename_all = "camelCase")]
84pub struct RoutingHostname {
85    /// Application slot — `api`, `gateway`, `web`, etc.
86    /// Must be a valid DNS label (RFC 1123): lowercase alpha-num
87    /// + hyphen, 1–63 chars, no leading/trailing hyphen. The
88    /// reconciler validates this at the boundary.
89    pub app: String,
90
91    /// Named instance segment. When `Some("demo-prod")` the FQDN
92    /// reads `${app}.demo-prod.${cluster}.…`. When `None` the
93    /// reconciler substitutes `blake3(canonical_spec)[:8]` —
94    /// deterministic per-spec, changes when the spec changes.
95    ///
96    /// Must be a valid DNS label when set.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub instance: Option<String>,
99
100    /// Cluster override. Empty/None ⇒ reconciler-config default
101    /// (e.g., `pleme-dev`). Used for cross-cluster routing rules,
102    /// rare in practice.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub cluster: Option<String>,
105}
106
107/// Backend Service the FQDN's Ingress routes traffic to.
108#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
109#[serde(rename_all = "camelCase")]
110pub struct RoutingBackend {
111    /// In-cluster Service name (same namespace as the Process).
112    pub service: String,
113
114    /// Port number on the Service to route to.
115    pub port: u16,
116
117    /// `ClusterIssuer` name for TLS. None ⇒ reconciler-config
118    /// default (typically `letsencrypt-prod` or the cluster's
119    /// SPIRE-issuing issuer).
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub tls_issuer: Option<String>,
122
123    /// Annotations stamped on every emitted Ingress. Common keys:
124    /// `nginx.ingress.kubernetes.io/rate-limit`, `nginx.ingress.
125    /// kubernetes.io/proxy-body-size`. The reconciler MERGES these
126    /// with its own annotations; conflict ⇒ this map wins.
127    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
128    pub ingress_annotations: BTreeMap<String, String>,
129}
130
131impl RoutingSpec {
132    /// True iff at least one hostname is declared. The reconciler
133    /// uses this to short-circuit: empty routing ⇒ no emission.
134    pub fn has_hostnames(&self) -> bool {
135        !self.hostnames.is_empty()
136    }
137
138    /// Total count of FQDNs this Process will emit:
139    /// `hostnames.len()` per-instance + `hostnames.len()` stable
140    /// when the claim is held.
141    pub fn emitted_fqdn_count(&self, claim_held: bool) -> usize {
142        self.hostnames.len() * if claim_held { 2 } else { 1 }
143    }
144}
145
146impl RoutingHostname {
147    /// True iff this entry resolves to a named slot (vs content-hash).
148    pub fn is_named(&self) -> bool {
149        self.instance.as_deref().is_some_and(|s| !s.is_empty())
150    }
151
152    /// Cluster override slice with a caller-supplied per-config
153    /// fallback applied — the ONE-line collapse of the paired
154    /// `self.cluster.as_deref().unwrap_or(fallback)` incantation the
155    /// reconciler's FQDN composer + stable-claim group-key composer
156    /// both spelled by hand pre-lift.
157    ///
158    /// Pre-lift the projection was hand-authored at TWO sites past
159    /// the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold in
160    /// `tatara-reconciler`, each walking the SAME borrow-form
161    /// `Option<String>` slot × per-config-fallback shape:
162    /// * `render::render_routing` — per-instance FQDN composer seed
163    ///   for [`crate::hostname::fmt_fqdn`], keyed on the cluster
164    ///   segment.
165    /// * `table_controller::stable_name_group_key` — claim-arbiter
166    ///   `(cluster, app)` group-key seed, keyed on the cluster
167    ///   segment.
168    ///
169    /// Both sites walked the SAME projection: pull the borrow-form
170    /// `.cluster.as_deref()` slot, sink an absent slot to the
171    /// per-config fallback the caller threads in from
172    /// `Context.config.cluster`. Post-lift both consumers read
173    /// `hostname.cluster_or(cfg_cluster)` — the projection sits at
174    /// ONE substrate owner, so a future normalization (a case-fold
175    /// pass, an empty-string-to-fallback promotion, a cross-cluster
176    /// alias resolver, a per-fleet cluster-name canonicalization)
177    /// lands here exactly once and every consumer (FQDN composer,
178    /// claim-arbiter group key, and any future edge whose downstream
179    /// keys on the cluster segment) inherits the upgrade
180    /// mechanically.
181    ///
182    /// Peer to [`Self::is_named`] on the (Option<String> slot ×
183    /// fallback shape) axis pair — both live on `RoutingHostname`
184    /// and hide the missing-slot corner behind ONE substrate
185    /// primitive; both preserve the borrow-form return, so downstream
186    /// composers thread the slice without a `.to_string()` step.
187    ///
188    /// Semantics: an explicit `Some("")` returns the empty string
189    /// (matching the pre-lift `.as_deref().unwrap_or(fallback)`
190    /// chain's behavior). Callers whose downstream rejects an
191    /// empty cluster segment must gate on that separately —
192    /// [`crate::hostname::fmt_fqdn`]'s validator does so
193    /// automatically via [`crate::hostname::HostnameError::
194    /// InvalidLabel`].
195    pub fn cluster_or<'a>(&'a self, fallback: &'a str) -> &'a str {
196        self.cluster.as_deref().unwrap_or(fallback)
197    }
198
199    /// Compose a [`RoutingHostname`] pinned to the "named-slot,
200    /// per-config cluster fallback" shape (`instance: Some(<instance>)`,
201    /// `cluster: None`) — the ONE substrate primitive owning the
202    /// 3-slot `RoutingHostname { app, instance: Some(<instance>),
203    /// cluster: None }` fixture literal every consumer restated by
204    /// hand pre-lift.
205    ///
206    /// Pre-lift the same 3-slot chain (`app: <s>.into()`, `instance:
207    /// Some(<s>.into())`, `cluster: None`) was hand-authored at TEN
208    /// workspace-wide sites past the ★★ PRIME-DIRECTIVE ≥ 2
209    /// duplication threshold, EVERY one of them the "named instance
210    /// segment, cluster-inherits-from-config" shape:
211    ///
212    /// * `tatara-process::hostname` — two sites: the `resolve_named_slot_wins`
213    ///   fixture plus the `end_to_end_named_and_unnamed_for_same_process`
214    ///   named-arm fixture.
215    /// * `tatara-process::routing` — four sites: the `demo_routing` seed
216    ///   (two hostnames), the `hostname_is_named_when_instance_nonempty`
217    ///   populated-instance pin, and the `cluster_or_borrow_form_return_shape_matches_fmt_fqdn_arg_shape`
218    ///   FQDN-composer parity pin.
219    /// * `tatara-reconciler::edges` — two sites: the `api_hostname`
220    ///   test fixture plus the `routing_edge_labels_stamps_app_slot_from_hostname`
221    ///   APP-slot pin (which stamps `"gateway"` instead of `"api"`).
222    /// * `tatara-reconciler::render` — two sites: the `two_hostname_routing`
223    ///   seed's `api` + `gateway` hostname pair.
224    ///
225    /// Post-lift every callsite reads `RoutingHostname::instanced(<app>,
226    /// <instance>)` and the three-slot struct's `cluster` slot stays
227    /// owned by the ONE substrate site — the per-config-cluster
228    /// fallback resolved through [`Self::cluster_or`] at read time
229    /// stays the ONLY axis a cluster override travels through, so a
230    /// future normalization (a per-cluster canonicalization, a
231    /// cross-cluster alias resolver, a claim-arbiter fallback swap)
232    /// lands here exactly once and every consumer inherits the
233    /// upgrade mechanically. The `impl Into<String>` bound on both
234    /// positional args accepts every pre-lift caller shape verbatim
235    /// — `&'static str` literals, owned `String` values, and
236    /// `.into()`-terminated chains alike — without a per-site
237    /// coercion.
238    ///
239    /// Peer to [`Self::content_hashed`] on the (instance slot ×
240    /// cluster slot) axis pair: both live on `RoutingHostname` and
241    /// hide the pair's "per-config cluster fallback" corner behind
242    /// ONE substrate primitive; [`Self::instanced`] fills the
243    /// `Some(<name>)` arm of the `instance` slot, [`Self::content_hashed`]
244    /// fills the `None` arm.
245    ///
246    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
247    /// the `RoutingHostname { app, instance: Some(<i>), cluster: None }`
248    /// fixture literal recurred at ten hand-authored sites past the
249    /// ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, and is lifted to
250    /// ONE owner here). THEORY.md §II.1 invariant 5 (composition
251    /// preserves proofs — a regression that drifted the default-
252    /// cluster sentinel from `None` to a hardcoded string, or
253    /// reordered the three struct slots, surfaces at the
254    /// `instanced_composes_byte_identical_to_pre_lift_literal_across_every_app_instance_pair`
255    /// pin below rather than as silent skew at every downstream
256    /// fixture).
257    #[must_use]
258    pub fn instanced(app: impl Into<String>, instance: impl Into<String>) -> Self {
259        Self {
260            app: app.into(),
261            instance: Some(instance.into()),
262            cluster: None,
263        }
264    }
265
266    /// Compose a [`RoutingHostname`] pinned to the "content-hash
267    /// anonymous, per-config cluster fallback" shape (`instance: None`,
268    /// `cluster: None`) — the ONE substrate primitive owning the
269    /// 3-slot `RoutingHostname { app, instance: None, cluster: None }`
270    /// fixture literal every consumer restated by hand pre-lift.
271    ///
272    /// The `instance: None` slot instructs the reconciler's
273    /// [`crate::hostname::resolve_ephemeral_id`] to substitute
274    /// `blake3(canonical_spec)[:8]` — the content-hashed FQDN form
275    /// documented on [`RoutingHostname`]. Pre-lift the same 3-slot
276    /// chain (`app: <s>.into()`, `instance: None`, `cluster: None`)
277    /// was hand-authored at NINE workspace-wide sites past the ★★
278    /// PRIME-DIRECTIVE ≥ 2 duplication threshold, EVERY one of them
279    /// the "unnamed instance, cluster-inherits-from-config" shape:
280    ///
281    /// * `tatara-process::hostname` — two sites: the `resolve_unset_named_falls_back`
282    ///   fixture plus the `end_to_end_named_and_unnamed_for_same_process`
283    ///   anon-arm fixture.
284    /// * `tatara-process::routing` — six sites: the `h_anon` pin, the
285    ///   `cluster_or_falls_back_to_caller_string_when_cluster_is_none`
286    ///   fallback pin, and four more `cluster_or` / round-trip fixtures.
287    /// * `tatara-reconciler::render` — one site: the
288    ///   `anonymous_hostname_uses_content_hash` FQDN composer pin.
289    ///
290    /// Peer to [`Self::instanced`] on the (instance slot × cluster
291    /// slot) axis pair — [`Self::content_hashed`] fills the `None`
292    /// arm of the `instance` slot, [`Self::instanced`] fills the
293    /// `Some(<name>)` arm.
294    ///
295    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
296    /// the `RoutingHostname { app, instance: None, cluster: None }`
297    /// fixture literal recurred at nine hand-authored sites past the
298    /// ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, and is lifted to
299    /// ONE owner here). THEORY.md §II.1 invariant 5.
300    #[must_use]
301    pub fn content_hashed(app: impl Into<String>) -> Self {
302        Self {
303            app: app.into(),
304            instance: None,
305            cluster: None,
306        }
307    }
308}
309
310impl RoutingBackend {
311    /// Compose a [`RoutingBackend`] pinned to the "reconciler-default
312    /// TLS issuer, no per-Ingress annotations" shape (`tls_issuer:
313    /// None`, `ingress_annotations: BTreeMap::new()`) — the ONE
314    /// substrate primitive owning the 4-slot `RoutingBackend { service,
315    /// port, tls_issuer: None, ingress_annotations: BTreeMap::new() }`
316    /// fixture literal every consumer restated by hand pre-lift.
317    ///
318    /// Pre-lift the same 4-slot chain (`service: <s>.into()`, `port:
319    /// <u16>`, `tls_issuer: None`, `ingress_annotations:
320    /// BTreeMap::new()`) was hand-authored at SEVEN workspace-wide
321    /// sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold,
322    /// EVERY one of them the "default-issuer, empty-annotations"
323    /// shape:
324    ///
325    /// * `tatara-process::routing` — three sites: the `demo_routing`
326    ///   seed plus two round-trip pins (`empty_routing_resolves_no_hostnames`,
327    ///   `empty_fields_skip_serialize`).
328    /// * `tatara-reconciler::edges` — one site: the `api_backend`
329    ///   test fixture consumed by every `IngressEdge` / `DnsEndpointEdge`
330    ///   render pin.
331    /// * `tatara-reconciler::render` — three sites: the `two_hostname_routing`
332    ///   seed's backend, the `empty_hostnames_emits_nothing` pin, and
333    ///   the `anonymous_hostname_uses_content_hash` pin.
334    ///
335    /// Post-lift every callsite reads `RoutingBackend::plain(<service>,
336    /// <port>)` and the four-slot struct's `tls_issuer` +
337    /// `ingress_annotations` slots stay owned by the ONE substrate
338    /// site — a future normalization (a per-fleet default `ClusterIssuer`
339    /// selection, a per-fleet baseline Ingress annotation set, a
340    /// SPIRE-vs-Let's-Encrypt discriminator) lands here exactly once
341    /// and every consumer inherits the upgrade mechanically. The
342    /// `impl Into<String>` bound on `service` accepts every pre-lift
343    /// caller shape verbatim.
344    ///
345    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
346    /// the `RoutingBackend { service, port, tls_issuer: None,
347    /// ingress_annotations: BTreeMap::new() }` fixture literal recurred
348    /// at seven hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
349    /// duplication trigger, and is lifted to ONE owner here).
350    /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
351    /// a regression that drifted the default `tls_issuer` sentinel
352    /// from `None` to a hardcoded string, or reordered the four
353    /// struct slots, surfaces at the
354    /// `plain_composes_byte_identical_to_pre_lift_literal_across_every_service_port_pair`
355    /// pin below rather than as silent skew at every downstream
356    /// fixture).
357    #[must_use]
358    pub fn plain(service: impl Into<String>, port: u16) -> Self {
359        Self {
360            service: service.into(),
361            port,
362            tls_issuer: None,
363            ingress_annotations: BTreeMap::new(),
364        }
365    }
366}
367
368/// Wire-form value stamped at
369/// [`tatara_process::annotations::ROUTING_FORM`][
370/// crate::annotations::ROUTING_FORM] on every routing edge
371/// (Ingress + DNSEndpoint) — both the `annotations` axis and the
372/// `labels` axis carry it. Distinguishes the two FQDN shapes
373/// [`RoutingSpec`] emits: the per-instance form
374/// (`${app}.${eph_id}.${cluster}.${loc}.${domain}`) and the
375/// stable-claim form (`${app}.${cluster}.${loc}.${domain}`,
376/// emitted iff `stable_name_claim` is set and this Process
377/// currently holds the ProcessTable claim for `(cluster, app)`).
378///
379/// The pre-lift reconciler restated the same
380/// `if ctx.is_stable { "stable" } else { "instance" }` ternary at
381/// three call sites (an Ingress annotation, an Ingress label, a
382/// DNSEndpoint label) plus two byte-literal comparison sites in
383/// render tests. This typed enum turns that stringly-typed
384/// disjunction into a two-variant type with a single wire
385/// encoding, so a future edge kind (a Gateway API `HTTPRoute`, a
386/// `NetworkPolicy` edge) sourcing the axis through
387/// [`RoutingForm::from_is_stable`] + [`RoutingForm::as_str`]
388/// cannot drift from the two existing edges' spellings.
389#[derive(Clone, Copy, Debug, PartialEq, Eq)]
390pub enum RoutingForm {
391    /// Emitted iff `RoutingSpec.stable_name_claim = true` AND
392    /// this Process currently holds the ProcessTable claim for
393    /// `(cluster, app)`. FQDN drops the `${eph_id}` segment.
394    Stable,
395    /// Emitted for every declared hostname entry (default). FQDN
396    /// carries the `${eph_id}` segment resolved by
397    /// [`crate::hostname::resolve_ephemeral_id`].
398    Instance,
399}
400
401impl RoutingForm {
402    /// Wire-form byte-shape stamped into the
403    /// [`ROUTING_FORM`][crate::annotations::ROUTING_FORM]
404    /// annotation / label. The reconciler's stable-form filter
405    /// checks byte-identity against these two strings — a rename
406    /// here is a wire-form break every operator's kubectl-side
407    /// selector notices.
408    pub const fn as_str(self) -> &'static str {
409        match self {
410            RoutingForm::Stable => "stable",
411            RoutingForm::Instance => "instance",
412        }
413    }
414
415    /// Route the reconciler's `EdgeContext::is_stable` bool
416    /// through ONE composer so every downstream axis (the
417    /// stable-form suffix in edge resource names + the
418    /// [`ROUTING_FORM`][crate::annotations::ROUTING_FORM] value
419    /// on labels + annotations) shares the same source of truth.
420    pub const fn from_is_stable(is_stable: bool) -> Self {
421        if is_stable {
422            RoutingForm::Stable
423        } else {
424            RoutingForm::Instance
425        }
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    fn demo_routing() -> RoutingSpec {
434        RoutingSpec {
435            hostnames: vec![
436                RoutingHostname::instanced("api", "demo-prod"),
437                RoutingHostname::instanced("gateway", "demo-prod"),
438            ],
439            backend: RoutingBackend::plain("demo-app-gateway", 8000),
440            stable_name_claim: true,
441            priority: 100,
442        }
443    }
444
445    #[test]
446    fn empty_routing_resolves_no_hostnames() {
447        let r = RoutingSpec {
448            hostnames: vec![],
449            backend: RoutingBackend::plain("x", 80),
450            stable_name_claim: false,
451            priority: 0,
452        };
453        assert!(!r.has_hostnames());
454        assert_eq!(r.emitted_fqdn_count(false), 0);
455        assert_eq!(r.emitted_fqdn_count(true), 0);
456    }
457
458    #[test]
459    fn fqdn_count_doubles_when_claim_held() {
460        let r = demo_routing();
461        assert_eq!(r.emitted_fqdn_count(false), 2);
462        assert_eq!(r.emitted_fqdn_count(true), 4);
463    }
464
465    #[test]
466    fn hostname_is_named_when_instance_nonempty() {
467        let h = RoutingHostname::instanced("x", "env-a");
468        assert!(h.is_named());
469
470        let h_anon = RoutingHostname::content_hashed("x");
471        assert!(!h_anon.is_named());
472
473        let h_empty = RoutingHostname {
474            app: "x".into(),
475            instance: Some(String::new()),
476            cluster: None,
477        };
478        assert!(!h_empty.is_named()); // empty string ⇒ unnamed
479    }
480
481    // ─── RoutingHostname::cluster_or substrate pins ──────────────
482    //
483    // The pre-lift reconciler restated the same
484    // `hostname.cluster.as_deref().unwrap_or(<cfg-cluster>)` chain at
485    // TWO callsites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
486    // trigger:
487    //   * render.rs::render_routing (line 561) — FQDN composer seed
488    //   * table_controller.rs::stable_name_group_key (line 101) —
489    //     claim-arbiter group-key seed
490    // Every corner of the paired projection is pinned here so a
491    // future normalization at the primitive lands with a
492    // fail-before-pass-after regression at THIS composer's pins
493    // rather than as silent operator-visible drift across the two
494    // callsite arms.
495
496    #[test]
497    fn cluster_or_returns_slot_when_cluster_is_populated() {
498        let h = RoutingHostname {
499            app: "api".into(),
500            instance: None,
501            cluster: Some("pleme-prod".into()),
502        };
503        assert_eq!(h.cluster_or("pleme-dev"), "pleme-prod");
504    }
505
506    #[test]
507    fn cluster_or_falls_back_to_caller_string_when_cluster_is_none() {
508        let h = RoutingHostname::content_hashed("api");
509        assert_eq!(h.cluster_or("pleme-dev"), "pleme-dev");
510    }
511
512    #[test]
513    fn cluster_or_returns_empty_slice_when_cluster_is_explicitly_empty_string() {
514        // Populated-empty short-circuit pin: `Some("")` is a
515        // populated slot for `as_deref().unwrap_or(...)`, so the
516        // fallback is NOT taken. The downstream FQDN composer's
517        // validator (`fmt_fqdn`) rejects the empty label with a
518        // typed `HostnameError::InvalidLabel`, not this primitive.
519        let h = RoutingHostname {
520            app: "api".into(),
521            instance: None,
522            cluster: Some(String::new()),
523        };
524        assert_eq!(h.cluster_or("pleme-dev"), "");
525    }
526
527    #[test]
528    fn cluster_or_is_a_pure_projection() {
529        // Two identical inputs → two identical outputs; no interior
530        // mutation or per-call hidden state.
531        let h = RoutingHostname {
532            app: "api".into(),
533            instance: Some("demo-prod".into()),
534            cluster: Some("pleme-prod".into()),
535        };
536        let a = h.cluster_or("pleme-dev");
537        let b = h.cluster_or("pleme-dev");
538        assert_eq!(a, b);
539        assert_eq!(a, "pleme-prod");
540    }
541
542    #[test]
543    fn cluster_or_borrow_form_return_shape_matches_fmt_fqdn_arg_shape() {
544        // The primitive returns `&str` so it slots straight into
545        // `fmt_fqdn(&hostname.app, eph_id, host_cluster, location,
546        // domain)` at `render::render_routing` without a
547        // `.to_string()` step. Compose here so a future return-shape
548        // change (owned `String`, `Cow<'_, str>`) breaks this pin,
549        // not the reconciler.
550        use crate::hostname::fmt_fqdn;
551        let h = RoutingHostname::instanced("api", "demo-prod");
552        let host_cluster: &str = h.cluster_or("pleme-dev");
553        let fqdn = fmt_fqdn(
554            &h.app,
555            h.instance.as_deref().unwrap(),
556            host_cluster,
557            "use1",
558            "quero.lol",
559        )
560        .expect("fmt_fqdn");
561        assert_eq!(fqdn, "api.demo-prod.pleme-dev.use1.quero.lol");
562    }
563
564    #[test]
565    fn cluster_or_matches_pre_lift_chain_verbatim() {
566        // Full 4-corner byte-identical parity table across the
567        // `(cluster slot × fallback shape)` axis pair. Any
568        // divergence between the primitive and each pre-lift
569        // callsite's inline chain surfaces HERE rather than as
570        // per-site operator-visible drift.
571        let fallbacks = ["pleme-dev", "pleme-prod", "", "some-other-cluster"];
572        let cluster_slots = [
573            None,
574            Some(String::new()),
575            Some("pleme-prod".into()),
576            Some("edge-1".into()),
577        ];
578        for fallback in fallbacks {
579            for cluster in &cluster_slots {
580                let h = RoutingHostname {
581                    app: "api".into(),
582                    instance: None,
583                    cluster: cluster.clone(),
584                };
585                let pre_lift = h.cluster.as_deref().unwrap_or(fallback);
586                let via_primitive = h.cluster_or(fallback);
587                assert_eq!(
588                    via_primitive, pre_lift,
589                    "primitive must match pre-lift `.as_deref().unwrap_or(fallback)` chain \
590                     byte-identically at (fallback={fallback:?}, cluster={cluster:?})"
591                );
592            }
593        }
594    }
595
596    #[test]
597    fn cluster_or_composes_with_stable_group_key_shape() {
598        // Peer-composition pin against
599        // `table_controller::stable_name_group_key`'s downstream
600        // seed shape (`format!("{cluster}/{}", hostname.app)`).
601        // A future rename of the separator or the composer's
602        // ordering breaks this pin, not the claim-arbiter row seed.
603        let h = RoutingHostname::content_hashed("api");
604        let cluster = h.cluster_or("pleme-dev");
605        let key = format!("{cluster}/{}", h.app);
606        assert_eq!(key, "pleme-dev/api");
607
608        let h_over = RoutingHostname {
609            app: "api".into(),
610            instance: None,
611            cluster: Some("pleme-prod".into()),
612        };
613        let cluster = h_over.cluster_or("pleme-dev");
614        let key = format!("{cluster}/{}", h_over.app);
615        assert_eq!(key, "pleme-prod/api");
616    }
617
618    #[test]
619    fn cluster_or_lifetime_ties_output_to_the_shorter_of_self_or_fallback() {
620        // Compile-time proof (via the return signature) that the
621        // returned slice borrows through EITHER `&self.cluster` or
622        // `&fallback` — the caller cannot outlive the shorter of
623        // the two. If a future refactor loosens the lifetime to
624        // `&'a str` where `'a` is only tied to `self`, this test
625        // stops compiling with the fallback-borrow arm.
626        let h = RoutingHostname::content_hashed("api");
627        {
628            let fallback = String::from("pleme-dev");
629            let slice = h.cluster_or(&fallback);
630            assert_eq!(slice, "pleme-dev");
631            // `slice` cannot escape this scope — its lifetime is
632            // bounded by `fallback`. That's the compile-time
633            // discipline the `<'a>` on the primitive encodes.
634        }
635    }
636
637    // ─── RoutingHostname::instanced substrate pins ───────────────
638    //
639    // The pre-lift workspace restated the 3-slot `RoutingHostname {
640    // app, instance: Some(<i>), cluster: None }` fixture literal at
641    // TEN hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
642    // duplication trigger. Every corner of the shipped shape is
643    // pinned here so a regression that drifted the default-cluster
644    // sentinel from `None` to a hardcoded string, or reordered the
645    // three struct slots, surfaces at THIS composer's shipped-shape
646    // pin rather than as silent skew at every downstream fixture.
647
648    #[test]
649    fn instanced_composes_populated_instance_with_default_cluster() {
650        let h = RoutingHostname::instanced("api", "demo-prod");
651        assert_eq!(h.app, "api");
652        assert_eq!(h.instance.as_deref(), Some("demo-prod"));
653        assert!(h.cluster.is_none());
654    }
655
656    #[test]
657    fn instanced_composes_byte_identical_to_pre_lift_literal_across_every_app_instance_pair() {
658        // Full 3-corner byte-identical parity table across the
659        // `(app × instance)` axis pair. Any divergence between the
660        // primitive and each pre-lift callsite's inline literal
661        // surfaces HERE rather than as per-site operator-visible
662        // drift.
663        let pairs = [
664            ("api", "demo-prod"),
665            ("gateway", "demo-prod"),
666            ("x", "env-a"),
667        ];
668        for (app, instance) in pairs {
669            let via_primitive = RoutingHostname::instanced(app, instance);
670            let pre_lift = RoutingHostname {
671                app: app.into(),
672                instance: Some(instance.into()),
673                cluster: None,
674            };
675            assert_eq!(
676                via_primitive, pre_lift,
677                "primitive must match pre-lift `RoutingHostname {{ app, instance: Some(..), \
678                 cluster: None }}` literal byte-identically at (app={app:?}, instance={instance:?})"
679            );
680        }
681    }
682
683    #[test]
684    fn instanced_is_named_via_peer_projection() {
685        // Peer-composition pin: the primitive's shipped shape must
686        // continue to satisfy `is_named` (the sibling `RoutingHostname`
687        // projection that reads the same `instance` slot).
688        assert!(RoutingHostname::instanced("api", "demo-prod").is_named());
689    }
690
691    #[test]
692    fn instanced_cluster_or_falls_back_to_caller_string() {
693        // Peer-composition pin against `cluster_or`: the primitive
694        // stamps `cluster: None`, so `cluster_or` MUST return the
695        // caller-supplied fallback verbatim.
696        let h = RoutingHostname::instanced("api", "demo-prod");
697        assert_eq!(h.cluster_or("pleme-dev"), "pleme-dev");
698    }
699
700    // ─── RoutingHostname::content_hashed substrate pins ──────────
701    //
702    // The pre-lift workspace restated the 3-slot `RoutingHostname {
703    // app, instance: None, cluster: None }` fixture literal at NINE
704    // hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
705    // trigger. Every corner of the shipped shape is pinned here.
706
707    #[test]
708    fn content_hashed_composes_unset_instance_with_default_cluster() {
709        let h = RoutingHostname::content_hashed("smoke");
710        assert_eq!(h.app, "smoke");
711        assert!(h.instance.is_none());
712        assert!(h.cluster.is_none());
713    }
714
715    #[test]
716    fn content_hashed_composes_byte_identical_to_pre_lift_literal_across_every_app_slot() {
717        let apps = ["api", "gateway", "smoke", "x"];
718        for app in apps {
719            let via_primitive = RoutingHostname::content_hashed(app);
720            let pre_lift = RoutingHostname {
721                app: app.into(),
722                instance: None,
723                cluster: None,
724            };
725            assert_eq!(
726                via_primitive, pre_lift,
727                "primitive must match pre-lift `RoutingHostname {{ app, instance: None, \
728                 cluster: None }}` literal byte-identically at (app={app:?})"
729            );
730        }
731    }
732
733    #[test]
734    fn content_hashed_is_not_named() {
735        // Peer-composition pin against `is_named`: an unset
736        // `instance` slot is definitionally content-hashed, i.e. NOT
737        // named — the reconciler's FQDN composer downstream
738        // substitutes `blake3(canonical_spec)[:8]` for the segment.
739        assert!(!RoutingHostname::content_hashed("smoke").is_named());
740    }
741
742    // ─── RoutingBackend::plain substrate pins ────────────────────
743    //
744    // The pre-lift workspace restated the 4-slot `RoutingBackend {
745    // service, port, tls_issuer: None, ingress_annotations:
746    // BTreeMap::new() }` fixture literal at SEVEN hand-authored sites
747    // past the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger.
748
749    #[test]
750    fn plain_composes_default_issuer_and_empty_annotations() {
751        let b = RoutingBackend::plain("svc", 8080);
752        assert_eq!(b.service, "svc");
753        assert_eq!(b.port, 8080);
754        assert!(b.tls_issuer.is_none());
755        assert!(b.ingress_annotations.is_empty());
756    }
757
758    #[test]
759    fn plain_composes_byte_identical_to_pre_lift_literal_across_every_service_port_pair() {
760        let pairs = [
761            ("demo-app-gateway", 8000_u16),
762            ("svc", 80),
763            ("svc", 8080),
764            ("x", 80),
765        ];
766        for (service, port) in pairs {
767            let via_primitive = RoutingBackend::plain(service, port);
768            let pre_lift = RoutingBackend {
769                service: service.into(),
770                port,
771                tls_issuer: None,
772                ingress_annotations: BTreeMap::new(),
773            };
774            assert_eq!(
775                via_primitive, pre_lift,
776                "primitive must match pre-lift `RoutingBackend {{ service, port, tls_issuer: \
777                 None, ingress_annotations: BTreeMap::new() }}` literal byte-identically at \
778                 (service={service:?}, port={port})"
779            );
780        }
781    }
782
783    #[test]
784    fn plain_wire_form_skips_defaulted_slots() {
785        // Peer-composition pin: the primitive stamps `tls_issuer:
786        // None` + empty `ingress_annotations`, both of which are
787        // `serde(skip_serializing_if)` — so the wire form MUST NOT
788        // include either key. A regression that flipped the default
789        // sentinels to non-empty values would leak them into every
790        // rendered wire form; this pin fails first.
791        let b = RoutingBackend::plain("svc", 80);
792        let yaml = serde_yaml::to_string(&b).unwrap();
793        assert!(!yaml.contains("tlsIssuer:"));
794        assert!(!yaml.contains("ingressAnnotations:"));
795        assert!(yaml.contains("service: svc"));
796        assert!(yaml.contains("port: 80"));
797    }
798
799    #[test]
800    fn serde_round_trip_via_yaml() {
801        let r = demo_routing();
802        let yaml = serde_yaml::to_string(&r).unwrap();
803        // camelCase wire form — what FluxCD / kubectl users see.
804        assert!(yaml.contains("hostnames:"));
805        assert!(yaml.contains("app: api"));
806        assert!(yaml.contains("instance: demo-prod"));
807        assert!(yaml.contains("backend:"));
808        assert!(yaml.contains("service: demo-app-gateway"));
809        assert!(yaml.contains("port: 8000"));
810        assert!(yaml.contains("stableNameClaim: true"));
811        assert!(yaml.contains("priority: 100"));
812
813        let back: RoutingSpec = serde_yaml::from_str(&yaml).unwrap();
814        assert_eq!(back.hostnames.len(), 2);
815        assert!(back.stable_name_claim);
816        assert_eq!(back.priority, 100);
817    }
818
819    #[test]
820    fn empty_fields_skip_serialize() {
821        // Minimal RoutingSpec — verify that absent optional fields
822        // don't pollute the wire format.
823        let r = RoutingSpec {
824            hostnames: vec![RoutingHostname::content_hashed("api")],
825            backend: RoutingBackend::plain("svc", 8080),
826            stable_name_claim: false,
827            priority: 0,
828        };
829        let yaml = serde_yaml::to_string(&r).unwrap();
830        // Optional + empty fields must NOT appear in the wire form.
831        assert!(!yaml.contains("instance:"));
832        assert!(!yaml.contains("cluster:"));
833        assert!(!yaml.contains("tlsIssuer:"));
834        assert!(!yaml.contains("ingressAnnotations:"));
835    }
836
837    #[test]
838    fn lisp_round_trip_via_defrouting() {
839        // The `(defrouting …)` keyword is registered by
840        // tatara_process::register_all (R3 adds this to the
841        // registry); for now compile via tatara_lisp directly.
842        let src = r#"
843            (defrouting demo-edges
844              :hostnames ((:app "api"   :instance "demo-prod")
845                          (:app "gateway" :instance "demo-prod"))
846              :backend   (:service "demo-app-gateway"
847                          :port 8000)
848              :stable-name-claim #t
849              :priority 100)
850        "#;
851        let defs: Vec<tatara_lisp::NamedDefinition<RoutingSpec>> =
852            tatara_lisp::compile_named::<RoutingSpec>(src).expect("compile");
853        assert_eq!(defs.len(), 1);
854        let d = &defs[0];
855        assert_eq!(d.name, "demo-edges");
856        assert_eq!(d.spec.hostnames.len(), 2);
857        assert_eq!(d.spec.hostnames[0].app, "api");
858        assert_eq!(d.spec.hostnames[0].instance.as_deref(), Some("demo-prod"));
859        assert_eq!(d.spec.backend.service, "demo-app-gateway");
860        assert_eq!(d.spec.backend.port, 8000);
861        assert!(d.spec.stable_name_claim);
862        assert_eq!(d.spec.priority, 100);
863    }
864
865    #[test]
866    fn lisp_round_trip_anonymous_instance() {
867        // `:instance` omitted ⇒ content-hash form (filled in by the
868        // hostname helper, not stored). Round-trip via Lisp +
869        // serde proves the Option<String> default flows cleanly.
870        let src = r#"
871            (defrouting smoke-edges
872              :hostnames ((:app "smoke"))
873              :backend   (:service "smoke" :port 80))
874        "#;
875        let defs: Vec<tatara_lisp::NamedDefinition<RoutingSpec>> =
876            tatara_lisp::compile_named::<RoutingSpec>(src).expect("compile");
877        let d = &defs[0];
878        assert_eq!(d.spec.hostnames.len(), 1);
879        assert_eq!(d.spec.hostnames[0].instance, None);
880        assert!(!d.spec.stable_name_claim); // default false
881        assert_eq!(d.spec.priority, 0); // default 0
882    }
883
884    // ─── RoutingForm substrate pins ───────────────────────────────
885    //
886    // The pre-lift `tatara-reconciler::edges` sites hand-wrote
887    // three `if ctx.is_stable { "stable" } else { "instance" }`
888    // ternaries at every axis (Ingress annotation, Ingress label,
889    // DNSEndpoint label) plus two byte-literal reads in render
890    // tests. Every byte the ternary + literals produced is pinned
891    // here so a rename of a `RoutingForm::as_str` arm surfaces at
892    // THIS composer's shipped-shape pin rather than as silent
893    // drift between the pre-lift edge sites (which pre-lift had
894    // already grown five copies of the same two-literal set).
895
896    #[test]
897    fn routing_form_as_str_matches_wire_form_pre_lift() {
898        // Byte-identity pin: the pre-lift ternary at
899        // `edges.rs::IngressEdge::render`,
900        // `edges.rs::DnsEndpointEdge::render` restated these two
901        // literals verbatim. A rename here is an
902        // operator-visible selector-mismatch after apply.
903        assert_eq!(RoutingForm::Stable.as_str(), "stable");
904        assert_eq!(RoutingForm::Instance.as_str(), "instance");
905    }
906
907    #[test]
908    fn routing_form_from_is_stable_routes_true_and_false() {
909        // Boolean → enum decision pinned here rather than restated
910        // as an inline ternary at every callsite.
911        assert_eq!(RoutingForm::from_is_stable(true), RoutingForm::Stable);
912        assert_eq!(RoutingForm::from_is_stable(false), RoutingForm::Instance);
913    }
914
915    #[test]
916    fn routing_form_round_trip_via_bool() {
917        // The decision the reconciler's `EdgeContext::is_stable`
918        // bool encodes is a two-variant disjunction; round-trip
919        // both bool values through the enum to prove the composer
920        // preserves the axis in both directions.
921        for is_stable in [true, false] {
922            let form = RoutingForm::from_is_stable(is_stable);
923            let expected = if is_stable { "stable" } else { "instance" };
924            assert_eq!(form.as_str(), expected);
925        }
926    }
927
928    #[test]
929    fn routing_form_annotation_key_is_prefixed_process_ns() {
930        // Byte-shape pin against the pre-lift string literal
931        // `edges.rs` restated four times (two annotation branches
932        // + two label sites). A rename that missed one of the
933        // pre-lift sites would silently split the axis across two
934        // K8s label keys — the const now closes that drift path.
935        assert_eq!(
936            crate::annotations::ROUTING_FORM,
937            "tatara.pleme.io/routing-form"
938        );
939    }
940
941    #[test]
942    fn routing_app_annotation_key_is_prefixed_process_ns() {
943        // Peer to `ROUTING_FORM`: pre-lift restated at the two
944        // `edges.rs` label sites (Ingress + DNSEndpoint).
945        assert_eq!(crate::annotations::APP, "tatara.pleme.io/app");
946    }
947
948    #[test]
949    fn ingress_annotations_round_trip() {
950        let mut annotations = BTreeMap::new();
951        annotations.insert(
952            "nginx.ingress.kubernetes.io/rate-limit".into(),
953            "100".into(),
954        );
955        annotations.insert(
956            "nginx.ingress.kubernetes.io/proxy-body-size".into(),
957            "10m".into(),
958        );
959        let r = RoutingSpec {
960            hostnames: vec![RoutingHostname::content_hashed("api")],
961            backend: RoutingBackend {
962                service: "svc".into(),
963                port: 8080,
964                tls_issuer: Some("letsencrypt-prod".into()),
965                ingress_annotations: annotations,
966            },
967            stable_name_claim: false,
968            priority: 0,
969        };
970        let yaml = serde_yaml::to_string(&r).unwrap();
971        assert!(yaml.contains("tlsIssuer: letsencrypt-prod"));
972        assert!(yaml.contains("rate-limit"));
973        let back: RoutingSpec = serde_yaml::from_str(&yaml).unwrap();
974        assert_eq!(back.backend.tls_issuer.as_deref(), Some("letsencrypt-prod"));
975        assert_eq!(back.backend.ingress_annotations.len(), 2);
976    }
977}