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