Skip to main content

kanade_shared/
kv.rs

1//! NATS KV bucket name + key helpers (spec §2.3.2).
2//!
3//! NATS KV bucket names must be domain-safe ASCII (a-z, A-Z, 0-9, _, -),
4//! so the spec's dotted names (`script.current`, `script.status`) are
5//! flattened to underscore form here.
6
7pub const BUCKET_SCRIPT_CURRENT: &str = "script_current";
8pub const BUCKET_SCRIPT_STATUS: &str = "script_status";
9pub const BUCKET_AGENTS_STATE: &str = "agents_state";
10pub const BUCKET_AGENT_CONFIG: &str = "agent_config";
11pub const BUCKET_AGENT_GROUPS: &str = "agent_groups";
12pub const BUCKET_SCHEDULES: &str = "schedules";
13
14/// Object Store bucket holding raw agent binaries (one object per
15/// version, e.g. `0.2.0` → file bytes).
16pub const OBJECT_AGENT_RELEASES: &str = "agent_releases";
17
18/// Key inside [`BUCKET_AGENT_CONFIG`] carrying the broadcast target
19/// version. Agents watch this key and self-update when their running
20/// version drifts.
21pub const KEY_AGENT_TARGET_VERSION: &str = "target_version";
22
23/// Sprint 6 layered-config keys inside [`BUCKET_AGENT_CONFIG`]:
24///   * `global`        — fleet-wide default ConfigScope JSON
25///   * `groups.<name>` — per-group override (partial ConfigScope)
26///   * `pcs.<pc_id>`   — per-pc override (partial ConfigScope)
27///
28/// The `groups.` / `pcs.` prefixes let a `kv.keys()` walk pick out
29/// just the rows in one scope when listing.
30pub const KEY_AGENT_CONFIG_GLOBAL: &str = "global";
31pub const PREFIX_AGENT_CONFIG_GROUPS: &str = "groups.";
32pub const PREFIX_AGENT_CONFIG_PCS: &str = "pcs.";
33
34pub fn agent_config_group_key(group: &str) -> String {
35    format!("{PREFIX_AGENT_CONFIG_GROUPS}{group}")
36}
37
38pub fn agent_config_pc_key(pc_id: &str) -> String {
39    format!("{PREFIX_AGENT_CONFIG_PCS}{pc_id}")
40}
41
42/// Inverse of [`agent_config_group_key`] — returns the bare group
43/// name if `key` carries the groups-scope prefix, else `None`.
44pub fn parse_agent_config_group_key(key: &str) -> Option<&str> {
45    key.strip_prefix(PREFIX_AGENT_CONFIG_GROUPS)
46}
47
48/// Inverse of [`agent_config_pc_key`].
49pub fn parse_agent_config_pc_key(key: &str) -> Option<&str> {
50    key.strip_prefix(PREFIX_AGENT_CONFIG_PCS)
51}
52
53pub const SCRIPT_STATUS_ACTIVE: &str = "ACTIVE";
54pub const SCRIPT_STATUS_REVOKED: &str = "REVOKED";
55
56pub const STREAM_INVENTORY: &str = "INVENTORY";
57pub const STREAM_RESULTS: &str = "RESULTS";
58pub const STREAM_DEPLOY: &str = "DEPLOY";
59pub const STREAM_EVENTS: &str = "EVENTS";
60pub const STREAM_AUDIT: &str = "AUDIT";
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    /// NATS KV bucket names must be domain-safe ASCII (a-z, A-Z, 0-9, _, -).
67    /// Lock the constants down so a future edit doesn't introduce a `.` and
68    /// break create_key_value silently on the broker side.
69    #[test]
70    fn bucket_names_are_domain_safe() {
71        for name in [
72            BUCKET_SCRIPT_CURRENT,
73            BUCKET_SCRIPT_STATUS,
74            BUCKET_AGENTS_STATE,
75            BUCKET_AGENT_CONFIG,
76            BUCKET_AGENT_GROUPS,
77            BUCKET_SCHEDULES,
78            OBJECT_AGENT_RELEASES,
79        ] {
80            assert!(
81                !name.contains('.'),
82                "bucket name {name:?} contains a dot, which NATS KV rejects"
83            );
84            assert!(
85                name.chars()
86                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
87                "bucket name {name:?} has non-domain-safe characters"
88            );
89        }
90    }
91
92    #[test]
93    fn stream_names_are_unique() {
94        let names = [
95            STREAM_INVENTORY,
96            STREAM_RESULTS,
97            STREAM_DEPLOY,
98            STREAM_EVENTS,
99            STREAM_AUDIT,
100        ];
101        let mut deduped = names.to_vec();
102        deduped.sort_unstable();
103        deduped.dedup();
104        assert_eq!(
105            deduped.len(),
106            names.len(),
107            "stream constants collide: {names:?}"
108        );
109    }
110
111    #[test]
112    fn script_status_strings() {
113        assert_eq!(SCRIPT_STATUS_ACTIVE, "ACTIVE");
114        assert_eq!(SCRIPT_STATUS_REVOKED, "REVOKED");
115        assert_ne!(SCRIPT_STATUS_ACTIVE, SCRIPT_STATUS_REVOKED);
116    }
117
118    #[test]
119    fn key_agent_target_version_constant() {
120        assert_eq!(KEY_AGENT_TARGET_VERSION, "target_version");
121    }
122
123    #[test]
124    fn agent_config_group_key_round_trips() {
125        let k = agent_config_group_key("canary");
126        assert_eq!(k, "groups.canary");
127        assert_eq!(parse_agent_config_group_key(&k), Some("canary"));
128    }
129
130    #[test]
131    fn agent_config_pc_key_round_trips() {
132        let k = agent_config_pc_key("MINIPC-01");
133        assert_eq!(k, "pcs.MINIPC-01");
134        assert_eq!(parse_agent_config_pc_key(&k), Some("MINIPC-01"));
135    }
136
137    #[test]
138    fn agent_config_scope_keys_do_not_collide() {
139        // Belt + braces: make sure no pc id starting with "groups." would
140        // be misparsed (or vice versa). The prefixes are distinct because
141        // they each end in `.` and the parent buckets disagree on what
142        // comes after — pcs holds host names, groups holds membership
143        // names — but locking the invariant in a test stops a future
144        // rename from breaking it.
145        assert_ne!(PREFIX_AGENT_CONFIG_GROUPS, PREFIX_AGENT_CONFIG_PCS);
146        assert!(parse_agent_config_group_key("pcs.someone").is_none());
147        assert!(parse_agent_config_pc_key("groups.someone").is_none());
148        assert_eq!(parse_agent_config_group_key("global"), None);
149        assert_eq!(parse_agent_config_pc_key("global"), None);
150    }
151}