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";
12
13/// `agent_meta` — per-PC operator-managed free-form key/value
14/// annotations (the primary user's name / email / department, an ad-hoc
15/// note), keyed by `pc_id`, value JSON
16/// [`AgentMeta`](crate::wire::AgentMeta). Durable operator metadata —
17/// distinct from the volatile `agents` heartbeat projection and from
18/// `agent_groups` membership. Edited via the SPA agent detail page or the
19/// `kanade meta` CLI (and typically bulk-populated by an operator AD-sync
20/// job that resolves the last-logon user's directory attributes).
21pub const BUCKET_AGENT_META: &str = "agent_meta";
22
23/// `group_contacts` — per-group notification email addresses, keyed by
24/// group name, value JSON [`GroupContacts`](crate::wire::GroupContacts).
25/// Operator-managed via the SPA Groups page. Distinct from
26/// `agent_groups` (per-PC membership) and `agent_config`'s `groups.*`
27/// scopes (agent config pushed to machines): this is operator contact
28/// info, read backend-side to fan a compliance alert out to email.
29pub const BUCKET_GROUP_CONTACTS: &str = "group_contacts";
30
31pub const BUCKET_SCHEDULES: &str = "schedules";
32
33/// Job catalog (v0.15) — operator-registered Manifests, keyed by
34/// `manifest.id`. Schedules and ad-hoc `kanade run --job-id ...` look
35/// jobs up here; the wire never round-trips an inline Manifest body
36/// through a Schedule again. Editing a job in-place retroactively
37/// changes what future schedule fires deploy.
38pub const BUCKET_JOBS: &str = "jobs";
39
40/// Parallel "operator source-of-truth YAML" stores keyed identically
41/// to `BUCKET_JOBS` / `BUCKET_SCHEDULES`. The agent / scheduler /
42/// projector all keep reading the JSON KVs above — these buckets
43/// exist only so the SPA's YAML editor can round-trip operator
44/// comments + script indentation + block-scalar style exactly.
45///
46/// Population is opportunistic: any `POST` with a
47/// `Content-Type: application/yaml` body stores the raw bytes here
48/// alongside the parsed JSON; JSON-content-type POSTs fall back to a
49/// `serde_yaml` dump so the buckets stay in lockstep with the JSON
50/// store (operator just loses comments on that path).
51pub const BUCKET_JOBS_YAML: &str = "jobs_yaml";
52pub const BUCKET_SCHEDULES_YAML: &str = "schedules_yaml";
53
54/// View catalog (#743) — operator-registered [`View`](crate::manifest::View)
55/// resources, keyed by `view.id`. A view is a pure, declarative
56/// read/aggregation over stored fleet data (`obs_events`, …) for the
57/// Analytics page — no `execute`, no schedule. The backend reads these at
58/// query time and merges their widgets with the co-located `aggregate:`
59/// hints on jobs. Distinct from `BUCKET_JOBS` so a cross-cutting dashboard
60/// doesn't need a noop job carrier.
61pub const BUCKET_VIEWS: &str = "views";
62/// Operator source-of-truth YAML mirror for `BUCKET_VIEWS` (same role as
63/// `BUCKET_JOBS_YAML`): keeps comments/formatting for the SPA editor.
64pub const BUCKET_VIEWS_YAML: &str = "views_yaml";
65
66/// Group-definition catalog (#1032) — operator-registered
67/// [`GroupDef`](crate::manifest::GroupDef) resources, keyed by `group.id`.
68/// A group definition is a **declared** fleet group: either a static
69/// `members:` list (reviewable, git-tracked membership) or a dynamic
70/// `query:` (a read-only SQL that returns a `pc_id` column, resolved
71/// backend-side against the projector tables). A schedule's `target.groups`
72/// resolves these in addition to the imperative `agent_groups` membership,
73/// so the two coexist — this bucket never touches `agent_groups`. Distinct
74/// from `BUCKET_AGENT_GROUPS` (per-PC imperative membership) and from
75/// `BUCKET_VIEWS` (dashboards).
76pub const BUCKET_GROUP_DEFS: &str = "group_defs";
77/// Operator source-of-truth YAML mirror for `BUCKET_GROUP_DEFS` (same role
78/// as `BUCKET_VIEWS_YAML`): keeps comments/formatting for the SPA editor.
79pub const BUCKET_GROUP_DEFS_YAML: &str = "group_defs_yaml";
80
81/// Fleet-wide singleton settings that aren't per-agent (so they don't
82/// belong in `agent_config`'s layered scopes) and aren't per-schedule
83/// (so they don't belong in `schedules`). First and only key so far is
84/// [`KEY_FREEZE`] (#418 Phase 5 global change-freeze). One small bucket
85/// both the backend scheduler and every agent's local scheduler watch.
86pub const BUCKET_FLEET_CONFIG: &str = "fleet_config";
87
88/// Backend-side, operator-editable server settings that aren't per-agent
89/// (so they don't belong in `agent_config`'s layered scopes) and aren't a
90/// fleet-wide switch every agent watches (so they don't belong in
91/// `fleet_config`). A single JSON document under [`KEY_SERVER_SETTINGS`]
92/// holding [`crate::wire::ServerSettings`], managed via the SPA Settings
93/// page's "server settings" tab. Deliberately generic: future server-side
94/// knobs join the same document rather than spawning a bucket each. First
95/// consumer is the cleanup task's dead-agent prune window
96/// (`ServerSettings::agent_prune_days`). `history: 1` — only the current
97/// state matters; nothing replays its history.
98pub const BUCKET_SERVER_SETTINGS: &str = "server_settings";
99
100/// Singleton key in [`BUCKET_SERVER_SETTINGS`] holding the JSON-encoded
101/// [`crate::wire::ServerSettings`]. **Key absent ⇒ all-default settings**
102/// (e.g. `agent_prune_days = 0`, pruning disabled), so a fresh deployment
103/// behaves exactly as it did before the bucket existed.
104pub const KEY_SERVER_SETTINGS: &str = "current";
105
106/// `notifications_read` — per-user read/ack state for end-user
107/// notifications (SPEC §2.3.2 / Phase E). Key shape
108/// `{pc_id}.{user_sid}.{notification_id}`, value JSON
109/// `{"acked_at": ..., "acked_by": "<sid>"}`. The agent writes a row
110/// when it handles a KLP `notifications.ack`, stamping the connecting
111/// user's OS-derived SID — so a shared PC tracks each user's reads
112/// independently. The `{pc_id}.{user_sid}.` prefix lets
113/// `notifications.list` fetch one user's read set with a single prefix
114/// walk. `history: 1` — only the latest ack per key matters.
115pub const BUCKET_NOTIFICATIONS_READ: &str = "notifications_read";
116
117/// KV key in [`BUCKET_NOTIFICATIONS_READ`] for one user's ack of one
118/// notification: `{pc_id}.{user_sid}.{notification_id}` (SPEC §2.3.2).
119///
120/// The components are joined with `.` per the spec's documented key
121/// shape. In practice none of them contain a `.` — `pc_id` is a
122/// hostname, `user_sid` is `S-1-5-…` (hyphen-delimited), and the
123/// backend mints `notification_id` as a UUID (operator-supplied
124/// manifest ids are kebab-case) — so the join stays unambiguous and
125/// the `{pc_id}.{user_sid}.` prefix (see
126/// [`notifications_read_prefix`]) cleanly selects exactly one user's
127/// read set for `notifications.list`.
128pub fn notifications_read_key(pc_id: &str, user_sid: &str, notification_id: &str) -> String {
129 format!("{pc_id}.{user_sid}.{notification_id}")
130}
131
132/// Prefix selecting every ack row for one `(pc_id, user_sid)` in
133/// [`BUCKET_NOTIFICATIONS_READ`] — `{pc_id}.{user_sid}.`.
134/// `notifications.list` walks the bucket keys and keeps those carrying
135/// this prefix to compute the caller's unread set. Pairs with
136/// [`notifications_read_key`].
137pub fn notifications_read_prefix(pc_id: &str, user_sid: &str) -> String {
138 format!("{pc_id}.{user_sid}.")
139}
140
141/// Singleton key in [`BUCKET_FLEET_CONFIG`] holding the JSON-encoded
142/// [`crate::manifest::Freeze`]. **Key absent ⇒ not frozen** (clearing
143/// the freeze is a KV delete), so readers treat a missing key as "fire
144/// normally" and only evaluate `Freeze::is_active` when the key exists.
145pub const KEY_FREEZE: &str = "freeze";
146
147/// KV bucket holding **per-(schedule, pc) last-dispatch marks** for the
148/// backend scheduler's in-flight suppression.
149///
150/// The per-pc / per-target dedup ([`crate::manifest::ExecMode`]) only
151/// sees *completed* runs (`execution_results`, exit_code = 0). Since
152/// #418 the reconcile poll runs every minute ([`crate::manifest::POLL_CRON`]),
153/// but a dispatched Command doesn't land a completion until
154/// `jitter (agent-side) + run + outbox drain` later — frequently
155/// several minutes with a 3–5 min jitter. Without a dispatch record the
156/// poll re-fires the same PC (or whole target) every tick across that
157/// gap. This bucket records "I dispatched (schedule, pc) at T" so the
158/// scheduler can suppress re-fire for a bounded window without waiting
159/// on the completion round-trip.
160///
161/// Values are the dispatch instant as an RFC3339 string. A bucket-wide
162/// `max_age` GCs marks once they're well past any suppression window,
163/// so the bucket can't grow unbounded; the suppression-window check
164/// itself lives in `scheduler::policy::suppress_dispatched`.
165pub const BUCKET_SCHEDULER_DISPATCH: &str = "scheduler_dispatch";
166
167/// Per-pc dispatch-mark key (OncePerPc).
168///
169/// The `pc.` / `target.` kind prefix keeps the two namespaces apart,
170/// and each component is **length-prefixed** (`<len>.<value>`) so no two
171/// distinct `(schedule_id, pc_id)` pairs can ever collide — even when an
172/// id contains the `.` separator: `("a.b", "c")` → `pc.3.a.b.1.c`,
173/// `("a", "b.c")` → `pc.1.a.3.b.c`. (Percent-/base64-encoding isn't an
174/// option: NATS KV keys only allow `[-/_=.a-zA-Z0-9]`, so the
175/// self-delimiting length prefix is the cheapest injective encoding that
176/// stays in-charset.)
177pub fn dispatch_mark_pc_key(schedule_id: &str, pc_id: &str) -> String {
178 format!(
179 "pc.{}.{}.{}.{}",
180 schedule_id.len(),
181 schedule_id,
182 pc_id.len(),
183 pc_id
184 )
185}
186
187/// Whole-target dispatch-mark key (OncePerTarget). One key per
188/// schedule — a per-target fire dispatches the whole target at once, so
189/// there's nothing per-pc to record. Length-prefixed for symmetry with
190/// [`dispatch_mark_pc_key`].
191pub fn dispatch_mark_target_key(schedule_id: &str) -> String {
192 format!("target.{}.{}", schedule_id.len(), schedule_id)
193}
194
195/// Object Store bucket holding raw agent binaries (one object per
196/// version, e.g. `0.2.0` → file bytes).
197pub const OBJECT_AGENT_RELEASES: &str = "agent_releases";
198
199/// Object Store holding **generic application packages** — anything
200/// the agent / kitting scripts pull down + install on endpoints.
201/// First consumer is the kanade-client app, but the bucket is
202/// intentionally generic: third-party installers (Webex, Teams,
203/// custom MSI bundles), upgrade scripts, configuration archives,
204/// etc. all live here.
205///
206/// Object keys are `<name>/<version>` — operator picks `<name>`
207/// once per package family (e.g. `kanade-client`,
208/// `webex-meetings`), then `<version>` per release (e.g.
209/// `0.41.0`, `2025.03`). Slashes are explicitly allowed by NATS
210/// Object Store key rules; the SPA / CLI / HTTP routes all carry
211/// the pair as two path segments.
212///
213/// Why a separate bucket from `agent_releases`:
214/// - `agent_releases` is fleet-critical (the agent's own self-
215/// update path). Keeping it small + audited matters.
216/// - `app_packages` is operator-curated user-space content. The
217/// lifecycle is different (operators add/remove packages
218/// freely; agent releases follow the release.yml pipeline).
219pub const OBJECT_APP_PACKAGES: &str = "app_packages";
220
221/// Object Store holding **manifest script bodies** referenced by
222/// `Execute::script_object` (SPEC §2.4.1's alternative to inline
223/// `script:` / repo-local `script_file:`). Per yukimemi/kanade
224/// issue #210, this is the "Plan B 4-bucket layout" sibling of
225/// `app_packages` — separated because scripts have a different
226/// lifecycle than installer binaries:
227///
228/// - Smaller (typical KB-to-low-MB, vs MB-to-hundreds-of-MB
229/// installers).
230/// - Coupled to manifest versions (script lifecycle = manifest
231/// lifecycle; the `script_current` / `script_status` KV gates
232/// in SPEC §2.6.2 already track manifest versions, so a
233/// matching dedicated bucket keeps the audit story aligned).
234/// - Different access pattern (every Command execute potentially
235/// fetches; vs installer fetched once per fleet deploy).
236///
237/// Object keys follow the same `<name>/<version>` shape as
238/// `app_packages` so the SPA / operator tooling stays uniform.
239/// For manifest-driven scripts `<name>` is the manifest id and
240/// `<version>` is the manifest version, but the bucket itself
241/// imposes no semantics on the pair — operator-uploaded
242/// ad-hoc scripts can use any `<name>/<version>` they like.
243pub const OBJECT_SCRIPTS: &str = "scripts";
244
245/// Object Store holding **overflow stdout / stderr blobs** for the
246/// `ExecResult` wire kind (#227). The default NATS `max_payload` is
247/// 1 MB; a result whose stdout / stderr exceeds it would reject the
248/// publish and pin the agent's outbox in a reconnect loop. The agent
249/// uploads any stdout / stderr larger than `STDOUT_INLINE_THRESHOLD`
250/// (256 KB, picked at 1/4 of the default max_payload so the rest of
251/// the ExecResult fields fit alongside) into this bucket and replaces
252/// the inline field with [`crate::wire::ExecResult::stdout_object`] /
253/// `stderr_object` pointers. Backend's results projector derefs the
254/// pointers before INSERT so downstream consumers (SQLite, SPA
255/// Activity, inventory projector) see the full text the same way
256/// they always have.
257///
258/// Object keys follow the shape `<request_id>/{stdout,stderr}` so
259/// stdout + stderr for the same execution share a sibling prefix —
260/// makes `kanade jetstream` listings group naturally and keeps the
261/// per-key namespace tight against duplicate uploads.
262///
263/// Per-bucket retention (not a stream-wide TTL since async-nats
264/// object_store inherits stream config): matches `STREAM_RESULTS`'s
265/// 30-day retention so an operator who can still query the result
266/// row in SQLite can also fetch the original blob if the inline
267/// copy ever needs re-projection.
268pub const OBJECT_RESULT_OUTPUT: &str = "result_output";
269
270/// Object Store holding **collected file bundles** (#219). A job
271/// carrying a `collect:` manifest hint prints a JSON list of file
272/// paths on stdout; the agent zips them and uploads the archive here,
273/// recording the key in [`crate::wire::ExecResult::collect_object`].
274/// The SPA Collect page lists / downloads bundles straight from this
275/// bucket. Object keys follow `<pc_id>/<job_id>/<rfc3339>.zip`, or
276/// `<pc_id>/<job_id>/<label>__<rfc3339>.zip` when a run emits multiple
277/// labeled bundles (e.g. one zip per day), so a listing groups by host
278/// then job. Per-bucket retention is 30 days
279/// (bundles are debugging/audit artifacts, not curated config like
280/// `app_packages` / `scripts`, so they auto-expire) — see
281/// `kanade-shared::bootstrap`.
282pub const OBJECT_COLLECTIONS: &str = "collections";
283
284/// Inline threshold for `ExecResult.stdout` / `.stderr`. Larger
285/// payloads overflow into [`OBJECT_RESULT_OUTPUT`]. 256 KB = 1/4 of
286/// the NATS default `max_payload` (1 MB) so the rest of the
287/// ExecResult JSON (request_id, exec_id, etc.) easily fits below the
288/// publish-reject ceiling.
289///
290/// Lives next to the bucket constant rather than on the agent side
291/// so the SPA / future operator tooling can quote the same threshold
292/// when explaining "why this result has no inline stdout".
293pub const STDOUT_INLINE_THRESHOLD: usize = 256 * 1024;
294
295/// Key inside [`BUCKET_AGENT_CONFIG`] carrying the broadcast target
296/// version. Agents watch this key and self-update when their running
297/// version drifts.
298pub const KEY_AGENT_TARGET_VERSION: &str = "target_version";
299
300/// Sprint 6 layered-config keys inside [`BUCKET_AGENT_CONFIG`]:
301/// * `global` — fleet-wide default ConfigScope JSON
302/// * `groups.<name>` — per-group override (partial ConfigScope)
303/// * `pcs.<pc_id>` — per-pc override (partial ConfigScope)
304///
305/// The `groups.` / `pcs.` prefixes let a `kv.keys()` walk pick out
306/// just the rows in one scope when listing.
307pub const KEY_AGENT_CONFIG_GLOBAL: &str = "global";
308pub const PREFIX_AGENT_CONFIG_GROUPS: &str = "groups.";
309pub const PREFIX_AGENT_CONFIG_PCS: &str = "pcs.";
310
311pub fn agent_config_group_key(group: &str) -> String {
312 format!("{PREFIX_AGENT_CONFIG_GROUPS}{group}")
313}
314
315pub fn agent_config_pc_key(pc_id: &str) -> String {
316 format!("{PREFIX_AGENT_CONFIG_PCS}{pc_id}")
317}
318
319/// Inverse of [`agent_config_group_key`] — returns the bare group
320/// name if `key` carries the groups-scope prefix, else `None`.
321pub fn parse_agent_config_group_key(key: &str) -> Option<&str> {
322 key.strip_prefix(PREFIX_AGENT_CONFIG_GROUPS)
323}
324
325/// Inverse of [`agent_config_pc_key`].
326pub fn parse_agent_config_pc_key(key: &str) -> Option<&str> {
327 key.strip_prefix(PREFIX_AGENT_CONFIG_PCS)
328}
329
330pub const SCRIPT_STATUS_ACTIVE: &str = "ACTIVE";
331pub const SCRIPT_STATUS_REVOKED: &str = "REVOKED";
332
333pub const STREAM_INVENTORY: &str = "INVENTORY";
334pub const STREAM_RESULTS: &str = "RESULTS";
335pub const STREAM_EXEC: &str = "EXEC";
336pub const STREAM_EVENTS: &str = "EVENTS";
337pub const STREAM_AUDIT: &str = "AUDIT";
338
339/// JetStream stream retaining end-user notification history (SPEC
340/// §2.3.1 / Phase E). Catches every `notifications.{all|group.X|pc.Y}`
341/// publish the backend fans out, so a Client App that connects after
342/// a notification was sent can still fetch it via KLP
343/// `notifications.list`. 90-day window — long enough for "what did I
344/// miss while on leave" without unbounded growth. Unlike `EXEC`,
345/// retains all messages per subject (no `max_messages_per_subject`):
346/// each notification is its own history entry, not a latest-only state.
347pub const STREAM_NOTIFICATIONS: &str = "NOTIFICATIONS";
348
349/// JetStream stream backing the per-PC observability event pipeline
350/// (Issue #246). Distinct from [`STREAM_EVENTS`] (in-flight script
351/// lifecycle) — `STREAM_OBS_EVENTS` carries the timeline data the
352/// SPA's Events page consumes: sign-in/out, power on/off, sleep/
353/// resume, agent milestones, diagnostic bundle pointers. The agent
354/// publishes on `obs.<pc_id>` (see [`crate::subject::obs`]) and
355/// this stream catches everything matching [`crate::subject::OBS_FILTER`]
356/// so a backend that boots after the agent doesn't miss any
357/// already-emitted events.
358pub const STREAM_OBS_EVENTS: &str = "OBS_EVENTS";
359
360/// Canonical list of every JetStream resource
361/// [`crate::bootstrap::ensure_jetstream_resources`] creates. The health
362/// rollup (`/api/health/fleet`) and the status snapshot
363/// (`/api/jetstream/status`) both iterate these so the dashboard reports
364/// the *complete* resource set — previously each kept its own hand-
365/// maintained subset that drifted behind bootstrap (e.g. only 1 of the 5
366/// object stores showed up). Keep in lockstep with `bootstrap.rs`: a new
367/// stream / bucket / store added there must be appended here too. The
368/// `canonical_resource_lists_are_sane` test below guards the easy
369/// mistakes (dots, dupes, empties); keeping the *set* aligned with
370/// bootstrap stays a manual discipline (bootstrap needs per-resource
371/// config, so it can't be derived from a name list alone).
372pub const ALL_STREAMS: &[&str] = &[
373 STREAM_INVENTORY,
374 STREAM_RESULTS,
375 STREAM_EXEC,
376 STREAM_EVENTS,
377 STREAM_AUDIT,
378 STREAM_OBS_EVENTS,
379 STREAM_NOTIFICATIONS,
380];
381
382/// Every KV bucket `ensure_jetstream_resources` creates. The `*_yaml`
383/// source-of-truth buckets and the operator-managed singletons
384/// (`fleet_config`, `group_contacts`) are included — they're part of the
385/// bootstrap contract, so a missing one is a genuine degradation. Lazily-
386/// created buckets that bootstrap does NOT guarantee (e.g. `views`,
387/// `scheduler_dispatch`) are deliberately excluded so a fresh fleet that
388/// never used them doesn't read as degraded.
389pub const ALL_KV_BUCKETS: &[&str] = &[
390 BUCKET_SCRIPT_CURRENT,
391 BUCKET_SCRIPT_STATUS,
392 BUCKET_AGENTS_STATE,
393 BUCKET_AGENT_CONFIG,
394 BUCKET_AGENT_GROUPS,
395 BUCKET_AGENT_META,
396 BUCKET_GROUP_CONTACTS,
397 BUCKET_SCHEDULES,
398 BUCKET_JOBS,
399 BUCKET_FLEET_CONFIG,
400 BUCKET_NOTIFICATIONS_READ,
401 BUCKET_JOBS_YAML,
402 BUCKET_SCHEDULES_YAML,
403];
404
405/// Every Object Store `ensure_jetstream_resources` creates. The status
406/// probe used to list only `agent_releases`, which is why the dashboard's
407/// "Object stores" column looked suspiciously empty.
408pub const ALL_OBJECT_STORES: &[&str] = &[
409 OBJECT_AGENT_RELEASES,
410 OBJECT_APP_PACKAGES,
411 OBJECT_SCRIPTS,
412 OBJECT_RESULT_OUTPUT,
413 OBJECT_COLLECTIONS,
414];
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 /// NATS KV bucket names must be domain-safe ASCII (a-z, A-Z, 0-9, _, -).
421 /// Lock the constants down so a future edit doesn't introduce a `.` and
422 /// break create_key_value silently on the broker side.
423 #[test]
424 fn bucket_names_are_domain_safe() {
425 for name in [
426 BUCKET_SCRIPT_CURRENT,
427 BUCKET_SCRIPT_STATUS,
428 BUCKET_AGENTS_STATE,
429 BUCKET_AGENT_CONFIG,
430 BUCKET_AGENT_GROUPS,
431 BUCKET_AGENT_META,
432 BUCKET_GROUP_CONTACTS,
433 BUCKET_SCHEDULES,
434 BUCKET_JOBS,
435 BUCKET_JOBS_YAML,
436 BUCKET_SCHEDULES_YAML,
437 BUCKET_VIEWS,
438 BUCKET_VIEWS_YAML,
439 BUCKET_GROUP_DEFS,
440 BUCKET_GROUP_DEFS_YAML,
441 BUCKET_FLEET_CONFIG,
442 BUCKET_SERVER_SETTINGS,
443 BUCKET_NOTIFICATIONS_READ,
444 BUCKET_SCHEDULER_DISPATCH,
445 OBJECT_AGENT_RELEASES,
446 OBJECT_APP_PACKAGES,
447 OBJECT_SCRIPTS,
448 OBJECT_RESULT_OUTPUT,
449 OBJECT_COLLECTIONS,
450 ] {
451 assert!(
452 !name.contains('.'),
453 "bucket name {name:?} contains a dot, which NATS KV rejects"
454 );
455 assert!(
456 name.chars()
457 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
458 "bucket name {name:?} has non-domain-safe characters"
459 );
460 }
461 }
462
463 #[test]
464 fn stream_names_are_unique() {
465 let names = [
466 STREAM_INVENTORY,
467 STREAM_RESULTS,
468 STREAM_EXEC,
469 STREAM_EVENTS,
470 STREAM_AUDIT,
471 STREAM_OBS_EVENTS,
472 STREAM_NOTIFICATIONS,
473 ];
474 let mut deduped = names.to_vec();
475 deduped.sort_unstable();
476 deduped.dedup();
477 assert_eq!(
478 deduped.len(),
479 names.len(),
480 "stream constants collide: {names:?}"
481 );
482 }
483
484 /// The canonical lists the health + status probes iterate must be
485 /// non-empty, dup-free, and domain-safe (the same charset rule the
486 /// broker enforces). Catches a copy-paste dupe or a stray `.` before
487 /// it turns into a phantom "missing resource" on the dashboard.
488 #[test]
489 fn canonical_resource_lists_are_sane() {
490 for (label, list) in [
491 ("ALL_STREAMS", ALL_STREAMS),
492 ("ALL_KV_BUCKETS", ALL_KV_BUCKETS),
493 ("ALL_OBJECT_STORES", ALL_OBJECT_STORES),
494 ] {
495 assert!(!list.is_empty(), "{label} is empty");
496 let mut deduped = list.to_vec();
497 deduped.sort_unstable();
498 deduped.dedup();
499 assert_eq!(
500 deduped.len(),
501 list.len(),
502 "{label} has duplicates: {list:?}"
503 );
504 for name in list {
505 assert!(
506 name.chars()
507 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
508 "{label} entry {name:?} has non-domain-safe characters"
509 );
510 }
511 }
512 }
513
514 #[test]
515 fn notifications_read_key_and_prefix_align() {
516 let key = notifications_read_key("PC1234", "S-1-5-21-1001", "notif-9f3a");
517 assert_eq!(key, "PC1234.S-1-5-21-1001.notif-9f3a");
518 let prefix = notifications_read_prefix("PC1234", "S-1-5-21-1001");
519 assert_eq!(prefix, "PC1234.S-1-5-21-1001.");
520 // The list path selects a user's read set by this prefix — the
521 // key for any of that user's notifications must carry it.
522 assert!(key.starts_with(&prefix));
523 // A different user's key must NOT match the prefix.
524 let other = notifications_read_key("PC1234", "S-1-5-21-1002", "notif-9f3a");
525 assert!(!other.starts_with(&prefix));
526 }
527
528 #[test]
529 fn script_status_strings() {
530 assert_eq!(SCRIPT_STATUS_ACTIVE, "ACTIVE");
531 assert_eq!(SCRIPT_STATUS_REVOKED, "REVOKED");
532 assert_ne!(SCRIPT_STATUS_ACTIVE, SCRIPT_STATUS_REVOKED);
533 }
534
535 #[test]
536 fn key_agent_target_version_constant() {
537 assert_eq!(KEY_AGENT_TARGET_VERSION, "target_version");
538 }
539
540 #[test]
541 fn agent_config_group_key_round_trips() {
542 let k = agent_config_group_key("canary");
543 assert_eq!(k, "groups.canary");
544 assert_eq!(parse_agent_config_group_key(&k), Some("canary"));
545 }
546
547 #[test]
548 fn agent_config_pc_key_round_trips() {
549 let k = agent_config_pc_key("PC-01");
550 assert_eq!(k, "pcs.PC-01");
551 assert_eq!(parse_agent_config_pc_key(&k), Some("PC-01"));
552 }
553
554 #[test]
555 fn dispatch_mark_keys_are_distinct_by_kind() {
556 // The whole-target key for one schedule must never equal the
557 // per-pc key for another — the `pc.` / `target.` prefixes keep
558 // the two namespaces apart even when ids look alike.
559 let per_pc = dispatch_mark_pc_key("collect-winlog-events", "PC-01");
560 let target = dispatch_mark_target_key("collect-winlog-events");
561 assert_eq!(per_pc, "pc.21.collect-winlog-events.5.PC-01");
562 assert_eq!(target, "target.21.collect-winlog-events");
563 assert_ne!(per_pc, target);
564 // A schedule literally named "collect-winlog-events.PC-01"
565 // still can't collide with the per-pc key above.
566 assert_ne!(
567 dispatch_mark_target_key("collect-winlog-events.PC-01"),
568 per_pc,
569 );
570 }
571
572 #[test]
573 fn dispatch_mark_pc_key_has_no_dot_collision() {
574 // Length-prefixing makes the encoding injective: a dotted
575 // schedule_id can't borrow a leading segment from the pc_id (or
576 // vice versa) to forge a colliding key. (CodeRabbit / claude #444.)
577 assert_ne!(
578 dispatch_mark_pc_key("a.b", "c"),
579 dispatch_mark_pc_key("a", "b.c"),
580 );
581 assert_ne!(
582 dispatch_mark_pc_key("x", "y.z"),
583 dispatch_mark_pc_key("x.y", "z"),
584 );
585 // Same components, swapped roles — also distinct.
586 assert_ne!(
587 dispatch_mark_pc_key("foo", "bar"),
588 dispatch_mark_pc_key("bar", "foo"),
589 );
590 }
591
592 #[test]
593 fn agent_config_scope_keys_do_not_collide() {
594 // Belt + braces: make sure no pc id starting with "groups." would
595 // be misparsed (or vice versa). The prefixes are distinct because
596 // they each end in `.` and the parent buckets disagree on what
597 // comes after — pcs holds host names, groups holds membership
598 // names — but locking the invariant in a test stops a future
599 // rename from breaking it.
600 assert_ne!(PREFIX_AGENT_CONFIG_GROUPS, PREFIX_AGENT_CONFIG_PCS);
601 assert!(parse_agent_config_group_key("pcs.someone").is_none());
602 assert!(parse_agent_config_pc_key("groups.someone").is_none());
603 assert_eq!(parse_agent_config_group_key("global"), None);
604 assert_eq!(parse_agent_config_pc_key("global"), None);
605 }
606}