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}