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