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/// Object Store holding **overflow stdout / stderr blobs** for the
86/// `ExecResult` wire kind (#227). The default NATS `max_payload` is
87/// 1 MB; a result whose stdout / stderr exceeds it would reject the
88/// publish and pin the agent's outbox in a reconnect loop. The agent
89/// uploads any stdout / stderr larger than `STDOUT_INLINE_THRESHOLD`
90/// (256 KB, picked at 1/4 of the default max_payload so the rest of
91/// the ExecResult fields fit alongside) into this bucket and replaces
92/// the inline field with [`crate::wire::ExecResult::stdout_object`] /
93/// `stderr_object` pointers. Backend's results projector derefs the
94/// pointers before INSERT so downstream consumers (SQLite, SPA
95/// Activity, inventory projector) see the full text the same way
96/// they always have.
97///
98/// Object keys follow the shape `<request_id>/{stdout,stderr}` so
99/// stdout + stderr for the same execution share a sibling prefix —
100/// makes `kanade jetstream` listings group naturally and keeps the
101/// per-key namespace tight against duplicate uploads.
102///
103/// Per-bucket retention (not a stream-wide TTL since async-nats
104/// object_store inherits stream config): matches `STREAM_RESULTS`'s
105/// 30-day retention so an operator who can still query the result
106/// row in SQLite can also fetch the original blob if the inline
107/// copy ever needs re-projection.
108pub const OBJECT_RESULT_OUTPUT: &str = "result_output";
109
110/// Inline threshold for `ExecResult.stdout` / `.stderr`. Larger
111/// payloads overflow into [`OBJECT_RESULT_OUTPUT`]. 256 KB = 1/4 of
112/// the NATS default `max_payload` (1 MB) so the rest of the
113/// ExecResult JSON (request_id, exec_id, etc.) easily fits below the
114/// publish-reject ceiling.
115///
116/// Lives next to the bucket constant rather than on the agent side
117/// so the SPA / future operator tooling can quote the same threshold
118/// when explaining "why this result has no inline stdout".
119pub const STDOUT_INLINE_THRESHOLD: usize = 256 * 1024;
120
121/// Key inside [`BUCKET_AGENT_CONFIG`] carrying the broadcast target
122/// version. Agents watch this key and self-update when their running
123/// version drifts.
124pub const KEY_AGENT_TARGET_VERSION: &str = "target_version";
125
126/// Sprint 6 layered-config keys inside [`BUCKET_AGENT_CONFIG`]:
127/// * `global` — fleet-wide default ConfigScope JSON
128/// * `groups.<name>` — per-group override (partial ConfigScope)
129/// * `pcs.<pc_id>` — per-pc override (partial ConfigScope)
130///
131/// The `groups.` / `pcs.` prefixes let a `kv.keys()` walk pick out
132/// just the rows in one scope when listing.
133pub const KEY_AGENT_CONFIG_GLOBAL: &str = "global";
134pub const PREFIX_AGENT_CONFIG_GROUPS: &str = "groups.";
135pub const PREFIX_AGENT_CONFIG_PCS: &str = "pcs.";
136
137pub fn agent_config_group_key(group: &str) -> String {
138 format!("{PREFIX_AGENT_CONFIG_GROUPS}{group}")
139}
140
141pub fn agent_config_pc_key(pc_id: &str) -> String {
142 format!("{PREFIX_AGENT_CONFIG_PCS}{pc_id}")
143}
144
145/// Inverse of [`agent_config_group_key`] — returns the bare group
146/// name if `key` carries the groups-scope prefix, else `None`.
147pub fn parse_agent_config_group_key(key: &str) -> Option<&str> {
148 key.strip_prefix(PREFIX_AGENT_CONFIG_GROUPS)
149}
150
151/// Inverse of [`agent_config_pc_key`].
152pub fn parse_agent_config_pc_key(key: &str) -> Option<&str> {
153 key.strip_prefix(PREFIX_AGENT_CONFIG_PCS)
154}
155
156pub const SCRIPT_STATUS_ACTIVE: &str = "ACTIVE";
157pub const SCRIPT_STATUS_REVOKED: &str = "REVOKED";
158
159pub const STREAM_INVENTORY: &str = "INVENTORY";
160pub const STREAM_RESULTS: &str = "RESULTS";
161pub const STREAM_EXEC: &str = "EXEC";
162pub const STREAM_EVENTS: &str = "EVENTS";
163pub const STREAM_AUDIT: &str = "AUDIT";
164
165/// JetStream stream backing the per-PC observability event pipeline
166/// (Issue #246). Distinct from [`STREAM_EVENTS`] (in-flight script
167/// lifecycle) — `STREAM_OBS_EVENTS` carries the timeline data the
168/// SPA's Events page consumes: sign-in/out, power on/off, sleep/
169/// resume, agent milestones, diagnostic bundle pointers. The agent
170/// publishes on `obs.<pc_id>` (see [`crate::subject::obs`]) and
171/// this stream catches everything matching [`crate::subject::OBS_FILTER`]
172/// so a backend that boots after the agent doesn't miss any
173/// already-emitted events.
174pub const STREAM_OBS_EVENTS: &str = "OBS_EVENTS";
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 /// NATS KV bucket names must be domain-safe ASCII (a-z, A-Z, 0-9, _, -).
181 /// Lock the constants down so a future edit doesn't introduce a `.` and
182 /// break create_key_value silently on the broker side.
183 #[test]
184 fn bucket_names_are_domain_safe() {
185 for name in [
186 BUCKET_SCRIPT_CURRENT,
187 BUCKET_SCRIPT_STATUS,
188 BUCKET_AGENTS_STATE,
189 BUCKET_AGENT_CONFIG,
190 BUCKET_AGENT_GROUPS,
191 BUCKET_SCHEDULES,
192 BUCKET_JOBS,
193 BUCKET_JOBS_YAML,
194 BUCKET_SCHEDULES_YAML,
195 OBJECT_AGENT_RELEASES,
196 OBJECT_APP_PACKAGES,
197 OBJECT_SCRIPTS,
198 OBJECT_RESULT_OUTPUT,
199 ] {
200 assert!(
201 !name.contains('.'),
202 "bucket name {name:?} contains a dot, which NATS KV rejects"
203 );
204 assert!(
205 name.chars()
206 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
207 "bucket name {name:?} has non-domain-safe characters"
208 );
209 }
210 }
211
212 #[test]
213 fn stream_names_are_unique() {
214 let names = [
215 STREAM_INVENTORY,
216 STREAM_RESULTS,
217 STREAM_EXEC,
218 STREAM_EVENTS,
219 STREAM_AUDIT,
220 STREAM_OBS_EVENTS,
221 ];
222 let mut deduped = names.to_vec();
223 deduped.sort_unstable();
224 deduped.dedup();
225 assert_eq!(
226 deduped.len(),
227 names.len(),
228 "stream constants collide: {names:?}"
229 );
230 }
231
232 #[test]
233 fn script_status_strings() {
234 assert_eq!(SCRIPT_STATUS_ACTIVE, "ACTIVE");
235 assert_eq!(SCRIPT_STATUS_REVOKED, "REVOKED");
236 assert_ne!(SCRIPT_STATUS_ACTIVE, SCRIPT_STATUS_REVOKED);
237 }
238
239 #[test]
240 fn key_agent_target_version_constant() {
241 assert_eq!(KEY_AGENT_TARGET_VERSION, "target_version");
242 }
243
244 #[test]
245 fn agent_config_group_key_round_trips() {
246 let k = agent_config_group_key("canary");
247 assert_eq!(k, "groups.canary");
248 assert_eq!(parse_agent_config_group_key(&k), Some("canary"));
249 }
250
251 #[test]
252 fn agent_config_pc_key_round_trips() {
253 let k = agent_config_pc_key("PC-01");
254 assert_eq!(k, "pcs.PC-01");
255 assert_eq!(parse_agent_config_pc_key(&k), Some("PC-01"));
256 }
257
258 #[test]
259 fn agent_config_scope_keys_do_not_collide() {
260 // Belt + braces: make sure no pc id starting with "groups." would
261 // be misparsed (or vice versa). The prefixes are distinct because
262 // they each end in `.` and the parent buckets disagree on what
263 // comes after — pcs holds host names, groups holds membership
264 // names — but locking the invariant in a test stops a future
265 // rename from breaking it.
266 assert_ne!(PREFIX_AGENT_CONFIG_GROUPS, PREFIX_AGENT_CONFIG_PCS);
267 assert!(parse_agent_config_group_key("pcs.someone").is_none());
268 assert!(parse_agent_config_pc_key("groups.someone").is_none());
269 assert_eq!(parse_agent_config_group_key("global"), None);
270 assert_eq!(parse_agent_config_pc_key("global"), None);
271 }
272}