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/// Job catalog (v0.15) — operator-registered Manifests, keyed by
15/// `manifest.id`. Schedules and ad-hoc `kanade run --job-id ...` look
16/// jobs up here; the wire never round-trips an inline Manifest body
17/// through a Schedule again. Editing a job in-place retroactively
18/// changes what future schedule fires deploy.
19pub const BUCKET_JOBS: &str = "jobs";
20
21/// Parallel "operator source-of-truth YAML" stores keyed identically
22/// to `BUCKET_JOBS` / `BUCKET_SCHEDULES`. The agent / scheduler /
23/// projector all keep reading the JSON KVs above — these buckets
24/// exist only so the SPA's YAML editor can round-trip operator
25/// comments + script indentation + block-scalar style exactly.
26///
27/// Population is opportunistic: any `POST` with a
28/// `Content-Type: application/yaml` body stores the raw bytes here
29/// alongside the parsed JSON; JSON-content-type POSTs fall back to a
30/// `serde_yaml` dump so the buckets stay in lockstep with the JSON
31/// store (operator just loses comments on that path).
32pub const BUCKET_JOBS_YAML: &str = "jobs_yaml";
33pub const BUCKET_SCHEDULES_YAML: &str = "schedules_yaml";
34
35/// Object Store bucket holding raw agent binaries (one object per
36/// version, e.g. `0.2.0` → file bytes).
37pub const OBJECT_AGENT_RELEASES: &str = "agent_releases";
38
39/// Key inside [`BUCKET_AGENT_CONFIG`] carrying the broadcast target
40/// version. Agents watch this key and self-update when their running
41/// version drifts.
42pub const KEY_AGENT_TARGET_VERSION: &str = "target_version";
43
44/// Sprint 6 layered-config keys inside [`BUCKET_AGENT_CONFIG`]:
45///   * `global`        — fleet-wide default ConfigScope JSON
46///   * `groups.<name>` — per-group override (partial ConfigScope)
47///   * `pcs.<pc_id>`   — per-pc override (partial ConfigScope)
48///
49/// The `groups.` / `pcs.` prefixes let a `kv.keys()` walk pick out
50/// just the rows in one scope when listing.
51pub const KEY_AGENT_CONFIG_GLOBAL: &str = "global";
52pub const PREFIX_AGENT_CONFIG_GROUPS: &str = "groups.";
53pub const PREFIX_AGENT_CONFIG_PCS: &str = "pcs.";
54
55pub fn agent_config_group_key(group: &str) -> String {
56    format!("{PREFIX_AGENT_CONFIG_GROUPS}{group}")
57}
58
59pub fn agent_config_pc_key(pc_id: &str) -> String {
60    format!("{PREFIX_AGENT_CONFIG_PCS}{pc_id}")
61}
62
63/// Inverse of [`agent_config_group_key`] — returns the bare group
64/// name if `key` carries the groups-scope prefix, else `None`.
65pub fn parse_agent_config_group_key(key: &str) -> Option<&str> {
66    key.strip_prefix(PREFIX_AGENT_CONFIG_GROUPS)
67}
68
69/// Inverse of [`agent_config_pc_key`].
70pub fn parse_agent_config_pc_key(key: &str) -> Option<&str> {
71    key.strip_prefix(PREFIX_AGENT_CONFIG_PCS)
72}
73
74pub const SCRIPT_STATUS_ACTIVE: &str = "ACTIVE";
75pub const SCRIPT_STATUS_REVOKED: &str = "REVOKED";
76
77pub const STREAM_INVENTORY: &str = "INVENTORY";
78pub const STREAM_RESULTS: &str = "RESULTS";
79pub const STREAM_EXEC: &str = "EXEC";
80pub const STREAM_EVENTS: &str = "EVENTS";
81pub const STREAM_AUDIT: &str = "AUDIT";
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    /// NATS KV bucket names must be domain-safe ASCII (a-z, A-Z, 0-9, _, -).
88    /// Lock the constants down so a future edit doesn't introduce a `.` and
89    /// break create_key_value silently on the broker side.
90    #[test]
91    fn bucket_names_are_domain_safe() {
92        for name in [
93            BUCKET_SCRIPT_CURRENT,
94            BUCKET_SCRIPT_STATUS,
95            BUCKET_AGENTS_STATE,
96            BUCKET_AGENT_CONFIG,
97            BUCKET_AGENT_GROUPS,
98            BUCKET_SCHEDULES,
99            BUCKET_JOBS,
100            BUCKET_JOBS_YAML,
101            BUCKET_SCHEDULES_YAML,
102            OBJECT_AGENT_RELEASES,
103        ] {
104            assert!(
105                !name.contains('.'),
106                "bucket name {name:?} contains a dot, which NATS KV rejects"
107            );
108            assert!(
109                name.chars()
110                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
111                "bucket name {name:?} has non-domain-safe characters"
112            );
113        }
114    }
115
116    #[test]
117    fn stream_names_are_unique() {
118        let names = [
119            STREAM_INVENTORY,
120            STREAM_RESULTS,
121            STREAM_EXEC,
122            STREAM_EVENTS,
123            STREAM_AUDIT,
124        ];
125        let mut deduped = names.to_vec();
126        deduped.sort_unstable();
127        deduped.dedup();
128        assert_eq!(
129            deduped.len(),
130            names.len(),
131            "stream constants collide: {names:?}"
132        );
133    }
134
135    #[test]
136    fn script_status_strings() {
137        assert_eq!(SCRIPT_STATUS_ACTIVE, "ACTIVE");
138        assert_eq!(SCRIPT_STATUS_REVOKED, "REVOKED");
139        assert_ne!(SCRIPT_STATUS_ACTIVE, SCRIPT_STATUS_REVOKED);
140    }
141
142    #[test]
143    fn key_agent_target_version_constant() {
144        assert_eq!(KEY_AGENT_TARGET_VERSION, "target_version");
145    }
146
147    #[test]
148    fn agent_config_group_key_round_trips() {
149        let k = agent_config_group_key("canary");
150        assert_eq!(k, "groups.canary");
151        assert_eq!(parse_agent_config_group_key(&k), Some("canary"));
152    }
153
154    #[test]
155    fn agent_config_pc_key_round_trips() {
156        let k = agent_config_pc_key("MINIPC-01");
157        assert_eq!(k, "pcs.MINIPC-01");
158        assert_eq!(parse_agent_config_pc_key(&k), Some("MINIPC-01"));
159    }
160
161    #[test]
162    fn agent_config_scope_keys_do_not_collide() {
163        // Belt + braces: make sure no pc id starting with "groups." would
164        // be misparsed (or vice versa). The prefixes are distinct because
165        // they each end in `.` and the parent buckets disagree on what
166        // comes after — pcs holds host names, groups holds membership
167        // names — but locking the invariant in a test stops a future
168        // rename from breaking it.
169        assert_ne!(PREFIX_AGENT_CONFIG_GROUPS, PREFIX_AGENT_CONFIG_PCS);
170        assert!(parse_agent_config_group_key("pcs.someone").is_none());
171        assert!(parse_agent_config_pc_key("groups.someone").is_none());
172        assert_eq!(parse_agent_config_group_key("global"), None);
173        assert_eq!(parse_agent_config_pc_key("global"), None);
174    }
175}