Skip to main content

yeti_types/
deployment.rs

1//! Canonical deployment address derivation.
2//!
3//! A deployment's *address* — the `deployment_hash`, 16 lowercase hex chars —
4//! is the stable identity that drives the URL/subdomain, filesystem paths, and
5//! certs. It must NOT move when the deployment's app set, plugin versions, or
6//! active revision change, so the derivation is app-INDEPENDENT: it hashes only
7//! `(customer, display_name, created)`. `display_name` + `created` distinguish a
8//! customer's separate deployments (prod vs staging, two named instances); none
9//! of the inputs is an app id or a revision, so apps/revisions churn freely
10//! without moving the address. Re-deriving for the same triple always yields the
11//! same address (idempotent); `created` is stamped once at deployment creation
12//! and stored, so it is stable for the life of the deployment.
13//!
14//! Format is 16 lowercase hex (the convention `yeti-config.yaml`'s
15//! canonical-form check and the running fabric already use).
16//!
17//! Replication peer-matching compares the attestation *value* a peer presents,
18//! not the derivation, so narrowing the inputs here is safe — every peer of one
19//! deployment carries the same stamped `(display_name, created)` and so the same
20//! address.
21
22use sha2::{Digest, Sha256};
23
24/// Length of a canonical address: the first 16 chars of a sha256 hex digest.
25const ADDRESS_LEN: usize = 16;
26
27/// Derive the stable, app-independent deployment address.
28///
29/// Returns `sha256("{customer}|{display_name}|{created}")` rendered as lowercase
30/// hex and truncated to its first 16 chars. No input is an app id or revision —
31/// the address is invariant under app-set and revision churn — and the function
32/// is idempotent: the same `(customer, display_name, created)` always maps to the
33/// same address. `created` is the deployment's creation stamp (pinned once at
34/// create), which lets a customer hold several distinct deployments.
35#[must_use]
36pub fn deployment_address(customer: &str, display_name: &str, created: &str) -> String {
37    let mut hasher = Sha256::new();
38    hasher.update(format!("{customer}|{display_name}|{created}").as_bytes());
39    let digest = hasher.finalize();
40    let mut hex = hex::encode(digest);
41    hex.truncate(ADDRESS_LEN);
42    hex
43}
44
45/// True iff `s` is a canonical address: exactly 16 chars, each an ASCII
46/// lowercase hex digit (`0-9a-f`).
47#[must_use]
48pub fn is_canonical_address(s: &str) -> bool {
49    s.len() == ADDRESS_LEN
50        && s.bytes()
51            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
52}
53
54/// The `DeploymentNodeState` row id for `(deployment, node)`. Shared by every
55/// writer/reader of that table (staging reactor, serve reactor, rollout seams)
56/// so the key format can never drift between them.
57///
58/// The separator is `--`, NOT `:` — the storage layer reserves `:` as its
59/// index/data separator, and a primary key containing it is silently filtered
60/// out of every table scan (rows become invisible to REST/FIQL/MCP listing).
61#[must_use]
62pub fn node_state_key(deployment_hash: &str, node_id: &str) -> String {
63    format!("{deployment_hash}--{node_id}")
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn address_is_idempotent() {
72        let a = deployment_address("acme", "orders", "1700000000");
73        let b = deployment_address("acme", "orders", "1700000000");
74        assert_eq!(a, b);
75    }
76
77    #[test]
78    fn address_is_app_independent() {
79        // The inputs are (customer, display_name, created) — no app id, no
80        // revision. This is the regression guard that the app-set never sneaks
81        // back into the derivation: the address for one deployment is fixed by
82        // its identity triple, with nothing app-shaped to vary.
83        let first = deployment_address("acme", "orders", "1700000000");
84        let second = deployment_address("acme", "orders", "1700000000");
85        assert_eq!(first, second);
86    }
87
88    #[test]
89    fn distinct_inputs_give_distinct_addresses() {
90        let base = deployment_address("acme", "orders", "1700000000");
91        // Different customer.
92        assert_ne!(base, deployment_address("globex", "orders", "1700000000"));
93        // Different display name — a customer's separate deployments.
94        assert_ne!(base, deployment_address("acme", "billing", "1700000000"));
95        // Different created stamp — two deployments, same name, made apart.
96        assert_ne!(base, deployment_address("acme", "orders", "1800000000"));
97    }
98
99    #[test]
100    fn derived_address_is_canonical() {
101        let addr = deployment_address("acme", "orders", "1700000000");
102        assert!(is_canonical_address(&addr));
103        assert_eq!(addr.len(), ADDRESS_LEN);
104    }
105
106    #[test]
107    fn node_state_key_is_scan_visible() {
108        // ':' is the storage index/data separator — a primary key containing
109        // it is filtered out of every scan. Guard the separator choice.
110        let key = node_state_key("deadbeefdeadbeef", "linode-us-east-1-abc-1");
111        assert!(!key.contains(':'));
112        assert_eq!(key, "deadbeefdeadbeef--linode-us-east-1-abc-1");
113    }
114
115    #[test]
116    fn is_canonical_rejects_malformed() {
117        // Too short / too long.
118        assert!(!is_canonical_address(""));
119        assert!(!is_canonical_address("deadbeef"));
120        assert!(!is_canonical_address("deadbeefdeadbeef0"));
121        // Uppercase is not canonical (lowercase hex only).
122        assert!(!is_canonical_address("DEADBEEFDEADBEEF"));
123        // Non-hex character at the right length.
124        assert!(!is_canonical_address("deadbeefdeadbeeg"));
125        // A valid lowercase-hex 16-char string is accepted.
126        assert!(is_canonical_address("0123456789abcdef"));
127    }
128}