Skip to main content

tatara_process/
hostname.rs

1//! Hostname helpers — typed FQDN formatting matching `nix/lib/fleet-
2//! domains.nix`'s `mkHostname` pattern.
3//!
4//! The substrate move: every FQDN this codebase emits is computed
5//! here. Two functions ([`fmt_fqdn`] for the per-instance form +
6//! [`fmt_fqdn_stable`] for the unprefixed stable-claim form) and one
7//! deterministic ephemeral-id derivation ([`ephemeral_id_from_spec`])
8//! are the single source of truth — no string `format!()` of DNS
9//! syntax anywhere else in the tree.
10//!
11//! Forms:
12//!
13//! ```text
14//!   Per-instance: ${app}.${ephemeral_id}.${cluster}.${location}.${domain}
15//!   Stable:       ${app}.${cluster}.${location}.${domain}
16//! ```
17//!
18//! Where `${ephemeral_id}` is:
19//!
20//! * `RoutingHostname.instance` when set — a named slot like
21//!   `demo-prod` or `pr-1234`.
22//! * `EPHEMERAL_ID_HASH_LEN` (= 8) hex chars of
23//!   `BLAKE3(canonical_spec_json)` when unset — a content-hash slot
24//!   that changes only when the Process's spec changes.
25//!
26//! All four FQDN segments are validated as RFC 1123 DNS labels at
27//! the boundary — lowercase alphanumeric + hyphen, 1–63 chars, no
28//! leading/trailing hyphen. Validation errors surface as typed
29//! [`HostnameError`] variants so callers can render targeted
30//! operator messages.
31
32use serde::Serialize;
33
34use crate::routing::RoutingHostname;
35
36/// Number of hex chars from BLAKE3 to use as the content-hash form
37/// of `ephemeral_id`. 8 = 32 bits of entropy; collision probability
38/// at 1k concurrent Processes ≈ 1 in 8.5 million. Comfortable for
39/// any single cluster's working set, room to grow.
40pub const EPHEMERAL_ID_HASH_LEN: usize = 8;
41
42/// Reserved 2-part forms forbidden as `app` values (saguão control
43/// plane — see pleme-io CLAUDE.md §Fleet hostname pattern).
44const RESERVED_APP_LABELS: &[&str] = &["auth", "cracha"];
45
46/// Why a hostname can't be formatted. Typed so callers can branch.
47#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
48pub enum HostnameError {
49    #[error("invalid DNS label {label:?} for segment {segment}: {reason}")]
50    InvalidLabel {
51        segment: &'static str,
52        label: String,
53        reason: &'static str,
54    },
55    #[error("app label {0:?} is reserved for the saguão control plane")]
56    ReservedApp(String),
57}
58
59/// Format the per-instance FQDN.
60///
61/// ```
62/// use tatara_process::hostname::fmt_fqdn;
63/// let fqdn = fmt_fqdn("api", "demo-prod", "pleme-dev", "use1", "quero.lol").unwrap();
64/// assert_eq!(fqdn, "api.demo-prod.pleme-dev.use1.quero.lol");
65/// ```
66pub fn fmt_fqdn(
67    app: &str,
68    ephemeral_id: &str,
69    cluster: &str,
70    location: &str,
71    domain: &str,
72) -> Result<String, HostnameError> {
73    validate_label("app", app)?;
74    if RESERVED_APP_LABELS.contains(&app) {
75        return Err(HostnameError::ReservedApp(app.to_string()));
76    }
77    validate_label("ephemeral_id", ephemeral_id)?;
78    validate_label("cluster", cluster)?;
79    validate_label("location", location)?;
80    validate_domain("domain", domain)?;
81    Ok(format!(
82        "{app}.{ephemeral_id}.{cluster}.{location}.{domain}"
83    ))
84}
85
86/// Format the stable-claim FQDN (no `ephemeral_id` segment).
87///
88/// ```
89/// use tatara_process::hostname::fmt_fqdn_stable;
90/// let fqdn = fmt_fqdn_stable("api", "pleme-dev", "use1", "quero.lol").unwrap();
91/// assert_eq!(fqdn, "api.pleme-dev.use1.quero.lol");
92/// ```
93pub fn fmt_fqdn_stable(
94    app: &str,
95    cluster: &str,
96    location: &str,
97    domain: &str,
98) -> Result<String, HostnameError> {
99    validate_label("app", app)?;
100    if RESERVED_APP_LABELS.contains(&app) {
101        return Err(HostnameError::ReservedApp(app.to_string()));
102    }
103    validate_label("cluster", cluster)?;
104    validate_label("location", location)?;
105    validate_domain("domain", domain)?;
106    Ok(format!("{app}.{cluster}.{location}.{domain}"))
107}
108
109/// Compute the content-hash form of `ephemeral_id` for a given
110/// `ProcessSpec`. Stable across reconciles of the same spec; new
111/// spec content ⇒ new hash ⇒ new DNS slot.
112///
113/// Uses [`EPHEMERAL_ID_HASH_LEN`] hex chars of BLAKE3 over the
114/// canonical JSON of the spec.
115pub fn ephemeral_id_from_spec<T: Serialize>(spec: &T) -> Result<String, HostnameError> {
116    let bytes = canonical_json(spec).map_err(|_| HostnameError::InvalidLabel {
117        segment: "spec",
118        label: "<unserializable>".into(),
119        reason: "spec failed to canonicalize",
120    })?;
121    Ok(short_hex_blake3(&bytes, EPHEMERAL_ID_HASH_LEN))
122}
123
124/// Resolve the `ephemeral_id` for a single [`RoutingHostname`]
125/// entry. Named slot wins if set; otherwise the content-hash form
126/// is computed from the surrounding `ProcessSpec` (caller passes
127/// in via `fallback_hash`).
128///
129/// The split-arg design keeps this pure — the spec hash is computed
130/// once by the caller (via [`ephemeral_id_from_spec`]) and reused
131/// across every hostname on the same Process.
132pub fn resolve_ephemeral_id<'a>(
133    hostname: &'a RoutingHostname,
134    fallback_hash: &'a str,
135) -> &'a str {
136    match &hostname.instance {
137        Some(s) if !s.is_empty() => s.as_str(),
138        _ => fallback_hash,
139    }
140}
141
142// ─── Validation ────────────────────────────────────────────────────
143
144fn validate_label(segment: &'static str, label: &str) -> Result<(), HostnameError> {
145    if label.is_empty() || label.len() > 63 {
146        return Err(HostnameError::InvalidLabel {
147            segment,
148            label: label.to_string(),
149            reason: "must be 1–63 characters",
150        });
151    }
152    if label.starts_with('-') || label.ends_with('-') {
153        return Err(HostnameError::InvalidLabel {
154            segment,
155            label: label.to_string(),
156            reason: "must not start or end with a hyphen",
157        });
158    }
159    if !label
160        .chars()
161        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
162    {
163        return Err(HostnameError::InvalidLabel {
164            segment,
165            label: label.to_string(),
166            reason: "must contain only [a-z0-9-]",
167        });
168    }
169    Ok(())
170}
171
172fn validate_domain(segment: &'static str, domain: &str) -> Result<(), HostnameError> {
173    if domain.is_empty() {
174        return Err(HostnameError::InvalidLabel {
175            segment,
176            label: domain.to_string(),
177            reason: "must not be empty",
178        });
179    }
180    // Multi-label domain — every dot-separated piece must be a valid label.
181    for piece in domain.split('.') {
182        validate_label(segment, piece)?;
183    }
184    Ok(())
185}
186
187fn canonical_json<T: Serialize>(value: &T) -> Result<Vec<u8>, serde_json::Error> {
188    // Canonical = serde_json round-trip through Value (preserves
189    // declaration-order keys). Matches the receipt + worker pattern.
190    let v = serde_json::to_value(value)?;
191    serde_json::to_vec(&v)
192}
193
194fn short_hex_blake3(bytes: &[u8], len: usize) -> String {
195    // Delegate the 2-link `blake3::hash → hex` step to the substrate
196    // primitive so the ephemeral-id prefix stays byte-identical to
197    // every receipt/attestation hex-digest workspace-wide; take a
198    // stable prefix of the shared full-length hex.
199    crate::hash::hex_blake3(bytes).chars().take(len).collect()
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use serde::Deserialize;
206
207    #[test]
208    fn fmt_fqdn_per_instance() {
209        let f = fmt_fqdn("api", "demo-prod", "pleme-dev", "use1", "quero.lol").unwrap();
210        assert_eq!(f, "api.demo-prod.pleme-dev.use1.quero.lol");
211    }
212
213    #[test]
214    fn fmt_fqdn_stable_form() {
215        let f = fmt_fqdn_stable("api", "pleme-dev", "use1", "quero.lol").unwrap();
216        assert_eq!(f, "api.pleme-dev.use1.quero.lol");
217    }
218
219    #[test]
220    fn fmt_fqdn_with_multilevel_domain() {
221        let f = fmt_fqdn("api", "env-a", "rio", "us", "internal.example.com").unwrap();
222        assert_eq!(f, "api.env-a.rio.us.internal.example.com");
223    }
224
225    #[test]
226    fn reserved_app_rejected() {
227        let r = fmt_fqdn("auth", "x", "y", "z", "example.com");
228        assert!(matches!(r, Err(HostnameError::ReservedApp(_))));
229        let r = fmt_fqdn_stable("cracha", "y", "z", "example.com");
230        assert!(matches!(r, Err(HostnameError::ReservedApp(_))));
231    }
232
233    #[test]
234    fn empty_label_rejected() {
235        let r = fmt_fqdn("", "x", "y", "z", "example.com");
236        assert!(matches!(r, Err(HostnameError::InvalidLabel { segment: "app", .. })));
237    }
238
239    #[test]
240    fn too_long_label_rejected() {
241        let long = "a".repeat(64);
242        let r = fmt_fqdn(&long, "x", "y", "z", "example.com");
243        assert!(matches!(r, Err(HostnameError::InvalidLabel { .. })));
244    }
245
246    #[test]
247    fn uppercase_label_rejected() {
248        let r = fmt_fqdn("API", "x", "y", "z", "example.com");
249        assert!(matches!(r, Err(HostnameError::InvalidLabel { .. })));
250    }
251
252    #[test]
253    fn leading_hyphen_label_rejected() {
254        let r = fmt_fqdn("api", "-bad", "y", "z", "example.com");
255        assert!(matches!(r, Err(HostnameError::InvalidLabel { .. })));
256    }
257
258    #[test]
259    fn underscore_label_rejected() {
260        let r = fmt_fqdn("api", "x_y", "z", "w", "example.com");
261        assert!(matches!(r, Err(HostnameError::InvalidLabel { .. })));
262    }
263
264    #[test]
265    fn empty_domain_rejected() {
266        let r = fmt_fqdn("api", "x", "y", "z", "");
267        assert!(matches!(r, Err(HostnameError::InvalidLabel { .. })));
268    }
269
270    // ─── Content-hash derivation ─────────────────────────────────
271
272    #[derive(Serialize, Deserialize)]
273    struct TestSpec {
274        a: u32,
275        b: String,
276    }
277
278    #[test]
279    fn ephemeral_id_is_8_hex_chars() {
280        let spec = TestSpec { a: 1, b: "x".into() };
281        let id = ephemeral_id_from_spec(&spec).unwrap();
282        assert_eq!(id.len(), EPHEMERAL_ID_HASH_LEN);
283        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
284    }
285
286    #[test]
287    fn ephemeral_id_is_deterministic() {
288        let s1 = TestSpec { a: 1, b: "x".into() };
289        let s2 = TestSpec { a: 1, b: "x".into() };
290        assert_eq!(
291            ephemeral_id_from_spec(&s1).unwrap(),
292            ephemeral_id_from_spec(&s2).unwrap()
293        );
294    }
295
296    #[test]
297    fn ephemeral_id_changes_with_spec() {
298        let s1 = TestSpec { a: 1, b: "x".into() };
299        let s2 = TestSpec { a: 2, b: "x".into() };
300        let s3 = TestSpec { a: 1, b: "y".into() };
301        let id1 = ephemeral_id_from_spec(&s1).unwrap();
302        let id2 = ephemeral_id_from_spec(&s2).unwrap();
303        let id3 = ephemeral_id_from_spec(&s3).unwrap();
304        assert_ne!(id1, id2);
305        assert_ne!(id1, id3);
306        assert_ne!(id2, id3);
307    }
308
309    #[test]
310    fn ephemeral_id_lowercase_valid_dns_label() {
311        // BLAKE3 hex is lowercase by design; the validator must
312        // accept the output as a valid DNS label.
313        let spec = TestSpec { a: 42, b: "anything".into() };
314        let id = ephemeral_id_from_spec(&spec).unwrap();
315        validate_label("ephemeral_id", &id).unwrap();
316    }
317
318    // ─── resolve_ephemeral_id ────────────────────────────────────
319
320    #[test]
321    fn resolve_named_slot_wins() {
322        let h = RoutingHostname {
323            app: "api".into(),
324            instance: Some("demo-prod".into()),
325            cluster: None,
326        };
327        assert_eq!(resolve_ephemeral_id(&h, "fallback"), "demo-prod");
328    }
329
330    #[test]
331    fn resolve_empty_named_falls_back() {
332        let h = RoutingHostname {
333            app: "api".into(),
334            instance: Some(String::new()),
335            cluster: None,
336        };
337        assert_eq!(resolve_ephemeral_id(&h, "abc123de"), "abc123de");
338    }
339
340    #[test]
341    fn resolve_unset_named_falls_back() {
342        let h = RoutingHostname {
343            app: "api".into(),
344            instance: None,
345            cluster: None,
346        };
347        assert_eq!(resolve_ephemeral_id(&h, "abc123de"), "abc123de");
348    }
349
350    // ─── End-to-end ──────────────────────────────────────────────
351
352    #[test]
353    fn end_to_end_named_and_unnamed_for_same_process() {
354        let spec = TestSpec { a: 1, b: "x".into() };
355        let hash = ephemeral_id_from_spec(&spec).unwrap();
356
357        let h_named = RoutingHostname {
358            app: "api".into(),
359            instance: Some("demo-prod".into()),
360            cluster: None,
361        };
362        let h_anon = RoutingHostname {
363            app: "gateway".into(),
364            instance: None,
365            cluster: None,
366        };
367
368        let id_named = resolve_ephemeral_id(&h_named, &hash);
369        let id_anon = resolve_ephemeral_id(&h_anon, &hash);
370
371        let fqdn_named =
372            fmt_fqdn(&h_named.app, id_named, "pleme-dev", "use1", "quero.lol").unwrap();
373        let fqdn_anon =
374            fmt_fqdn(&h_anon.app, id_anon, "pleme-dev", "use1", "quero.lol").unwrap();
375
376        assert_eq!(fqdn_named, "api.demo-prod.pleme-dev.use1.quero.lol");
377        assert!(fqdn_anon.starts_with("gateway."));
378        assert!(fqdn_anon.ends_with(".pleme-dev.use1.quero.lol"));
379        // 5 named segments (app + eph_id + cluster + location + domain),
380        // but `domain` itself splits as `quero.lol` ⇒ 6 dot-delimited
381        // pieces. The shape, not the count, is the invariant.
382        assert_eq!(fqdn_anon.matches('.').count(), 5);
383    }
384}