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 "gator" :instance "akeyless-prod")
27//!                       (:app "gateway"))
28//!           :backend   (:service "akeyless-saas-akeyless-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`, `gator`, `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("akeyless-prod")` the FQDN
92    /// reads `${app}.akeyless-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#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn akeyless_routing() -> RoutingSpec {
158        RoutingSpec {
159            hostnames: vec![
160                RoutingHostname {
161                    app: "gator".into(),
162                    instance: Some("akeyless-prod".into()),
163                    cluster: None,
164                },
165                RoutingHostname {
166                    app: "gateway".into(),
167                    instance: Some("akeyless-prod".into()),
168                    cluster: None,
169                },
170            ],
171            backend: RoutingBackend {
172                service: "akeyless-saas-akeyless-gateway".into(),
173                port: 8000,
174                tls_issuer: None,
175                ingress_annotations: BTreeMap::new(),
176            },
177            stable_name_claim: true,
178            priority: 100,
179        }
180    }
181
182    #[test]
183    fn empty_routing_resolves_no_hostnames() {
184        let r = RoutingSpec {
185            hostnames: vec![],
186            backend: RoutingBackend {
187                service: "x".into(),
188                port: 80,
189                tls_issuer: None,
190                ingress_annotations: BTreeMap::new(),
191            },
192            stable_name_claim: false,
193            priority: 0,
194        };
195        assert!(!r.has_hostnames());
196        assert_eq!(r.emitted_fqdn_count(false), 0);
197        assert_eq!(r.emitted_fqdn_count(true), 0);
198    }
199
200    #[test]
201    fn fqdn_count_doubles_when_claim_held() {
202        let r = akeyless_routing();
203        assert_eq!(r.emitted_fqdn_count(false), 2);
204        assert_eq!(r.emitted_fqdn_count(true), 4);
205    }
206
207    #[test]
208    fn hostname_is_named_when_instance_nonempty() {
209        let h = RoutingHostname {
210            app: "x".into(),
211            instance: Some("env-a".into()),
212            cluster: None,
213        };
214        assert!(h.is_named());
215
216        let h_anon = RoutingHostname {
217            app: "x".into(),
218            instance: None,
219            cluster: None,
220        };
221        assert!(!h_anon.is_named());
222
223        let h_empty = RoutingHostname {
224            app: "x".into(),
225            instance: Some(String::new()),
226            cluster: None,
227        };
228        assert!(!h_empty.is_named()); // empty string ⇒ unnamed
229    }
230
231    #[test]
232    fn serde_round_trip_via_yaml() {
233        let r = akeyless_routing();
234        let yaml = serde_yaml::to_string(&r).unwrap();
235        // camelCase wire form — what FluxCD / kubectl users see.
236        assert!(yaml.contains("hostnames:"));
237        assert!(yaml.contains("app: gator"));
238        assert!(yaml.contains("instance: akeyless-prod"));
239        assert!(yaml.contains("backend:"));
240        assert!(yaml.contains("service: akeyless-saas-akeyless-gateway"));
241        assert!(yaml.contains("port: 8000"));
242        assert!(yaml.contains("stableNameClaim: true"));
243        assert!(yaml.contains("priority: 100"));
244
245        let back: RoutingSpec = serde_yaml::from_str(&yaml).unwrap();
246        assert_eq!(back.hostnames.len(), 2);
247        assert!(back.stable_name_claim);
248        assert_eq!(back.priority, 100);
249    }
250
251    #[test]
252    fn empty_fields_skip_serialize() {
253        // Minimal RoutingSpec — verify that absent optional fields
254        // don't pollute the wire format.
255        let r = RoutingSpec {
256            hostnames: vec![RoutingHostname {
257                app: "api".into(),
258                instance: None,
259                cluster: None,
260            }],
261            backend: RoutingBackend {
262                service: "svc".into(),
263                port: 8080,
264                tls_issuer: None,
265                ingress_annotations: BTreeMap::new(),
266            },
267            stable_name_claim: false,
268            priority: 0,
269        };
270        let yaml = serde_yaml::to_string(&r).unwrap();
271        // Optional + empty fields must NOT appear in the wire form.
272        assert!(!yaml.contains("instance:"));
273        assert!(!yaml.contains("cluster:"));
274        assert!(!yaml.contains("tlsIssuer:"));
275        assert!(!yaml.contains("ingressAnnotations:"));
276    }
277
278    #[test]
279    fn lisp_round_trip_via_defrouting() {
280        // The `(defrouting …)` keyword is registered by
281        // tatara_process::register_all (R3 adds this to the
282        // registry); for now compile via tatara_lisp directly.
283        let src = r#"
284            (defrouting akeyless-edges
285              :hostnames ((:app "gator"   :instance "akeyless-prod")
286                          (:app "gateway" :instance "akeyless-prod"))
287              :backend   (:service "akeyless-saas-akeyless-gateway"
288                          :port 8000)
289              :stable-name-claim #t
290              :priority 100)
291        "#;
292        let defs: Vec<tatara_lisp::NamedDefinition<RoutingSpec>> =
293            tatara_lisp::compile_named::<RoutingSpec>(src).expect("compile");
294        assert_eq!(defs.len(), 1);
295        let d = &defs[0];
296        assert_eq!(d.name, "akeyless-edges");
297        assert_eq!(d.spec.hostnames.len(), 2);
298        assert_eq!(d.spec.hostnames[0].app, "gator");
299        assert_eq!(
300            d.spec.hostnames[0].instance.as_deref(),
301            Some("akeyless-prod")
302        );
303        assert_eq!(d.spec.backend.service, "akeyless-saas-akeyless-gateway");
304        assert_eq!(d.spec.backend.port, 8000);
305        assert!(d.spec.stable_name_claim);
306        assert_eq!(d.spec.priority, 100);
307    }
308
309    #[test]
310    fn lisp_round_trip_anonymous_instance() {
311        // `:instance` omitted ⇒ content-hash form (filled in by the
312        // hostname helper, not stored). Round-trip via Lisp +
313        // serde proves the Option<String> default flows cleanly.
314        let src = r#"
315            (defrouting smoke-edges
316              :hostnames ((:app "smoke"))
317              :backend   (:service "smoke" :port 80))
318        "#;
319        let defs: Vec<tatara_lisp::NamedDefinition<RoutingSpec>> =
320            tatara_lisp::compile_named::<RoutingSpec>(src).expect("compile");
321        let d = &defs[0];
322        assert_eq!(d.spec.hostnames.len(), 1);
323        assert_eq!(d.spec.hostnames[0].instance, None);
324        assert!(!d.spec.stable_name_claim); // default false
325        assert_eq!(d.spec.priority, 0); // default 0
326    }
327
328    #[test]
329    fn ingress_annotations_round_trip() {
330        let mut annotations = BTreeMap::new();
331        annotations.insert(
332            "nginx.ingress.kubernetes.io/rate-limit".into(),
333            "100".into(),
334        );
335        annotations.insert(
336            "nginx.ingress.kubernetes.io/proxy-body-size".into(),
337            "10m".into(),
338        );
339        let r = RoutingSpec {
340            hostnames: vec![RoutingHostname {
341                app: "api".into(),
342                instance: None,
343                cluster: None,
344            }],
345            backend: RoutingBackend {
346                service: "svc".into(),
347                port: 8080,
348                tls_issuer: Some("letsencrypt-prod".into()),
349                ingress_annotations: annotations,
350            },
351            stable_name_claim: false,
352            priority: 0,
353        };
354        let yaml = serde_yaml::to_string(&r).unwrap();
355        assert!(yaml.contains("tlsIssuer: letsencrypt-prod"));
356        assert!(yaml.contains("rate-limit"));
357        let back: RoutingSpec = serde_yaml::from_str(&yaml).unwrap();
358        assert_eq!(
359            back.backend.tls_issuer.as_deref(),
360            Some("letsencrypt-prod")
361        );
362        assert_eq!(back.backend.ingress_annotations.len(), 2);
363    }
364}