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}