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
153/// Wire-form value stamped at
154/// [`tatara_process::annotations::ROUTING_FORM`][
155/// crate::annotations::ROUTING_FORM] on every routing edge
156/// (Ingress + DNSEndpoint) — both the `annotations` axis and the
157/// `labels` axis carry it. Distinguishes the two FQDN shapes
158/// [`RoutingSpec`] emits: the per-instance form
159/// (`${app}.${eph_id}.${cluster}.${loc}.${domain}`) and the
160/// stable-claim form (`${app}.${cluster}.${loc}.${domain}`,
161/// emitted iff `stable_name_claim` is set and this Process
162/// currently holds the ProcessTable claim for `(cluster, app)`).
163///
164/// The pre-lift reconciler restated the same
165/// `if ctx.is_stable { "stable" } else { "instance" }` ternary at
166/// three call sites (an Ingress annotation, an Ingress label, a
167/// DNSEndpoint label) plus two byte-literal comparison sites in
168/// render tests. This typed enum turns that stringly-typed
169/// disjunction into a two-variant type with a single wire
170/// encoding, so a future edge kind (a Gateway API `HTTPRoute`, a
171/// `NetworkPolicy` edge) sourcing the axis through
172/// [`RoutingForm::from_is_stable`] + [`RoutingForm::as_str`]
173/// cannot drift from the two existing edges' spellings.
174#[derive(Clone, Copy, Debug, PartialEq, Eq)]
175pub enum RoutingForm {
176    /// Emitted iff `RoutingSpec.stable_name_claim = true` AND
177    /// this Process currently holds the ProcessTable claim for
178    /// `(cluster, app)`. FQDN drops the `${eph_id}` segment.
179    Stable,
180    /// Emitted for every declared hostname entry (default). FQDN
181    /// carries the `${eph_id}` segment resolved by
182    /// [`crate::hostname::resolve_ephemeral_id`].
183    Instance,
184}
185
186impl RoutingForm {
187    /// Wire-form byte-shape stamped into the
188    /// [`ROUTING_FORM`][crate::annotations::ROUTING_FORM]
189    /// annotation / label. The reconciler's stable-form filter
190    /// checks byte-identity against these two strings — a rename
191    /// here is a wire-form break every operator's kubectl-side
192    /// selector notices.
193    pub const fn as_str(self) -> &'static str {
194        match self {
195            RoutingForm::Stable => "stable",
196            RoutingForm::Instance => "instance",
197        }
198    }
199
200    /// Route the reconciler's `EdgeContext::is_stable` bool
201    /// through ONE composer so every downstream axis (the
202    /// stable-form suffix in edge resource names + the
203    /// [`ROUTING_FORM`][crate::annotations::ROUTING_FORM] value
204    /// on labels + annotations) shares the same source of truth.
205    pub const fn from_is_stable(is_stable: bool) -> Self {
206        if is_stable {
207            RoutingForm::Stable
208        } else {
209            RoutingForm::Instance
210        }
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    fn demo_routing() -> RoutingSpec {
219        RoutingSpec {
220            hostnames: vec![
221                RoutingHostname {
222                    app: "api".into(),
223                    instance: Some("demo-prod".into()),
224                    cluster: None,
225                },
226                RoutingHostname {
227                    app: "gateway".into(),
228                    instance: Some("demo-prod".into()),
229                    cluster: None,
230                },
231            ],
232            backend: RoutingBackend {
233                service: "demo-app-gateway".into(),
234                port: 8000,
235                tls_issuer: None,
236                ingress_annotations: BTreeMap::new(),
237            },
238            stable_name_claim: true,
239            priority: 100,
240        }
241    }
242
243    #[test]
244    fn empty_routing_resolves_no_hostnames() {
245        let r = RoutingSpec {
246            hostnames: vec![],
247            backend: RoutingBackend {
248                service: "x".into(),
249                port: 80,
250                tls_issuer: None,
251                ingress_annotations: BTreeMap::new(),
252            },
253            stable_name_claim: false,
254            priority: 0,
255        };
256        assert!(!r.has_hostnames());
257        assert_eq!(r.emitted_fqdn_count(false), 0);
258        assert_eq!(r.emitted_fqdn_count(true), 0);
259    }
260
261    #[test]
262    fn fqdn_count_doubles_when_claim_held() {
263        let r = demo_routing();
264        assert_eq!(r.emitted_fqdn_count(false), 2);
265        assert_eq!(r.emitted_fqdn_count(true), 4);
266    }
267
268    #[test]
269    fn hostname_is_named_when_instance_nonempty() {
270        let h = RoutingHostname {
271            app: "x".into(),
272            instance: Some("env-a".into()),
273            cluster: None,
274        };
275        assert!(h.is_named());
276
277        let h_anon = RoutingHostname {
278            app: "x".into(),
279            instance: None,
280            cluster: None,
281        };
282        assert!(!h_anon.is_named());
283
284        let h_empty = RoutingHostname {
285            app: "x".into(),
286            instance: Some(String::new()),
287            cluster: None,
288        };
289        assert!(!h_empty.is_named()); // empty string ⇒ unnamed
290    }
291
292    #[test]
293    fn serde_round_trip_via_yaml() {
294        let r = demo_routing();
295        let yaml = serde_yaml::to_string(&r).unwrap();
296        // camelCase wire form — what FluxCD / kubectl users see.
297        assert!(yaml.contains("hostnames:"));
298        assert!(yaml.contains("app: api"));
299        assert!(yaml.contains("instance: demo-prod"));
300        assert!(yaml.contains("backend:"));
301        assert!(yaml.contains("service: demo-app-gateway"));
302        assert!(yaml.contains("port: 8000"));
303        assert!(yaml.contains("stableNameClaim: true"));
304        assert!(yaml.contains("priority: 100"));
305
306        let back: RoutingSpec = serde_yaml::from_str(&yaml).unwrap();
307        assert_eq!(back.hostnames.len(), 2);
308        assert!(back.stable_name_claim);
309        assert_eq!(back.priority, 100);
310    }
311
312    #[test]
313    fn empty_fields_skip_serialize() {
314        // Minimal RoutingSpec — verify that absent optional fields
315        // don't pollute the wire format.
316        let r = RoutingSpec {
317            hostnames: vec![RoutingHostname {
318                app: "api".into(),
319                instance: None,
320                cluster: None,
321            }],
322            backend: RoutingBackend {
323                service: "svc".into(),
324                port: 8080,
325                tls_issuer: None,
326                ingress_annotations: BTreeMap::new(),
327            },
328            stable_name_claim: false,
329            priority: 0,
330        };
331        let yaml = serde_yaml::to_string(&r).unwrap();
332        // Optional + empty fields must NOT appear in the wire form.
333        assert!(!yaml.contains("instance:"));
334        assert!(!yaml.contains("cluster:"));
335        assert!(!yaml.contains("tlsIssuer:"));
336        assert!(!yaml.contains("ingressAnnotations:"));
337    }
338
339    #[test]
340    fn lisp_round_trip_via_defrouting() {
341        // The `(defrouting …)` keyword is registered by
342        // tatara_process::register_all (R3 adds this to the
343        // registry); for now compile via tatara_lisp directly.
344        let src = r#"
345            (defrouting demo-edges
346              :hostnames ((:app "api"   :instance "demo-prod")
347                          (:app "gateway" :instance "demo-prod"))
348              :backend   (:service "demo-app-gateway"
349                          :port 8000)
350              :stable-name-claim #t
351              :priority 100)
352        "#;
353        let defs: Vec<tatara_lisp::NamedDefinition<RoutingSpec>> =
354            tatara_lisp::compile_named::<RoutingSpec>(src).expect("compile");
355        assert_eq!(defs.len(), 1);
356        let d = &defs[0];
357        assert_eq!(d.name, "demo-edges");
358        assert_eq!(d.spec.hostnames.len(), 2);
359        assert_eq!(d.spec.hostnames[0].app, "api");
360        assert_eq!(d.spec.hostnames[0].instance.as_deref(), Some("demo-prod"));
361        assert_eq!(d.spec.backend.service, "demo-app-gateway");
362        assert_eq!(d.spec.backend.port, 8000);
363        assert!(d.spec.stable_name_claim);
364        assert_eq!(d.spec.priority, 100);
365    }
366
367    #[test]
368    fn lisp_round_trip_anonymous_instance() {
369        // `:instance` omitted ⇒ content-hash form (filled in by the
370        // hostname helper, not stored). Round-trip via Lisp +
371        // serde proves the Option<String> default flows cleanly.
372        let src = r#"
373            (defrouting smoke-edges
374              :hostnames ((:app "smoke"))
375              :backend   (:service "smoke" :port 80))
376        "#;
377        let defs: Vec<tatara_lisp::NamedDefinition<RoutingSpec>> =
378            tatara_lisp::compile_named::<RoutingSpec>(src).expect("compile");
379        let d = &defs[0];
380        assert_eq!(d.spec.hostnames.len(), 1);
381        assert_eq!(d.spec.hostnames[0].instance, None);
382        assert!(!d.spec.stable_name_claim); // default false
383        assert_eq!(d.spec.priority, 0); // default 0
384    }
385
386    // ─── RoutingForm substrate pins ───────────────────────────────
387    //
388    // The pre-lift `tatara-reconciler::edges` sites hand-wrote
389    // three `if ctx.is_stable { "stable" } else { "instance" }`
390    // ternaries at every axis (Ingress annotation, Ingress label,
391    // DNSEndpoint label) plus two byte-literal reads in render
392    // tests. Every byte the ternary + literals produced is pinned
393    // here so a rename of a `RoutingForm::as_str` arm surfaces at
394    // THIS composer's shipped-shape pin rather than as silent
395    // drift between the pre-lift edge sites (which pre-lift had
396    // already grown five copies of the same two-literal set).
397
398    #[test]
399    fn routing_form_as_str_matches_wire_form_pre_lift() {
400        // Byte-identity pin: the pre-lift ternary at
401        // `edges.rs::IngressEdge::render`,
402        // `edges.rs::DnsEndpointEdge::render` restated these two
403        // literals verbatim. A rename here is an
404        // operator-visible selector-mismatch after apply.
405        assert_eq!(RoutingForm::Stable.as_str(), "stable");
406        assert_eq!(RoutingForm::Instance.as_str(), "instance");
407    }
408
409    #[test]
410    fn routing_form_from_is_stable_routes_true_and_false() {
411        // Boolean → enum decision pinned here rather than restated
412        // as an inline ternary at every callsite.
413        assert_eq!(RoutingForm::from_is_stable(true), RoutingForm::Stable);
414        assert_eq!(RoutingForm::from_is_stable(false), RoutingForm::Instance);
415    }
416
417    #[test]
418    fn routing_form_round_trip_via_bool() {
419        // The decision the reconciler's `EdgeContext::is_stable`
420        // bool encodes is a two-variant disjunction; round-trip
421        // both bool values through the enum to prove the composer
422        // preserves the axis in both directions.
423        for is_stable in [true, false] {
424            let form = RoutingForm::from_is_stable(is_stable);
425            let expected = if is_stable { "stable" } else { "instance" };
426            assert_eq!(form.as_str(), expected);
427        }
428    }
429
430    #[test]
431    fn routing_form_annotation_key_is_prefixed_process_ns() {
432        // Byte-shape pin against the pre-lift string literal
433        // `edges.rs` restated four times (two annotation branches
434        // + two label sites). A rename that missed one of the
435        // pre-lift sites would silently split the axis across two
436        // K8s label keys — the const now closes that drift path.
437        assert_eq!(
438            crate::annotations::ROUTING_FORM,
439            "tatara.pleme.io/routing-form"
440        );
441    }
442
443    #[test]
444    fn routing_app_annotation_key_is_prefixed_process_ns() {
445        // Peer to `ROUTING_FORM`: pre-lift restated at the two
446        // `edges.rs` label sites (Ingress + DNSEndpoint).
447        assert_eq!(crate::annotations::APP, "tatara.pleme.io/app");
448    }
449
450    #[test]
451    fn ingress_annotations_round_trip() {
452        let mut annotations = BTreeMap::new();
453        annotations.insert(
454            "nginx.ingress.kubernetes.io/rate-limit".into(),
455            "100".into(),
456        );
457        annotations.insert(
458            "nginx.ingress.kubernetes.io/proxy-body-size".into(),
459            "10m".into(),
460        );
461        let r = RoutingSpec {
462            hostnames: vec![RoutingHostname {
463                app: "api".into(),
464                instance: None,
465                cluster: None,
466            }],
467            backend: RoutingBackend {
468                service: "svc".into(),
469                port: 8080,
470                tls_issuer: Some("letsencrypt-prod".into()),
471                ingress_annotations: annotations,
472            },
473            stable_name_claim: false,
474            priority: 0,
475        };
476        let yaml = serde_yaml::to_string(&r).unwrap();
477        assert!(yaml.contains("tlsIssuer: letsencrypt-prod"));
478        assert!(yaml.contains("rate-limit"));
479        let back: RoutingSpec = serde_yaml::from_str(&yaml).unwrap();
480        assert_eq!(back.backend.tls_issuer.as_deref(), Some("letsencrypt-prod"));
481        assert_eq!(back.backend.ingress_annotations.len(), 2);
482    }
483}