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}