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}