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/// The NATS broker's default `max_payload`. A publish above this is
304/// rejected outright, which is why every variable-size thing this system
305/// puts in a single message is budgeted against it.
306///
307/// Named rather than spelled inline so the derivation below reads as a
308/// derivation. If a deployment ever raises the broker's setting, this is
309/// the one place that has to learn about it.
310pub const NATS_DEFAULT_MAX_PAYLOAD: usize = 1024 * 1024;
311
312/// How large a single variable-size blob may be inside one NATS message:
313/// a quarter of [`NATS_DEFAULT_MAX_PAYLOAD`], leaving the other
314/// three-quarters for whatever structured fields travel alongside it.
315///
316/// Shared by every such budget in the system — currently
317/// [`STDOUT_INLINE_THRESHOLD`] and `wire::MAX_TILE_BYTES`. Those are
318/// *different concepts* (when to spill stdout to the Object Store; how
319/// far an encoder may let one screen tile grow) and deliberately remain
320/// separate constants, but they answer to the same broker limit, so the
321/// limit lives in one place and each concept derives from it. Restating
322/// `256 * 1024` at each site would let them drift apart silently.
323pub const NATS_PAYLOAD_BUDGET: usize = NATS_DEFAULT_MAX_PAYLOAD / 4;
324
325/// Inline threshold for `ExecResult.stdout` / `.stderr`. Larger
326/// payloads overflow into [`OBJECT_RESULT_OUTPUT`]. 256 KB = 1/4 of
327/// the NATS default `max_payload` (1 MB) so the rest of the
328/// ExecResult JSON (request_id, exec_id, etc.) easily fits below the
329/// publish-reject ceiling.
330///
331/// Lives next to the bucket constant rather than on the agent side
332/// so the SPA / future operator tooling can quote the same threshold
333/// when explaining "why this result has no inline stdout".
334pub const STDOUT_INLINE_THRESHOLD: usize = NATS_PAYLOAD_BUDGET;
335
336/// Key inside [`BUCKET_AGENT_CONFIG`] carrying the broadcast target
337/// version. Agents watch this key and self-update when their running
338/// version drifts.
339pub const KEY_AGENT_TARGET_VERSION: &str = "target_version";
340
341/// Sprint 6 layered-config keys inside [`BUCKET_AGENT_CONFIG`]:
342/// * `global` — fleet-wide default ConfigScope JSON
343/// * `groups.<name>` — per-group override (partial ConfigScope)
344/// * `pcs.<pc_id>` — per-pc override (partial ConfigScope)
345///
346/// The `groups.` / `pcs.` prefixes let a `kv.keys()` walk pick out
347/// just the rows in one scope when listing.
348pub const KEY_AGENT_CONFIG_GLOBAL: &str = "global";
349pub const PREFIX_AGENT_CONFIG_GROUPS: &str = "groups.";
350pub const PREFIX_AGENT_CONFIG_PCS: &str = "pcs.";
351
352pub fn agent_config_group_key(group: &str) -> String {
353 format!("{PREFIX_AGENT_CONFIG_GROUPS}{group}")
354}
355
356pub fn agent_config_pc_key(pc_id: &str) -> String {
357 format!("{PREFIX_AGENT_CONFIG_PCS}{pc_id}")
358}
359
360/// Inverse of [`agent_config_group_key`] — returns the bare group
361/// name if `key` carries the groups-scope prefix, else `None`.
362pub fn parse_agent_config_group_key(key: &str) -> Option<&str> {
363 key.strip_prefix(PREFIX_AGENT_CONFIG_GROUPS)
364}
365
366/// Inverse of [`agent_config_pc_key`].
367pub fn parse_agent_config_pc_key(key: &str) -> Option<&str> {
368 key.strip_prefix(PREFIX_AGENT_CONFIG_PCS)
369}
370
371pub const SCRIPT_STATUS_ACTIVE: &str = "ACTIVE";
372pub const SCRIPT_STATUS_REVOKED: &str = "REVOKED";
373
374pub const STREAM_INVENTORY: &str = "INVENTORY";
375pub const STREAM_RESULTS: &str = "RESULTS";
376pub const STREAM_EXEC: &str = "EXEC";
377pub const STREAM_EVENTS: &str = "EVENTS";
378pub const STREAM_AUDIT: &str = "AUDIT";
379
380/// JetStream stream retaining end-user notification history (SPEC
381/// §2.3.1 / Phase E). Catches every `notifications.{all|group.X|pc.Y}`
382/// publish the backend fans out, so a Client App that connects after
383/// a notification was sent can still fetch it via KLP
384/// `notifications.list`. 90-day window — long enough for "what did I
385/// miss while on leave" without unbounded growth. Unlike `EXEC`,
386/// retains all messages per subject (no `max_messages_per_subject`):
387/// each notification is its own history entry, not a latest-only state.
388pub const STREAM_NOTIFICATIONS: &str = "NOTIFICATIONS";
389
390/// JetStream stream backing the per-PC observability event pipeline
391/// (Issue #246). Distinct from [`STREAM_EVENTS`] (in-flight script
392/// lifecycle) — `STREAM_OBS_EVENTS` carries the timeline data the
393/// SPA's Events page consumes: sign-in/out, power on/off, sleep/
394/// resume, agent milestones, diagnostic bundle pointers. The agent
395/// publishes on `obs.<pc_id>` (see [`crate::subject::obs`]) and
396/// this stream catches everything matching [`crate::subject::OBS_FILTER`]
397/// so a backend that boots after the agent doesn't miss any
398/// already-emitted events.
399pub const STREAM_OBS_EVENTS: &str = "OBS_EVENTS";
400
401/// Canonical list of every JetStream resource
402/// [`crate::bootstrap::ensure_jetstream_resources`] creates. The health
403/// rollup (`/api/health/fleet`) and the status snapshot
404/// (`/api/jetstream/status`) both iterate these so the dashboard reports
405/// the *complete* resource set — previously each kept its own hand-
406/// maintained subset that drifted behind bootstrap (e.g. only 1 of the 5
407/// object stores showed up). Keep in lockstep with `bootstrap.rs`: a new
408/// stream / bucket / store added there must be appended here too. The
409/// `canonical_resource_lists_are_sane` test below guards the easy
410/// mistakes (dots, dupes, empties); keeping the *set* aligned with
411/// bootstrap stays a manual discipline (bootstrap needs per-resource
412/// config, so it can't be derived from a name list alone).
413pub const ALL_STREAMS: &[&str] = &[
414 STREAM_INVENTORY,
415 STREAM_RESULTS,
416 STREAM_EXEC,
417 STREAM_EVENTS,
418 STREAM_AUDIT,
419 STREAM_OBS_EVENTS,
420 STREAM_NOTIFICATIONS,
421];
422
423/// Every KV bucket `ensure_jetstream_resources` creates. The `*_yaml`
424/// source-of-truth buckets and the operator-managed singletons
425/// (`fleet_config`, `group_contacts`) are included — they're part of the
426/// bootstrap contract, so a missing one is a genuine degradation. Lazily-
427/// created buckets that bootstrap does NOT guarantee (e.g. `views`,
428/// `scheduler_dispatch`) are deliberately excluded so a fresh fleet that
429/// never used them doesn't read as degraded.
430pub const ALL_KV_BUCKETS: &[&str] = &[
431 BUCKET_SCRIPT_CURRENT,
432 BUCKET_SCRIPT_STATUS,
433 BUCKET_AGENTS_STATE,
434 BUCKET_AGENT_CONFIG,
435 BUCKET_AGENT_GROUPS,
436 BUCKET_AGENT_GROUPS_DERIVED,
437 BUCKET_AGENT_META,
438 BUCKET_GROUP_CONTACTS,
439 BUCKET_SCHEDULES,
440 BUCKET_JOBS,
441 BUCKET_FLEET_CONFIG,
442 BUCKET_NOTIFICATIONS_READ,
443 BUCKET_JOBS_YAML,
444 BUCKET_SCHEDULES_YAML,
445];
446
447/// Every Object Store `ensure_jetstream_resources` creates. The status
448/// probe used to list only `agent_releases`, which is why the dashboard's
449/// "Object stores" column looked suspiciously empty.
450pub const ALL_OBJECT_STORES: &[&str] = &[
451 OBJECT_AGENT_RELEASES,
452 OBJECT_APP_PACKAGES,
453 OBJECT_SCRIPTS,
454 OBJECT_RESULT_OUTPUT,
455 OBJECT_COLLECTIONS,
456];
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 /// NATS KV bucket names must be domain-safe ASCII (a-z, A-Z, 0-9, _, -).
463 /// Lock the constants down so a future edit doesn't introduce a `.` and
464 /// break create_key_value silently on the broker side.
465 #[test]
466 fn bucket_names_are_domain_safe() {
467 for name in [
468 BUCKET_SCRIPT_CURRENT,
469 BUCKET_SCRIPT_STATUS,
470 BUCKET_AGENTS_STATE,
471 BUCKET_AGENT_CONFIG,
472 BUCKET_AGENT_GROUPS,
473 BUCKET_AGENT_GROUPS_DERIVED,
474 BUCKET_AGENT_META,
475 BUCKET_GROUP_CONTACTS,
476 BUCKET_SCHEDULES,
477 BUCKET_JOBS,
478 BUCKET_JOBS_YAML,
479 BUCKET_SCHEDULES_YAML,
480 BUCKET_VIEWS,
481 BUCKET_VIEWS_YAML,
482 BUCKET_GROUP_DEFS,
483 BUCKET_GROUP_DEFS_YAML,
484 BUCKET_FLEET_CONFIG,
485 BUCKET_SERVER_SETTINGS,
486 BUCKET_NOTIFICATIONS_READ,
487 BUCKET_SCHEDULER_DISPATCH,
488 OBJECT_AGENT_RELEASES,
489 OBJECT_APP_PACKAGES,
490 OBJECT_SCRIPTS,
491 OBJECT_RESULT_OUTPUT,
492 OBJECT_COLLECTIONS,
493 ] {
494 assert!(
495 !name.contains('.'),
496 "bucket name {name:?} contains a dot, which NATS KV rejects"
497 );
498 assert!(
499 name.chars()
500 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
501 "bucket name {name:?} has non-domain-safe characters"
502 );
503 }
504 }
505
506 #[test]
507 fn stream_names_are_unique() {
508 let names = [
509 STREAM_INVENTORY,
510 STREAM_RESULTS,
511 STREAM_EXEC,
512 STREAM_EVENTS,
513 STREAM_AUDIT,
514 STREAM_OBS_EVENTS,
515 STREAM_NOTIFICATIONS,
516 ];
517 let mut deduped = names.to_vec();
518 deduped.sort_unstable();
519 deduped.dedup();
520 assert_eq!(
521 deduped.len(),
522 names.len(),
523 "stream constants collide: {names:?}"
524 );
525 }
526
527 /// The canonical lists the health + status probes iterate must be
528 /// non-empty, dup-free, and domain-safe (the same charset rule the
529 /// broker enforces). Catches a copy-paste dupe or a stray `.` before
530 /// it turns into a phantom "missing resource" on the dashboard.
531 #[test]
532 fn canonical_resource_lists_are_sane() {
533 for (label, list) in [
534 ("ALL_STREAMS", ALL_STREAMS),
535 ("ALL_KV_BUCKETS", ALL_KV_BUCKETS),
536 ("ALL_OBJECT_STORES", ALL_OBJECT_STORES),
537 ] {
538 assert!(!list.is_empty(), "{label} is empty");
539 let mut deduped = list.to_vec();
540 deduped.sort_unstable();
541 deduped.dedup();
542 assert_eq!(
543 deduped.len(),
544 list.len(),
545 "{label} has duplicates: {list:?}"
546 );
547 for name in list {
548 assert!(
549 name.chars()
550 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
551 "{label} entry {name:?} has non-domain-safe characters"
552 );
553 }
554 }
555 }
556
557 #[test]
558 fn notifications_read_key_and_prefix_align() {
559 let key = notifications_read_key("PC1234", "S-1-5-21-1001", "notif-9f3a");
560 assert_eq!(key, "PC1234.S-1-5-21-1001.notif-9f3a");
561 let prefix = notifications_read_prefix("PC1234", "S-1-5-21-1001");
562 assert_eq!(prefix, "PC1234.S-1-5-21-1001.");
563 // The list path selects a user's read set by this prefix — the
564 // key for any of that user's notifications must carry it.
565 assert!(key.starts_with(&prefix));
566 // A different user's key must NOT match the prefix.
567 let other = notifications_read_key("PC1234", "S-1-5-21-1002", "notif-9f3a");
568 assert!(!other.starts_with(&prefix));
569 }
570
571 #[test]
572 fn script_status_strings() {
573 assert_eq!(SCRIPT_STATUS_ACTIVE, "ACTIVE");
574 assert_eq!(SCRIPT_STATUS_REVOKED, "REVOKED");
575 assert_ne!(SCRIPT_STATUS_ACTIVE, SCRIPT_STATUS_REVOKED);
576 }
577
578 #[test]
579 fn key_agent_target_version_constant() {
580 assert_eq!(KEY_AGENT_TARGET_VERSION, "target_version");
581 }
582
583 #[test]
584 fn agent_config_group_key_round_trips() {
585 let k = agent_config_group_key("canary");
586 assert_eq!(k, "groups.canary");
587 assert_eq!(parse_agent_config_group_key(&k), Some("canary"));
588 }
589
590 #[test]
591 fn agent_config_pc_key_round_trips() {
592 let k = agent_config_pc_key("PC-01");
593 assert_eq!(k, "pcs.PC-01");
594 assert_eq!(parse_agent_config_pc_key(&k), Some("PC-01"));
595 }
596
597 #[test]
598 fn dispatch_mark_keys_are_distinct_by_kind() {
599 // The whole-target key for one schedule must never equal the
600 // per-pc key for another — the `pc.` / `target.` prefixes keep
601 // the two namespaces apart even when ids look alike.
602 let per_pc = dispatch_mark_pc_key("collect-winlog-events", "PC-01");
603 let target = dispatch_mark_target_key("collect-winlog-events");
604 assert_eq!(per_pc, "pc.21.collect-winlog-events.5.PC-01");
605 assert_eq!(target, "target.21.collect-winlog-events");
606 assert_ne!(per_pc, target);
607 // A schedule literally named "collect-winlog-events.PC-01"
608 // still can't collide with the per-pc key above.
609 assert_ne!(
610 dispatch_mark_target_key("collect-winlog-events.PC-01"),
611 per_pc,
612 );
613 }
614
615 #[test]
616 fn dispatch_mark_pc_key_has_no_dot_collision() {
617 // Length-prefixing makes the encoding injective: a dotted
618 // schedule_id can't borrow a leading segment from the pc_id (or
619 // vice versa) to forge a colliding key. (CodeRabbit / claude #444.)
620 assert_ne!(
621 dispatch_mark_pc_key("a.b", "c"),
622 dispatch_mark_pc_key("a", "b.c"),
623 );
624 assert_ne!(
625 dispatch_mark_pc_key("x", "y.z"),
626 dispatch_mark_pc_key("x.y", "z"),
627 );
628 // Same components, swapped roles — also distinct.
629 assert_ne!(
630 dispatch_mark_pc_key("foo", "bar"),
631 dispatch_mark_pc_key("bar", "foo"),
632 );
633 }
634
635 #[test]
636 fn agent_config_scope_keys_do_not_collide() {
637 // Belt + braces: make sure no pc id starting with "groups." would
638 // be misparsed (or vice versa). The prefixes are distinct because
639 // they each end in `.` and the parent buckets disagree on what
640 // comes after — pcs holds host names, groups holds membership
641 // names — but locking the invariant in a test stops a future
642 // rename from breaking it.
643 assert_ne!(PREFIX_AGENT_CONFIG_GROUPS, PREFIX_AGENT_CONFIG_PCS);
644 assert!(parse_agent_config_group_key("pcs.someone").is_none());
645 assert!(parse_agent_config_pc_key("groups.someone").is_none());
646 assert_eq!(parse_agent_config_group_key("global"), None);
647 assert_eq!(parse_agent_config_pc_key("global"), None);
648 }
649}