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
200/// Wire-form value stamped at
201/// [`tatara_process::annotations::ROUTING_FORM`][
202/// crate::annotations::ROUTING_FORM] on every routing edge
203/// (Ingress + DNSEndpoint) — both the `annotations` axis and the
204/// `labels` axis carry it. Distinguishes the two FQDN shapes
205/// [`RoutingSpec`] emits: the per-instance form
206/// (`${app}.${eph_id}.${cluster}.${loc}.${domain}`) and the
207/// stable-claim form (`${app}.${cluster}.${loc}.${domain}`,
208/// emitted iff `stable_name_claim` is set and this Process
209/// currently holds the ProcessTable claim for `(cluster, app)`).
210///
211/// The pre-lift reconciler restated the same
212/// `if ctx.is_stable { "stable" } else { "instance" }` ternary at
213/// three call sites (an Ingress annotation, an Ingress label, a
214/// DNSEndpoint label) plus two byte-literal comparison sites in
215/// render tests. This typed enum turns that stringly-typed
216/// disjunction into a two-variant type with a single wire
217/// encoding, so a future edge kind (a Gateway API `HTTPRoute`, a
218/// `NetworkPolicy` edge) sourcing the axis through
219/// [`RoutingForm::from_is_stable`] + [`RoutingForm::as_str`]
220/// cannot drift from the two existing edges' spellings.
221#[derive(Clone, Copy, Debug, PartialEq, Eq)]
222pub enum RoutingForm {
223 /// Emitted iff `RoutingSpec.stable_name_claim = true` AND
224 /// this Process currently holds the ProcessTable claim for
225 /// `(cluster, app)`. FQDN drops the `${eph_id}` segment.
226 Stable,
227 /// Emitted for every declared hostname entry (default). FQDN
228 /// carries the `${eph_id}` segment resolved by
229 /// [`crate::hostname::resolve_ephemeral_id`].
230 Instance,
231}
232
233impl RoutingForm {
234 /// Wire-form byte-shape stamped into the
235 /// [`ROUTING_FORM`][crate::annotations::ROUTING_FORM]
236 /// annotation / label. The reconciler's stable-form filter
237 /// checks byte-identity against these two strings — a rename
238 /// here is a wire-form break every operator's kubectl-side
239 /// selector notices.
240 pub const fn as_str(self) -> &'static str {
241 match self {
242 RoutingForm::Stable => "stable",
243 RoutingForm::Instance => "instance",
244 }
245 }
246
247 /// Route the reconciler's `EdgeContext::is_stable` bool
248 /// through ONE composer so every downstream axis (the
249 /// stable-form suffix in edge resource names + the
250 /// [`ROUTING_FORM`][crate::annotations::ROUTING_FORM] value
251 /// on labels + annotations) shares the same source of truth.
252 pub const fn from_is_stable(is_stable: bool) -> Self {
253 if is_stable {
254 RoutingForm::Stable
255 } else {
256 RoutingForm::Instance
257 }
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 fn demo_routing() -> RoutingSpec {
266 RoutingSpec {
267 hostnames: vec![
268 RoutingHostname {
269 app: "api".into(),
270 instance: Some("demo-prod".into()),
271 cluster: None,
272 },
273 RoutingHostname {
274 app: "gateway".into(),
275 instance: Some("demo-prod".into()),
276 cluster: None,
277 },
278 ],
279 backend: RoutingBackend {
280 service: "demo-app-gateway".into(),
281 port: 8000,
282 tls_issuer: None,
283 ingress_annotations: BTreeMap::new(),
284 },
285 stable_name_claim: true,
286 priority: 100,
287 }
288 }
289
290 #[test]
291 fn empty_routing_resolves_no_hostnames() {
292 let r = RoutingSpec {
293 hostnames: vec![],
294 backend: RoutingBackend {
295 service: "x".into(),
296 port: 80,
297 tls_issuer: None,
298 ingress_annotations: BTreeMap::new(),
299 },
300 stable_name_claim: false,
301 priority: 0,
302 };
303 assert!(!r.has_hostnames());
304 assert_eq!(r.emitted_fqdn_count(false), 0);
305 assert_eq!(r.emitted_fqdn_count(true), 0);
306 }
307
308 #[test]
309 fn fqdn_count_doubles_when_claim_held() {
310 let r = demo_routing();
311 assert_eq!(r.emitted_fqdn_count(false), 2);
312 assert_eq!(r.emitted_fqdn_count(true), 4);
313 }
314
315 #[test]
316 fn hostname_is_named_when_instance_nonempty() {
317 let h = RoutingHostname {
318 app: "x".into(),
319 instance: Some("env-a".into()),
320 cluster: None,
321 };
322 assert!(h.is_named());
323
324 let h_anon = RoutingHostname {
325 app: "x".into(),
326 instance: None,
327 cluster: None,
328 };
329 assert!(!h_anon.is_named());
330
331 let h_empty = RoutingHostname {
332 app: "x".into(),
333 instance: Some(String::new()),
334 cluster: None,
335 };
336 assert!(!h_empty.is_named()); // empty string ⇒ unnamed
337 }
338
339 // ─── RoutingHostname::cluster_or substrate pins ──────────────
340 //
341 // The pre-lift reconciler restated the same
342 // `hostname.cluster.as_deref().unwrap_or(<cfg-cluster>)` chain at
343 // TWO callsites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
344 // trigger:
345 // * render.rs::render_routing (line 561) — FQDN composer seed
346 // * table_controller.rs::stable_name_group_key (line 101) —
347 // claim-arbiter group-key seed
348 // Every corner of the paired projection is pinned here so a
349 // future normalization at the primitive lands with a
350 // fail-before-pass-after regression at THIS composer's pins
351 // rather than as silent operator-visible drift across the two
352 // callsite arms.
353
354 #[test]
355 fn cluster_or_returns_slot_when_cluster_is_populated() {
356 let h = RoutingHostname {
357 app: "api".into(),
358 instance: None,
359 cluster: Some("pleme-prod".into()),
360 };
361 assert_eq!(h.cluster_or("pleme-dev"), "pleme-prod");
362 }
363
364 #[test]
365 fn cluster_or_falls_back_to_caller_string_when_cluster_is_none() {
366 let h = RoutingHostname {
367 app: "api".into(),
368 instance: None,
369 cluster: None,
370 };
371 assert_eq!(h.cluster_or("pleme-dev"), "pleme-dev");
372 }
373
374 #[test]
375 fn cluster_or_returns_empty_slice_when_cluster_is_explicitly_empty_string() {
376 // Populated-empty short-circuit pin: `Some("")` is a
377 // populated slot for `as_deref().unwrap_or(...)`, so the
378 // fallback is NOT taken. The downstream FQDN composer's
379 // validator (`fmt_fqdn`) rejects the empty label with a
380 // typed `HostnameError::InvalidLabel`, not this primitive.
381 let h = RoutingHostname {
382 app: "api".into(),
383 instance: None,
384 cluster: Some(String::new()),
385 };
386 assert_eq!(h.cluster_or("pleme-dev"), "");
387 }
388
389 #[test]
390 fn cluster_or_is_a_pure_projection() {
391 // Two identical inputs → two identical outputs; no interior
392 // mutation or per-call hidden state.
393 let h = RoutingHostname {
394 app: "api".into(),
395 instance: Some("demo-prod".into()),
396 cluster: Some("pleme-prod".into()),
397 };
398 let a = h.cluster_or("pleme-dev");
399 let b = h.cluster_or("pleme-dev");
400 assert_eq!(a, b);
401 assert_eq!(a, "pleme-prod");
402 }
403
404 #[test]
405 fn cluster_or_borrow_form_return_shape_matches_fmt_fqdn_arg_shape() {
406 // The primitive returns `&str` so it slots straight into
407 // `fmt_fqdn(&hostname.app, eph_id, host_cluster, location,
408 // domain)` at `render::render_routing` without a
409 // `.to_string()` step. Compose here so a future return-shape
410 // change (owned `String`, `Cow<'_, str>`) breaks this pin,
411 // not the reconciler.
412 use crate::hostname::fmt_fqdn;
413 let h = RoutingHostname {
414 app: "api".into(),
415 instance: Some("demo-prod".into()),
416 cluster: None,
417 };
418 let host_cluster: &str = h.cluster_or("pleme-dev");
419 let fqdn = fmt_fqdn(
420 &h.app,
421 h.instance.as_deref().unwrap(),
422 host_cluster,
423 "use1",
424 "quero.lol",
425 )
426 .expect("fmt_fqdn");
427 assert_eq!(fqdn, "api.demo-prod.pleme-dev.use1.quero.lol");
428 }
429
430 #[test]
431 fn cluster_or_matches_pre_lift_chain_verbatim() {
432 // Full 4-corner byte-identical parity table across the
433 // `(cluster slot × fallback shape)` axis pair. Any
434 // divergence between the primitive and each pre-lift
435 // callsite's inline chain surfaces HERE rather than as
436 // per-site operator-visible drift.
437 let fallbacks = ["pleme-dev", "pleme-prod", "", "some-other-cluster"];
438 let cluster_slots = [
439 None,
440 Some(String::new()),
441 Some("pleme-prod".into()),
442 Some("edge-1".into()),
443 ];
444 for fallback in fallbacks {
445 for cluster in &cluster_slots {
446 let h = RoutingHostname {
447 app: "api".into(),
448 instance: None,
449 cluster: cluster.clone(),
450 };
451 let pre_lift = h.cluster.as_deref().unwrap_or(fallback);
452 let via_primitive = h.cluster_or(fallback);
453 assert_eq!(
454 via_primitive, pre_lift,
455 "primitive must match pre-lift `.as_deref().unwrap_or(fallback)` chain \
456 byte-identically at (fallback={fallback:?}, cluster={cluster:?})"
457 );
458 }
459 }
460 }
461
462 #[test]
463 fn cluster_or_composes_with_stable_group_key_shape() {
464 // Peer-composition pin against
465 // `table_controller::stable_name_group_key`'s downstream
466 // seed shape (`format!("{cluster}/{}", hostname.app)`).
467 // A future rename of the separator or the composer's
468 // ordering breaks this pin, not the claim-arbiter row seed.
469 let h = RoutingHostname {
470 app: "api".into(),
471 instance: None,
472 cluster: None,
473 };
474 let cluster = h.cluster_or("pleme-dev");
475 let key = format!("{cluster}/{}", h.app);
476 assert_eq!(key, "pleme-dev/api");
477
478 let h_over = RoutingHostname {
479 app: "api".into(),
480 instance: None,
481 cluster: Some("pleme-prod".into()),
482 };
483 let cluster = h_over.cluster_or("pleme-dev");
484 let key = format!("{cluster}/{}", h_over.app);
485 assert_eq!(key, "pleme-prod/api");
486 }
487
488 #[test]
489 fn cluster_or_lifetime_ties_output_to_the_shorter_of_self_or_fallback() {
490 // Compile-time proof (via the return signature) that the
491 // returned slice borrows through EITHER `&self.cluster` or
492 // `&fallback` — the caller cannot outlive the shorter of
493 // the two. If a future refactor loosens the lifetime to
494 // `&'a str` where `'a` is only tied to `self`, this test
495 // stops compiling with the fallback-borrow arm.
496 let h = RoutingHostname {
497 app: "api".into(),
498 instance: None,
499 cluster: None,
500 };
501 {
502 let fallback = String::from("pleme-dev");
503 let slice = h.cluster_or(&fallback);
504 assert_eq!(slice, "pleme-dev");
505 // `slice` cannot escape this scope — its lifetime is
506 // bounded by `fallback`. That's the compile-time
507 // discipline the `<'a>` on the primitive encodes.
508 }
509 }
510
511 #[test]
512 fn serde_round_trip_via_yaml() {
513 let r = demo_routing();
514 let yaml = serde_yaml::to_string(&r).unwrap();
515 // camelCase wire form — what FluxCD / kubectl users see.
516 assert!(yaml.contains("hostnames:"));
517 assert!(yaml.contains("app: api"));
518 assert!(yaml.contains("instance: demo-prod"));
519 assert!(yaml.contains("backend:"));
520 assert!(yaml.contains("service: demo-app-gateway"));
521 assert!(yaml.contains("port: 8000"));
522 assert!(yaml.contains("stableNameClaim: true"));
523 assert!(yaml.contains("priority: 100"));
524
525 let back: RoutingSpec = serde_yaml::from_str(&yaml).unwrap();
526 assert_eq!(back.hostnames.len(), 2);
527 assert!(back.stable_name_claim);
528 assert_eq!(back.priority, 100);
529 }
530
531 #[test]
532 fn empty_fields_skip_serialize() {
533 // Minimal RoutingSpec — verify that absent optional fields
534 // don't pollute the wire format.
535 let r = RoutingSpec {
536 hostnames: vec![RoutingHostname {
537 app: "api".into(),
538 instance: None,
539 cluster: None,
540 }],
541 backend: RoutingBackend {
542 service: "svc".into(),
543 port: 8080,
544 tls_issuer: None,
545 ingress_annotations: BTreeMap::new(),
546 },
547 stable_name_claim: false,
548 priority: 0,
549 };
550 let yaml = serde_yaml::to_string(&r).unwrap();
551 // Optional + empty fields must NOT appear in the wire form.
552 assert!(!yaml.contains("instance:"));
553 assert!(!yaml.contains("cluster:"));
554 assert!(!yaml.contains("tlsIssuer:"));
555 assert!(!yaml.contains("ingressAnnotations:"));
556 }
557
558 #[test]
559 fn lisp_round_trip_via_defrouting() {
560 // The `(defrouting …)` keyword is registered by
561 // tatara_process::register_all (R3 adds this to the
562 // registry); for now compile via tatara_lisp directly.
563 let src = r#"
564 (defrouting demo-edges
565 :hostnames ((:app "api" :instance "demo-prod")
566 (:app "gateway" :instance "demo-prod"))
567 :backend (:service "demo-app-gateway"
568 :port 8000)
569 :stable-name-claim #t
570 :priority 100)
571 "#;
572 let defs: Vec<tatara_lisp::NamedDefinition<RoutingSpec>> =
573 tatara_lisp::compile_named::<RoutingSpec>(src).expect("compile");
574 assert_eq!(defs.len(), 1);
575 let d = &defs[0];
576 assert_eq!(d.name, "demo-edges");
577 assert_eq!(d.spec.hostnames.len(), 2);
578 assert_eq!(d.spec.hostnames[0].app, "api");
579 assert_eq!(d.spec.hostnames[0].instance.as_deref(), Some("demo-prod"));
580 assert_eq!(d.spec.backend.service, "demo-app-gateway");
581 assert_eq!(d.spec.backend.port, 8000);
582 assert!(d.spec.stable_name_claim);
583 assert_eq!(d.spec.priority, 100);
584 }
585
586 #[test]
587 fn lisp_round_trip_anonymous_instance() {
588 // `:instance` omitted ⇒ content-hash form (filled in by the
589 // hostname helper, not stored). Round-trip via Lisp +
590 // serde proves the Option<String> default flows cleanly.
591 let src = r#"
592 (defrouting smoke-edges
593 :hostnames ((:app "smoke"))
594 :backend (:service "smoke" :port 80))
595 "#;
596 let defs: Vec<tatara_lisp::NamedDefinition<RoutingSpec>> =
597 tatara_lisp::compile_named::<RoutingSpec>(src).expect("compile");
598 let d = &defs[0];
599 assert_eq!(d.spec.hostnames.len(), 1);
600 assert_eq!(d.spec.hostnames[0].instance, None);
601 assert!(!d.spec.stable_name_claim); // default false
602 assert_eq!(d.spec.priority, 0); // default 0
603 }
604
605 // ─── RoutingForm substrate pins ───────────────────────────────
606 //
607 // The pre-lift `tatara-reconciler::edges` sites hand-wrote
608 // three `if ctx.is_stable { "stable" } else { "instance" }`
609 // ternaries at every axis (Ingress annotation, Ingress label,
610 // DNSEndpoint label) plus two byte-literal reads in render
611 // tests. Every byte the ternary + literals produced is pinned
612 // here so a rename of a `RoutingForm::as_str` arm surfaces at
613 // THIS composer's shipped-shape pin rather than as silent
614 // drift between the pre-lift edge sites (which pre-lift had
615 // already grown five copies of the same two-literal set).
616
617 #[test]
618 fn routing_form_as_str_matches_wire_form_pre_lift() {
619 // Byte-identity pin: the pre-lift ternary at
620 // `edges.rs::IngressEdge::render`,
621 // `edges.rs::DnsEndpointEdge::render` restated these two
622 // literals verbatim. A rename here is an
623 // operator-visible selector-mismatch after apply.
624 assert_eq!(RoutingForm::Stable.as_str(), "stable");
625 assert_eq!(RoutingForm::Instance.as_str(), "instance");
626 }
627
628 #[test]
629 fn routing_form_from_is_stable_routes_true_and_false() {
630 // Boolean → enum decision pinned here rather than restated
631 // as an inline ternary at every callsite.
632 assert_eq!(RoutingForm::from_is_stable(true), RoutingForm::Stable);
633 assert_eq!(RoutingForm::from_is_stable(false), RoutingForm::Instance);
634 }
635
636 #[test]
637 fn routing_form_round_trip_via_bool() {
638 // The decision the reconciler's `EdgeContext::is_stable`
639 // bool encodes is a two-variant disjunction; round-trip
640 // both bool values through the enum to prove the composer
641 // preserves the axis in both directions.
642 for is_stable in [true, false] {
643 let form = RoutingForm::from_is_stable(is_stable);
644 let expected = if is_stable { "stable" } else { "instance" };
645 assert_eq!(form.as_str(), expected);
646 }
647 }
648
649 #[test]
650 fn routing_form_annotation_key_is_prefixed_process_ns() {
651 // Byte-shape pin against the pre-lift string literal
652 // `edges.rs` restated four times (two annotation branches
653 // + two label sites). A rename that missed one of the
654 // pre-lift sites would silently split the axis across two
655 // K8s label keys — the const now closes that drift path.
656 assert_eq!(
657 crate::annotations::ROUTING_FORM,
658 "tatara.pleme.io/routing-form"
659 );
660 }
661
662 #[test]
663 fn routing_app_annotation_key_is_prefixed_process_ns() {
664 // Peer to `ROUTING_FORM`: pre-lift restated at the two
665 // `edges.rs` label sites (Ingress + DNSEndpoint).
666 assert_eq!(crate::annotations::APP, "tatara.pleme.io/app");
667 }
668
669 #[test]
670 fn ingress_annotations_round_trip() {
671 let mut annotations = BTreeMap::new();
672 annotations.insert(
673 "nginx.ingress.kubernetes.io/rate-limit".into(),
674 "100".into(),
675 );
676 annotations.insert(
677 "nginx.ingress.kubernetes.io/proxy-body-size".into(),
678 "10m".into(),
679 );
680 let r = RoutingSpec {
681 hostnames: vec![RoutingHostname {
682 app: "api".into(),
683 instance: None,
684 cluster: None,
685 }],
686 backend: RoutingBackend {
687 service: "svc".into(),
688 port: 8080,
689 tls_issuer: Some("letsencrypt-prod".into()),
690 ingress_annotations: annotations,
691 },
692 stable_name_claim: false,
693 priority: 0,
694 };
695 let yaml = serde_yaml::to_string(&r).unwrap();
696 assert!(yaml.contains("tlsIssuer: letsencrypt-prod"));
697 assert!(yaml.contains("rate-limit"));
698 let back: RoutingSpec = serde_yaml::from_str(&yaml).unwrap();
699 assert_eq!(back.backend.tls_issuer.as_deref(), Some("letsencrypt-prod"));
700 assert_eq!(back.backend.ingress_annotations.len(), 2);
701 }
702}