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
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}