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/// 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}