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/// Object Store holding **generic application packages** — anything
40/// the agent / kitting scripts pull down + install on endpoints.
41/// First consumer is the kanade-client app, but the bucket is
42/// intentionally generic: third-party installers (Webex, Teams,
43/// custom MSI bundles), upgrade scripts, configuration archives,
44/// etc. all live here.
45///
46/// Object keys are `<name>/<version>` — operator picks `<name>`
47/// once per package family (e.g. `kanade-client`,
48/// `webex-meetings`), then `<version>` per release (e.g.
49/// `0.41.0`, `2025.03`). Slashes are explicitly allowed by NATS
50/// Object Store key rules; the SPA / CLI / HTTP routes all carry
51/// the pair as two path segments.
52///
53/// Why a separate bucket from `agent_releases`:
54/// - `agent_releases` is fleet-critical (the agent's own self-
55/// update path). Keeping it small + audited matters.
56/// - `app_packages` is operator-curated user-space content. The
57/// lifecycle is different (operators add/remove packages
58/// freely; agent releases follow the release.yml pipeline).
59pub const OBJECT_APP_PACKAGES: &str = "app_packages";
60
61/// Object Store holding **manifest script bodies** referenced by
62/// `Execute::script_object` (SPEC §2.4.1's alternative to inline
63/// `script:` / repo-local `script_file:`). Per yukimemi/kanade
64/// issue #210, this is the "Plan B 4-bucket layout" sibling of
65/// `app_packages` — separated because scripts have a different
66/// lifecycle than installer binaries:
67///
68/// - Smaller (typical KB-to-low-MB, vs MB-to-hundreds-of-MB
69/// installers).
70/// - Coupled to manifest versions (script lifecycle = manifest
71/// lifecycle; the `script_current` / `script_status` KV gates
72/// in SPEC §2.6.2 already track manifest versions, so a
73/// matching dedicated bucket keeps the audit story aligned).
74/// - Different access pattern (every Command execute potentially
75/// fetches; vs installer fetched once per fleet deploy).
76///
77/// Object keys follow the same `<name>/<version>` shape as
78/// `app_packages` so the SPA / operator tooling stays uniform.
79/// For manifest-driven scripts `<name>` is the manifest id and
80/// `<version>` is the manifest version, but the bucket itself
81/// imposes no semantics on the pair — operator-uploaded
82/// ad-hoc scripts can use any `<name>/<version>` they like.
83pub const OBJECT_SCRIPTS: &str = "scripts";
84
85/// Key inside [`BUCKET_AGENT_CONFIG`] carrying the broadcast target
86/// version. Agents watch this key and self-update when their running
87/// version drifts.
88pub const KEY_AGENT_TARGET_VERSION: &str = "target_version";
89
90/// Sprint 6 layered-config keys inside [`BUCKET_AGENT_CONFIG`]:
91/// * `global` — fleet-wide default ConfigScope JSON
92/// * `groups.<name>` — per-group override (partial ConfigScope)
93/// * `pcs.<pc_id>` — per-pc override (partial ConfigScope)
94///
95/// The `groups.` / `pcs.` prefixes let a `kv.keys()` walk pick out
96/// just the rows in one scope when listing.
97pub const KEY_AGENT_CONFIG_GLOBAL: &str = "global";
98pub const PREFIX_AGENT_CONFIG_GROUPS: &str = "groups.";
99pub const PREFIX_AGENT_CONFIG_PCS: &str = "pcs.";
100
101pub fn agent_config_group_key(group: &str) -> String {
102 format!("{PREFIX_AGENT_CONFIG_GROUPS}{group}")
103}
104
105pub fn agent_config_pc_key(pc_id: &str) -> String {
106 format!("{PREFIX_AGENT_CONFIG_PCS}{pc_id}")
107}
108
109/// Inverse of [`agent_config_group_key`] — returns the bare group
110/// name if `key` carries the groups-scope prefix, else `None`.
111pub fn parse_agent_config_group_key(key: &str) -> Option<&str> {
112 key.strip_prefix(PREFIX_AGENT_CONFIG_GROUPS)
113}
114
115/// Inverse of [`agent_config_pc_key`].
116pub fn parse_agent_config_pc_key(key: &str) -> Option<&str> {
117 key.strip_prefix(PREFIX_AGENT_CONFIG_PCS)
118}
119
120pub const SCRIPT_STATUS_ACTIVE: &str = "ACTIVE";
121pub const SCRIPT_STATUS_REVOKED: &str = "REVOKED";
122
123pub const STREAM_INVENTORY: &str = "INVENTORY";
124pub const STREAM_RESULTS: &str = "RESULTS";
125pub const STREAM_EXEC: &str = "EXEC";
126pub const STREAM_EVENTS: &str = "EVENTS";
127pub const STREAM_AUDIT: &str = "AUDIT";
128
129/// JetStream stream backing the per-PC observability event pipeline
130/// (Issue #246). Distinct from [`STREAM_EVENTS`] (in-flight script
131/// lifecycle) — `STREAM_OBS_EVENTS` carries the timeline data the
132/// SPA's Events page consumes: sign-in/out, power on/off, sleep/
133/// resume, agent milestones, diagnostic bundle pointers. The agent
134/// publishes on `obs.<pc_id>` (see [`crate::subject::obs`]) and
135/// this stream catches everything matching [`crate::subject::OBS_FILTER`]
136/// so a backend that boots after the agent doesn't miss any
137/// already-emitted events.
138pub const STREAM_OBS_EVENTS: &str = "OBS_EVENTS";
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 /// NATS KV bucket names must be domain-safe ASCII (a-z, A-Z, 0-9, _, -).
145 /// Lock the constants down so a future edit doesn't introduce a `.` and
146 /// break create_key_value silently on the broker side.
147 #[test]
148 fn bucket_names_are_domain_safe() {
149 for name in [
150 BUCKET_SCRIPT_CURRENT,
151 BUCKET_SCRIPT_STATUS,
152 BUCKET_AGENTS_STATE,
153 BUCKET_AGENT_CONFIG,
154 BUCKET_AGENT_GROUPS,
155 BUCKET_SCHEDULES,
156 BUCKET_JOBS,
157 BUCKET_JOBS_YAML,
158 BUCKET_SCHEDULES_YAML,
159 OBJECT_AGENT_RELEASES,
160 OBJECT_APP_PACKAGES,
161 OBJECT_SCRIPTS,
162 ] {
163 assert!(
164 !name.contains('.'),
165 "bucket name {name:?} contains a dot, which NATS KV rejects"
166 );
167 assert!(
168 name.chars()
169 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
170 "bucket name {name:?} has non-domain-safe characters"
171 );
172 }
173 }
174
175 #[test]
176 fn stream_names_are_unique() {
177 let names = [
178 STREAM_INVENTORY,
179 STREAM_RESULTS,
180 STREAM_EXEC,
181 STREAM_EVENTS,
182 STREAM_AUDIT,
183 STREAM_OBS_EVENTS,
184 ];
185 let mut deduped = names.to_vec();
186 deduped.sort_unstable();
187 deduped.dedup();
188 assert_eq!(
189 deduped.len(),
190 names.len(),
191 "stream constants collide: {names:?}"
192 );
193 }
194
195 #[test]
196 fn script_status_strings() {
197 assert_eq!(SCRIPT_STATUS_ACTIVE, "ACTIVE");
198 assert_eq!(SCRIPT_STATUS_REVOKED, "REVOKED");
199 assert_ne!(SCRIPT_STATUS_ACTIVE, SCRIPT_STATUS_REVOKED);
200 }
201
202 #[test]
203 fn key_agent_target_version_constant() {
204 assert_eq!(KEY_AGENT_TARGET_VERSION, "target_version");
205 }
206
207 #[test]
208 fn agent_config_group_key_round_trips() {
209 let k = agent_config_group_key("canary");
210 assert_eq!(k, "groups.canary");
211 assert_eq!(parse_agent_config_group_key(&k), Some("canary"));
212 }
213
214 #[test]
215 fn agent_config_pc_key_round_trips() {
216 let k = agent_config_pc_key("MINIPC-01");
217 assert_eq!(k, "pcs.MINIPC-01");
218 assert_eq!(parse_agent_config_pc_key(&k), Some("MINIPC-01"));
219 }
220
221 #[test]
222 fn agent_config_scope_keys_do_not_collide() {
223 // Belt + braces: make sure no pc id starting with "groups." would
224 // be misparsed (or vice versa). The prefixes are distinct because
225 // they each end in `.` and the parent buckets disagree on what
226 // comes after — pcs holds host names, groups holds membership
227 // names — but locking the invariant in a test stops a future
228 // rename from breaking it.
229 assert_ne!(PREFIX_AGENT_CONFIG_GROUPS, PREFIX_AGENT_CONFIG_PCS);
230 assert!(parse_agent_config_group_key("pcs.someone").is_none());
231 assert!(parse_agent_config_pc_key("groups.someone").is_none());
232 assert_eq!(parse_agent_config_group_key("global"), None);
233 assert_eq!(parse_agent_config_pc_key("global"), None);
234 }
235}