Skip to main content

kanade_shared/
bootstrap.rs

1//! Idempotent JetStream bootstrap (Sprint 6.x follow-up).
2//!
3//! Lists every NATS JetStream resource the kanade fleet expects —
4//! streams, KV buckets, Object Stores — and asks the broker to
5//! create-or-update them. v0.25.0 switched from `create_*` to
6//! `create_or_update_*`: the old form returned error 10058 ("name
7//! already in use with a different configuration") when a release
8//! widened a stream's subjects or changed its retention policy on
9//! a broker that still held the older config. With the new form the
10//! broker reconciles its definition to the one in this file, so
11//! version bumps no longer require operator-side data wipes.
12//!
13//! Centralising the list here means a future "we added a new
14//! bucket" change touches one place and both the operator CLI +
15//! the auto-bootstrap path pick it up.
16
17use std::time::Duration;
18
19use anyhow::{Context, Result};
20use async_nats::jetstream::{
21    self,
22    kv::Config as KvConfig,
23    object_store::Config as ObjectStoreConfig,
24    stream::{Config as StreamConfig, DiscardPolicy},
25};
26use tracing::{info, warn};
27
28use crate::kv::{
29    BUCKET_AGENT_CONFIG, BUCKET_AGENT_GROUPS, BUCKET_AGENT_GROUPS_DERIVED, BUCKET_AGENT_META,
30    BUCKET_AGENTS_STATE, BUCKET_FLEET_CONFIG, BUCKET_GROUP_CONTACTS, BUCKET_JOBS, BUCKET_JOBS_YAML,
31    BUCKET_NOTIFICATIONS_READ, BUCKET_SCHEDULES, BUCKET_SCHEDULES_YAML, BUCKET_SCRIPT_CURRENT,
32    BUCKET_SCRIPT_STATUS, BUCKET_SERVER_SETTINGS, OBJECT_AGENT_RELEASES, OBJECT_APP_PACKAGES,
33    OBJECT_COLLECTIONS, OBJECT_RESULT_OUTPUT, OBJECT_SCRIPTS, STREAM_AUDIT, STREAM_EVENTS,
34    STREAM_EXEC, STREAM_INVENTORY, STREAM_NOTIFICATIONS, STREAM_OBS_EVENTS, STREAM_RESULTS,
35};
36use crate::wire::{
37    DEFAULT_AGENT_RELEASES_CAP_MIB, DEFAULT_APP_PACKAGES_CAP_MIB, DEFAULT_COLLECT_RETENTION_DAYS,
38    DEFAULT_COLLECTIONS_CAP_MIB, DEFAULT_RESULT_OUTPUT_CAP_MIB, DEFAULT_SCRIPTS_CAP_MIB,
39};
40
41/// Create-or-update an Object Store, but never let it wedge backend
42/// startup. `create_object_store` neither reconciles an existing
43/// store's config nor has a `create_or_update` form in async-nats
44/// 0.49, so a store whose desired config drifted — e.g. the #518
45/// `max_bytes` cap added after the bucket was first created uncapped,
46/// which the broker then rejects with error 10058 ("stream name
47/// already in use with a different configuration") — would otherwise
48/// fail `ensure_jetstream_resources` and crash the backend on boot
49/// (production outage 2026-06-11). Fall back to the existing store
50/// (uncapped, as it already was) and warn. #506 tracks real
51/// reconciliation of object-store config.
52async fn ensure_object_store(js: &jetstream::Context, cfg: ObjectStoreConfig) -> Result<()> {
53    let name = cfg.bucket.clone();
54    if let Err(e) = js.create_object_store(cfg).await {
55        // The fallback is deliberately broad — any create error is
56        // tolerated AS LONG AS the store already exists, because the
57        // alternative is a wedged backend and "never crash on boot"
58        // wins over "surface this specific error". The expected error
59        // is 10058 (config drift, the incident), but auth/network
60        // blips on an already-bootstrapped broker take this path too;
61        // they remain visible via the `warn!`. Only a genuine
62        // "can't create AND doesn't exist" is fatal.
63        if js.get_object_store(&name).await.is_err() {
64            return Err(e).with_context(|| {
65                format!("create_object_store {name} (and no existing store to fall back to)")
66            });
67        }
68        warn!(
69            store = %name, error = %e,
70            "object store exists with a different config; using it as-is (cap not reconciled)",
71        );
72    }
73    info!(store = %name, "ready");
74    Ok(())
75}
76
77/// Idempotently create every NATS JetStream resource the kanade
78/// fleet relies on. Calling repeatedly is safe — `create_*` returns
79/// the existing resource if it's already configured.
80///
81/// Returns once every resource is in place. The function is async
82/// so backends can `await` it as part of their startup sequence
83/// (one round-trip per resource — ~10 RTTs total).
84pub async fn ensure_jetstream_resources(js: &jetstream::Context) -> Result<()> {
85    // ── Streams ──────────────────────────────────────────────────
86    // #518: every stream carries a `max_bytes` cap with
87    // `Discard::Old` on top of its `max_age` window. Within their
88    // age windows the streams used to be unbounded by size, and
89    // JetStream's file store shares a disk with SQLite on the
90    // backend host — one job printing 200 KB per run fleet-wide
91    // could exhaust the store, at which point EVERY publish fails
92    // (results, obs, audit, KV puts). With the caps, worst-case
93    // degradation is "shorter history on the offending stream"
94    // instead of "broker down".
95    //
96    // Sizing: JetStream RESERVES each `max_bytes` against its
97    // available storage (min of max_file_store and free disk) at
98    // create/update time and fails with error 10047 when the sum
99    // doesn't fit, so these must stay small enough for modest
100    // hosts. That's fine: every stream here is a transport +
101    // replay buffer — the durable record is the backend's SQLite
102    // (results/inventory/obs/audit are all projected within
103    // seconds) — so the caps are runaway-output backstops, not
104    // history budgets. Total reservation ≈ 5.3 GiB including the
105    // result_output object store below.
106    const MIB: i64 = 1024 * 1024;
107    const GIB: i64 = 1024 * MIB;
108
109    // INVENTORY — 90-day rolling history (spec §2.3.1).
110    js.create_or_update_stream(StreamConfig {
111        name: STREAM_INVENTORY.into(),
112        subjects: vec!["inventory.>".into()],
113        max_age: Duration::from_secs(90 * 24 * 60 * 60),
114        max_bytes: GIB,
115        discard: DiscardPolicy::Old,
116        ..Default::default()
117    })
118    .await
119    .with_context(|| format!("create_or_update_stream {STREAM_INVENTORY}"))?;
120    info!(stream = STREAM_INVENTORY, "ready");
121
122    // RESULTS — 30-day rolling history. The biggest producer by
123    // far (every job run on every PC, with up to 256 KB of inline
124    // stdout/stderr per message), so it gets the largest slice of
125    // the disk budget.
126    js.create_or_update_stream(StreamConfig {
127        name: STREAM_RESULTS.into(),
128        subjects: vec!["results.>".into()],
129        max_age: Duration::from_secs(30 * 24 * 60 * 60),
130        max_bytes: 2 * GIB,
131        discard: DiscardPolicy::Old,
132        ..Default::default()
133    })
134    .await
135    .with_context(|| format!("create_or_update_stream {STREAM_RESULTS}"))?;
136    info!(stream = STREAM_RESULTS, "ready");
137
138    // EXEC — latest-per-subject only (spec §2.6 Layer 1). v0.22.1:
139    // catch the existing `commands.{all,group.X,pc.Y}` subjects so a
140    // single backend publish lands in BOTH the agent's live core
141    // subscription AND the stream's retention store. Reconnecting
142    // agents catch up via a durable consumer with
143    // `DeliverPolicy::LastPerSubject` — they receive the most
144    // recent Command per subject they care about, no matter how
145    // long they were offline (within `max_age`).
146    js.create_or_update_stream(StreamConfig {
147        name: STREAM_EXEC.into(),
148        subjects: vec!["commands.>".into()],
149        max_messages_per_subject: 1,
150        max_age: Duration::from_secs(7 * 24 * 60 * 60),
151        // Latest-per-subject keeps this tiny (one Command per
152        // group/pc subject); the cap is a backstop against subject
153        // cardinality bugs, not a working budget.
154        max_bytes: 64 * MIB,
155        discard: DiscardPolicy::Old,
156        ..Default::default()
157    })
158    .await
159    .with_context(|| format!("create_or_update_stream {STREAM_EXEC}"))?;
160    info!(stream = STREAM_EXEC, "ready");
161
162    // EVENTS — short-lived broadcast bus for kill / revoke / etc.
163    // 7-day window matches the EXEC spec window.
164    js.create_or_update_stream(StreamConfig {
165        name: STREAM_EVENTS.into(),
166        subjects: vec!["events.>".into()],
167        max_age: Duration::from_secs(7 * 24 * 60 * 60),
168        max_bytes: 256 * MIB,
169        discard: DiscardPolicy::Old,
170        ..Default::default()
171    })
172    .await
173    .with_context(|| format!("create_or_update_stream {STREAM_EVENTS}"))?;
174    info!(stream = STREAM_EVENTS, "ready");
175
176    // AUDIT — operator-action record (spec §2.3.1). The DURABLE
177    // copy is the backend's SQLite `audit_log` table (the projector
178    // INSERTs each message, idempotently since #501; 365-day
179    // retention since #486) — the stream is transport + replay
180    // buffer, not the archive, so it can be bounded like the rest.
181    // 90 days / 512 MiB is far more than the projector ever lags;
182    // previously this stream had NO limits at all, making it an
183    // unbounded disk leak on the broker host.
184    js.create_or_update_stream(StreamConfig {
185        name: STREAM_AUDIT.into(),
186        subjects: vec!["audit.>".into()],
187        max_age: Duration::from_secs(90 * 24 * 60 * 60),
188        max_bytes: 512 * MIB,
189        discard: DiscardPolicy::Old,
190        ..Default::default()
191    })
192    .await
193    .with_context(|| format!("create_or_update_stream {STREAM_AUDIT}"))?;
194    info!(stream = STREAM_AUDIT, "ready");
195
196    // OBS_EVENTS — per-PC observability timeline (Issue #246). The
197    // 90-day window matches `obs_events` table retention so a
198    // backend bootstrapping after long downtime can catch up but
199    // doesn't carry data the table will discard anyway. Subject
200    // filter `obs.>` catches every PC without a per-PC subscription.
201    //
202    // Days-to-seconds is spelt out once instead of `90 * 24 * 60 *
203    // 60` open-coded across bootstrap + cleanup; the matching prune
204    // window in `kanade-backend::cleanup` quotes the same number
205    // separately (SQLite-relative string syntax there, not a
206    // duration), so it can't share a constant — but a single
207    // arithmetic spell-out here makes the relationship grep-able.
208    const SECS_PER_DAY: u64 = 24 * 60 * 60;
209    const OBS_EVENTS_RETENTION_DAYS: u64 = 90;
210    js.create_or_update_stream(StreamConfig {
211        name: STREAM_OBS_EVENTS.into(),
212        subjects: vec!["obs.>".into()],
213        max_age: Duration::from_secs(OBS_EVENTS_RETENTION_DAYS * SECS_PER_DAY),
214        max_bytes: 512 * MIB,
215        discard: DiscardPolicy::Old,
216        ..Default::default()
217    })
218    .await
219    .with_context(|| format!("create_or_update_stream {STREAM_OBS_EVENTS}"))?;
220    info!(stream = STREAM_OBS_EVENTS, "ready");
221
222    // NOTIFICATIONS — end-user notification history (SPEC §2.3.1 /
223    // Phase E). 90-day window matches INVENTORY: a Client App that
224    // connects after a notification was sent fetches the missed ones
225    // via KLP `notifications.list`. Subject filter `notifications.>`
226    // catches every fan-out target (`all` / `group.X` / `pc.Y`) with
227    // one stream. Retains all messages per subject — each notification
228    // is its own history entry, not a latest-only state like EXEC.
229    // #518: 512 MiB cap + DiscardPolicy::Old, matching the other
230    // 90-day streams (AUDIT / OBS_EVENTS) — notification payloads are
231    // small, so this is generous headroom while still bounding the
232    // broker's disk lease.
233    js.create_or_update_stream(StreamConfig {
234        name: STREAM_NOTIFICATIONS.into(),
235        subjects: vec!["notifications.>".into()],
236        max_age: Duration::from_secs(90 * 24 * 60 * 60),
237        max_bytes: 512 * MIB,
238        discard: DiscardPolicy::Old,
239        ..Default::default()
240    })
241    .await
242    .with_context(|| format!("create_or_update_stream {STREAM_NOTIFICATIONS}"))?;
243    info!(stream = STREAM_NOTIFICATIONS, "ready");
244
245    // ── KV buckets ───────────────────────────────────────────────
246    // script_current — cmd_id → version (spec §2.6 Layer 2).
247    js.create_or_update_key_value(KvConfig {
248        bucket: BUCKET_SCRIPT_CURRENT.into(),
249        history: 5,
250        ..Default::default()
251    })
252    .await
253    .with_context(|| format!("create_or_update_key_value {BUCKET_SCRIPT_CURRENT}"))?;
254    info!(bucket = BUCKET_SCRIPT_CURRENT, "ready");
255
256    // script_status — cmd_id → ACTIVE / REVOKED.
257    js.create_or_update_key_value(KvConfig {
258        bucket: BUCKET_SCRIPT_STATUS.into(),
259        history: 5,
260        ..Default::default()
261    })
262    .await
263    .with_context(|| format!("create_or_update_key_value {BUCKET_SCRIPT_STATUS}"))?;
264    info!(bucket = BUCKET_SCRIPT_STATUS, "ready");
265
266    // agents_state — pc_id → latest hw snapshot (history=1).
267    js.create_or_update_key_value(KvConfig {
268        bucket: BUCKET_AGENTS_STATE.into(),
269        history: 1,
270        ..Default::default()
271    })
272    .await
273    .with_context(|| format!("create_or_update_key_value {BUCKET_AGENTS_STATE}"))?;
274    info!(bucket = BUCKET_AGENTS_STATE, "ready");
275
276    // agent_config — Sprint 6 layered scopes (global / groups.* /
277    // pcs.*) plus the legacy target_version key.
278    // history: 1 — agents only ever read the current value (the watch is
279    // DeliverPolicy::New + an initial_sync get(), never kv.history()).
280    // Retained old revisions only fed reconnect history-replay, which
281    // flapped self-update backward (#828). Operator change-history lives
282    // in the audit log, so keeping one revision loses nothing. (#830)
283    js.create_or_update_key_value(KvConfig {
284        bucket: BUCKET_AGENT_CONFIG.into(),
285        history: 1,
286        ..Default::default()
287    })
288    .await
289    .with_context(|| format!("create_or_update_key_value {BUCKET_AGENT_CONFIG}"))?;
290    info!(bucket = BUCKET_AGENT_CONFIG, "ready");
291
292    // agent_groups — Sprint 5 per-pc group membership.
293    // history: 1 — same reasoning as agent_config above: agents only need
294    // the current membership; replayed history just churned subscriptions
295    // through stale sets on every reconnect (a transient wrong membership,
296    // #830). One revision makes that replay material non-existent. (#830)
297    js.create_or_update_key_value(KvConfig {
298        bucket: BUCKET_AGENT_GROUPS.into(),
299        history: 1,
300        ..Default::default()
301    })
302    .await
303    .with_context(|| format!("create_or_update_key_value {BUCKET_AGENT_GROUPS}"))?;
304    info!(bucket = BUCKET_AGENT_GROUPS, "ready");
305
306    // agent_groups_derived — #1032①: per-pc DERIVED group membership written by
307    // the backend group-materializer (resolved from GroupDefs). Agents watch it
308    // alongside agent_groups and union. history: 1 for the same reason as
309    // agent_groups (#830 — agents read only the current value; replayed history
310    // churns subscriptions). Separate bucket so the materializer (sole writer
311    // here) never clobbers operator-set membership in agent_groups.
312    js.create_or_update_key_value(KvConfig {
313        bucket: BUCKET_AGENT_GROUPS_DERIVED.into(),
314        history: 1,
315        ..Default::default()
316    })
317    .await
318    .with_context(|| format!("create_or_update_key_value {BUCKET_AGENT_GROUPS_DERIVED}"))?;
319    info!(bucket = BUCKET_AGENT_GROUPS_DERIVED, "ready");
320
321    // agent_meta — per-PC operator-managed key/value annotations
322    // (edited via the SPA agent detail page / the `kanade meta` CLI, and
323    // typically bulk-populated by an operator AD-sync job). history: 5 —
324    // a few revisions of operator edit history, like group_contacts;
325    // nothing replays it, so the exact depth is not load-bearing.
326    js.create_or_update_key_value(KvConfig {
327        bucket: BUCKET_AGENT_META.into(),
328        history: 5,
329        ..Default::default()
330    })
331    .await
332    .with_context(|| format!("create_or_update_key_value {BUCKET_AGENT_META}"))?;
333    info!(bucket = BUCKET_AGENT_META, "ready");
334
335    // group_contacts — per-group notification email addresses
336    // (operator-managed via the SPA Groups page).
337    js.create_or_update_key_value(KvConfig {
338        bucket: BUCKET_GROUP_CONTACTS.into(),
339        history: 5,
340        ..Default::default()
341    })
342    .await
343    .with_context(|| format!("create_or_update_key_value {BUCKET_GROUP_CONTACTS}"))?;
344    info!(bucket = BUCKET_GROUP_CONTACTS, "ready");
345
346    // schedules — admin-API CRUD'd cron table (spec §2.5.3).
347    // Backend's scheduler.rs also creates this on startup; calling
348    // twice is harmless.
349    js.create_or_update_key_value(KvConfig {
350        bucket: BUCKET_SCHEDULES.into(),
351        history: 5,
352        ..Default::default()
353    })
354    .await
355    .with_context(|| format!("create_or_update_key_value {BUCKET_SCHEDULES}"))?;
356    info!(bucket = BUCKET_SCHEDULES, "ready");
357
358    // jobs — v0.15 operator-registered Manifest catalog. Schedules
359    // reference rows here by id; editing a job rewrites what future
360    // schedule fires exec.
361    js.create_or_update_key_value(KvConfig {
362        bucket: BUCKET_JOBS.into(),
363        history: 5,
364        ..Default::default()
365    })
366    .await
367    .with_context(|| format!("create_or_update_key_value {BUCKET_JOBS}"))?;
368    info!(bucket = BUCKET_JOBS, "ready");
369
370    // fleet_config — #418 Phase 5 fleet-wide singletons (the global
371    // change-freeze under KEY_FREEZE). history: 1 — only the current
372    // state matters; both schedulers watch it.
373    js.create_or_update_key_value(KvConfig {
374        bucket: BUCKET_FLEET_CONFIG.into(),
375        history: 1,
376        ..Default::default()
377    })
378    .await
379    .with_context(|| format!("create_or_update_key_value {BUCKET_FLEET_CONFIG}"))?;
380    info!(bucket = BUCKET_FLEET_CONFIG, "ready");
381
382    // server_settings — backend-side operator-editable settings (SPA
383    // Settings page "server settings" tab). A single JSON document under
384    // KEY_SERVER_SETTINGS; history: 1 since only the current state
385    // matters. First consumer is the cleanup task's dead-agent prune
386    // window.
387    js.create_or_update_key_value(KvConfig {
388        bucket: BUCKET_SERVER_SETTINGS.into(),
389        history: 1,
390        ..Default::default()
391    })
392    .await
393    .with_context(|| format!("create_or_update_key_value {BUCKET_SERVER_SETTINGS}"))?;
394    info!(bucket = BUCKET_SERVER_SETTINGS, "ready");
395
396    // notifications_read — per-(pc, user, notification) read/ack state
397    // (SPEC §2.3.2 / Phase E). The agent writes here on KLP
398    // `notifications.ack`; `notifications.list` reads it back to filter
399    // the unread bucket. history: 1 — only the latest ack per key
400    // matters.
401    js.create_or_update_key_value(KvConfig {
402        bucket: BUCKET_NOTIFICATIONS_READ.into(),
403        history: 1,
404        ..Default::default()
405    })
406    .await
407    .with_context(|| format!("create_or_update_key_value {BUCKET_NOTIFICATIONS_READ}"))?;
408    info!(bucket = BUCKET_NOTIFICATIONS_READ, "ready");
409
410    // jobs_yaml / schedules_yaml — operator source-of-truth YAML
411    // alongside the JSON catalogs above. Same key shape (manifest id
412    // / schedule id), but the value is the raw YAML bytes so the
413    // SPA's YAML editor preserves comments + script block-scalar
414    // indentation across edits. Agents/scheduler don't read these.
415    js.create_or_update_key_value(KvConfig {
416        bucket: BUCKET_JOBS_YAML.into(),
417        history: 5,
418        ..Default::default()
419    })
420    .await
421    .with_context(|| format!("create_or_update_key_value {BUCKET_JOBS_YAML}"))?;
422    info!(bucket = BUCKET_JOBS_YAML, "ready");
423
424    js.create_or_update_key_value(KvConfig {
425        bucket: BUCKET_SCHEDULES_YAML.into(),
426        history: 5,
427        ..Default::default()
428    })
429    .await
430    .with_context(|| format!("create_or_update_key_value {BUCKET_SCHEDULES_YAML}"))?;
431    info!(bucket = BUCKET_SCHEDULES_YAML, "ready");
432
433    // ── Object Store ─────────────────────────────────────────────
434    // #1247: every bucket is born with a `max_bytes` cap. The first
435    // three had none at all (unbounded disk leak); result_output /
436    // collections had caps in code since #518, but pre-existing
437    // buckets never received them (create-drift tolerated by
438    // `ensure_object_store`) — the backend's boot reconcile
439    // (`reconcile_object_store_max_bytes` driven by
440    // `ServerSettings::object_store_caps`) is what delivers these to
441    // a live broker. The shared DEFAULT_*_CAP_MIB constants keep the
442    // fresh-bucket value and the SPA-visible default in one place.
443    // agent_releases — one object per version, raw exe bytes.
444    ensure_object_store(
445        js,
446        ObjectStoreConfig {
447            bucket: OBJECT_AGENT_RELEASES.into(),
448            max_bytes: DEFAULT_AGENT_RELEASES_CAP_MIB as i64 * MIB,
449            ..Default::default()
450        },
451    )
452    .await?;
453
454    // app_packages — generic operator-uploaded binary distribution
455    // (kanade-client today; third-party installers like Webex /
456    // Teams once those flows land). Object keys are
457    // `<name>/<version>`; see `kanade-shared::kv::OBJECT_APP_PACKAGES`
458    // for the full rationale.
459    ensure_object_store(
460        js,
461        ObjectStoreConfig {
462            bucket: OBJECT_APP_PACKAGES.into(),
463            max_bytes: DEFAULT_APP_PACKAGES_CAP_MIB as i64 * MIB,
464            ..Default::default()
465        },
466    )
467    .await?;
468
469    // scripts — manifest script bodies referenced by
470    // `Execute::script_object` (SPEC §2.4.1). Sibling of
471    // `app_packages`; see `kanade-shared::kv::OBJECT_SCRIPTS` for
472    // the bucket-split rationale (smaller payloads + manifest-
473    // coupled lifecycle vs operator-curated installers).
474    ensure_object_store(
475        js,
476        ObjectStoreConfig {
477            bucket: OBJECT_SCRIPTS.into(),
478            max_bytes: DEFAULT_SCRIPTS_CAP_MIB as i64 * MIB,
479            ..Default::default()
480        },
481    )
482    .await?;
483
484    // result_output — overflow stdout / stderr blobs for the
485    // `ExecResult` wire kind (#227). Anything larger than the agent's
486    // 256 KB inline threshold gets uploaded here under
487    // `<request_id>/{stdout,stderr}`; the backend's results
488    // projector derefs the pointer fields before INSERT so SQLite
489    // + the SPA see the full text inline. 30-day max_age matches
490    // STREAM_RESULTS so the lifetimes stay in lockstep — a row still
491    // resolvable in execution_results never points at a missing
492    // blob.
493    // #518: capped like the streams — a job whose output overflows
494    // the inline threshold writes blobs HERE instead of
495    // STREAM_RESULTS, so without its own cap this store bypasses
496    // the stream budget entirely and can still fill the file store.
497    // The projector derefs blobs within seconds of publish, so
498    // eviction only ever hits already-projected (or expired)
499    // output.
500    ensure_object_store(
501        js,
502        ObjectStoreConfig {
503            bucket: OBJECT_RESULT_OUTPUT.into(),
504            max_age: Duration::from_secs(SECS_PER_DAY * 30),
505            max_bytes: DEFAULT_RESULT_OUTPUT_CAP_MIB as i64 * MIB,
506            ..Default::default()
507        },
508    )
509    .await?;
510
511    // #219: collected file bundles. A `collect:` job's agent zips the
512    // script's listed files and uploads the archive here under
513    // `<pc_id>/<job_id>/<rfc3339>.zip`; the SPA Collect page lists /
514    // downloads them. Default max_age = DEFAULT_COLLECT_RETENTION_DAYS —
515    // bundles are debugging / audit artifacts (not curated config like
516    // app_packages / scripts), so they auto-expire and the bucket doesn't
517    // grow unbounded. Capped at 5 GiB (DiscardPolicy::Old evicts oldest
518    // first) so a fleet's worth of bundles can't fill the file store.
519    //
520    // This is only the value a FRESH bucket is born with; the window is
521    // operator-tunable from the SPA (`ServerSettings::collect_retention_days`)
522    // and the backend reconciles the live bucket's max_age to the configured
523    // value at boot and on save — see [`reconcile_collect_retention`].
524    ensure_object_store(
525        js,
526        ObjectStoreConfig {
527            bucket: OBJECT_COLLECTIONS.into(),
528            max_age: Duration::from_secs(SECS_PER_DAY * DEFAULT_COLLECT_RETENTION_DAYS as u64),
529            max_bytes: DEFAULT_COLLECTIONS_CAP_MIB as i64 * MIB,
530            ..Default::default()
531        },
532    )
533    .await?;
534
535    Ok(())
536}
537
538/// NATS names the stream backing an Object Store `OBJ_<bucket>` (mirroring
539/// `KV_<bucket>` for key-value stores). We reconcile the collect bucket's
540/// retention through this stream because async-nats 0.49 has no
541/// create-or-update / reconcile form for Object Stores themselves (the same
542/// gap [`ensure_object_store`] works around) — but the underlying stream
543/// *does* support `update_stream`.
544fn object_store_stream_name(bucket: &str) -> String {
545    format!("OBJ_{bucket}")
546}
547
548/// Reconcile the `collections` Object Store's retention window to
549/// `retention_days` by updating the `max_age` on its backing stream.
550///
551/// Why this exists: the bucket is created once (at bootstrap) with the
552/// built-in default, and `create_object_store` neither has a
553/// create-or-update form nor reconciles config in async-nats 0.49. So to
554/// honour an operator's `ServerSettings::collect_retention_days` change on an
555/// already-provisioned bucket, we read the backing stream's config, patch
556/// **only** `max_age` (a read-modify-write that leaves every object-store-
557/// specific stream setting untouched), and `update_stream`. `max_bytes`
558/// and the discard policy are deliberately left as-is, so extending the
559/// window never lifts the 5 GiB disk ceiling.
560///
561/// Idempotent: if the stream's `max_age` already matches, it's a no-op
562/// (skips the update round-trip and returns `false`). A missing stream (the
563/// bucket was never provisioned — e.g. a broker that predates this feature
564/// and hasn't run bootstrap) is a soft error the caller can log-and-continue:
565/// bootstrap runs before this on the backend boot path, so in practice the
566/// stream is always present.
567///
568/// Returns `Ok(true)` when it actually changed the stream, `Ok(false)` when
569/// already in sync.
570pub async fn reconcile_collect_retention(
571    js: &jetstream::Context,
572    retention_days: u32,
573) -> Result<bool> {
574    const SECS_PER_DAY: u64 = 24 * 60 * 60;
575    let desired = Duration::from_secs(SECS_PER_DAY * retention_days as u64);
576    let stream_name = object_store_stream_name(OBJECT_COLLECTIONS);
577
578    let mut stream = js
579        .get_stream(&stream_name)
580        .await
581        .with_context(|| format!("get_stream {stream_name} for collect-retention reconcile"))?;
582    let info = stream
583        .info()
584        .await
585        .with_context(|| format!("stream info {stream_name}"))?;
586    if info.config.max_age == desired {
587        return Ok(false);
588    }
589    let mut cfg = info.config.clone();
590    cfg.max_age = desired;
591    js.update_stream(cfg)
592        .await
593        .with_context(|| format!("update_stream {stream_name} max_age"))?;
594    info!(
595        stream = %stream_name,
596        retention_days,
597        "collect retention: reconciled Object Store max_age",
598    );
599    Ok(true)
600}
601
602/// Reconcile one Object Store's disk cap to `cap_mib` by updating
603/// `max_bytes` on its backing `OBJ_<bucket>` stream (#1247).
604///
605/// Same shape as [`reconcile_collect_retention`] (read-modify-write
606/// patching ONLY the one field, so object-store-specific stream
607/// settings and the `max_age` window stay untouched), but for the
608/// field that bounds disk. This is what delivers caps to buckets
609/// created BEFORE the cap existed — `ensure_object_store` deliberately
610/// tolerates the 10058 config-drift error instead of reconciling, so
611/// without this a live bucket keeps whatever it was born with forever
612/// (how `OBJ_result_output` reached 6.76 GB against a nominal 1 GiB).
613///
614/// Idempotent: returns `Ok(false)` when already in sync. A missing
615/// stream is a soft error the caller logs and continues past —
616/// bootstrap runs before this on the boot path. Eviction is the
617/// broker's job once the cap lands (`DiscardPolicy::Old`, the object
618/// store default); shrink-to-fit is not instantaneous but needs no
619/// operator action.
620pub async fn reconcile_object_store_max_bytes(
621    js: &jetstream::Context,
622    bucket: &str,
623    cap_mib: u32,
624) -> Result<bool> {
625    const MIB: i64 = 1024 * 1024;
626    let desired = cap_mib as i64 * MIB;
627    let stream_name = object_store_stream_name(bucket);
628
629    let mut stream = js
630        .get_stream(&stream_name)
631        .await
632        .with_context(|| format!("get_stream {stream_name} for max_bytes reconcile"))?;
633    let info = stream
634        .info()
635        .await
636        .with_context(|| format!("stream info {stream_name}"))?;
637    if info.config.max_bytes == desired {
638        return Ok(false);
639    }
640    let mut cfg = info.config.clone();
641    cfg.max_bytes = desired;
642    js.update_stream(cfg)
643        .await
644        .with_context(|| format!("update_stream {stream_name} max_bytes"))?;
645    info!(
646        stream = %stream_name,
647        cap_mib,
648        "object store: reconciled max_bytes",
649    );
650    Ok(true)
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use std::process::Stdio;
657
658    /// Throwaway `nats-server -js` on a random port, like the
659    /// kv_cas_live / offline_boot harnesses. Ignored tests only.
660    struct Broker {
661        js: jetstream::Context,
662        _server: tokio::process::Child,
663        _storage: tempfile::TempDir,
664    }
665
666    async fn spawn_broker() -> Broker {
667        let port = portpicker::pick_unused_port().expect("pick port");
668        let storage = tempfile::TempDir::new().expect("storage tempdir");
669        let server = tokio::process::Command::new("nats-server")
670            .arg("-js")
671            .arg("-p")
672            .arg(port.to_string())
673            .arg("-sd")
674            .arg(storage.path())
675            .stdout(Stdio::null())
676            .stderr(Stdio::null())
677            .kill_on_drop(true)
678            .spawn()
679            .expect("spawn nats-server (is it in PATH?)");
680        let url = format!("nats://127.0.0.1:{port}");
681        let mut client = None;
682        for _ in 0..50 {
683            if let Ok(c) = async_nats::connect(&url).await {
684                client = Some(c);
685                break;
686            }
687            tokio::time::sleep(Duration::from_millis(100)).await;
688        }
689        Broker {
690            js: jetstream::new(client.expect("nats-server did not come up in 5s")),
691            _server: server,
692            _storage: storage,
693        }
694    }
695
696    /// #506 / 2026-06-11 incident: `create_object_store` neither
697    /// reconciles config nor has a create-or-update form, so adding
698    /// the #518 `max_bytes` cap to a store first created uncapped made
699    /// the broker reject the create (error 10058 "name already in use
700    /// with a different configuration") and crashed the backend on
701    /// boot. `ensure_object_store` must instead accept the existing
702    /// store and let startup proceed.
703    #[tokio::test]
704    #[ignore = "requires nats-server in PATH; cargo test -- --ignored"]
705    async fn ensure_object_store_accepts_config_drift() {
706        let b = spawn_broker().await;
707        // First create: uncapped, as the pre-#518 backend did.
708        ensure_object_store(
709            &b.js,
710            ObjectStoreConfig {
711                bucket: "result_output".into(),
712                ..Default::default()
713            },
714        )
715        .await
716        .expect("fresh create");
717
718        // Second create with a conflicting config (now capped) must
719        // NOT error — it accepts the existing store.
720        ensure_object_store(
721            &b.js,
722            ObjectStoreConfig {
723                bucket: "result_output".into(),
724                max_bytes: 1024 * 1024 * 1024,
725                ..Default::default()
726            },
727        )
728        .await
729        .expect("config drift must not wedge startup");
730
731        // The store is still usable.
732        let store = b.js.get_object_store("result_output").await.expect("store");
733        store
734            .put("k", &mut &b"hi"[..])
735            .await
736            .expect("put after drift");
737    }
738
739    /// A fresh create with a cap succeeds on a broker with room (the
740    /// normal first-boot path).
741    #[tokio::test]
742    #[ignore = "requires nats-server in PATH; cargo test -- --ignored"]
743    async fn ensure_object_store_fresh_create_with_cap() {
744        let b = spawn_broker().await;
745        ensure_object_store(
746            &b.js,
747            ObjectStoreConfig {
748                bucket: "fresh".into(),
749                max_bytes: 64 * 1024 * 1024,
750                ..Default::default()
751            },
752        )
753        .await
754        .expect("fresh capped create");
755        b.js.get_object_store("fresh").await.expect("exists");
756    }
757
758    /// The fatal path: when create fails for a store that ALSO does
759    /// not exist, the error must propagate (we only swallow errors we
760    /// can fall back from). An invalid bucket name fails create's
761    /// charset validation and never creates a store to fall back to.
762    #[tokio::test]
763    #[ignore = "requires nats-server in PATH; cargo test -- --ignored"]
764    async fn ensure_object_store_propagates_when_no_fallback() {
765        let b = spawn_broker().await;
766        let err = ensure_object_store(
767            &b.js,
768            ObjectStoreConfig {
769                // Spaces / '!' are rejected by the object-store name
770                // rules, so create fails and get also finds nothing.
771                bucket: "bad name!".into(),
772                ..Default::default()
773            },
774        )
775        .await
776        .expect_err("a create failure with no existing store must be fatal");
777        assert!(
778            err.to_string()
779                .contains("no existing store to fall back to"),
780            "unexpected error: {err:#}",
781        );
782    }
783
784    /// `reconcile_collect_retention` must change the live bucket's `max_age`
785    /// (broker-side retention) without disturbing the other stream config —
786    /// the mechanism the SPA relies on to extend collect retention past the
787    /// 30-day default. Also asserts the idempotent no-op path (`Ok(false)`
788    /// when already in sync) and that `max_bytes` survives the update.
789    #[tokio::test]
790    #[ignore = "requires nats-server in PATH; cargo test -- --ignored"]
791    async fn reconcile_collect_retention_updates_max_age() {
792        use crate::kv::OBJECT_COLLECTIONS;
793        const SECS_PER_DAY: u64 = 24 * 60 * 60;
794        let b = spawn_broker().await;
795
796        // Provision the collections bucket the way bootstrap does: 30-day
797        // default max_age, 5 GiB cap.
798        ensure_object_store(
799            &b.js,
800            ObjectStoreConfig {
801                bucket: OBJECT_COLLECTIONS.into(),
802                max_age: Duration::from_secs(SECS_PER_DAY * 30),
803                max_bytes: 5 * 1024 * 1024 * 1024,
804                ..Default::default()
805            },
806        )
807        .await
808        .expect("fresh collections bucket");
809
810        let stream_name = object_store_stream_name(OBJECT_COLLECTIONS);
811
812        // Extend to 90 days — first call changes the stream.
813        assert!(
814            reconcile_collect_retention(&b.js, 90)
815                .await
816                .expect("reconcile to 90d"),
817            "first reconcile should report a change",
818        );
819        let mut stream = b.js.get_stream(&stream_name).await.expect("stream");
820        let info = stream.info().await.expect("info");
821        assert_eq!(
822            info.config.max_age,
823            Duration::from_secs(SECS_PER_DAY * 90),
824            "max_age must be extended to 90 days",
825        );
826        assert_eq!(
827            info.config.max_bytes,
828            5 * 1024 * 1024 * 1024,
829            "the size cap must survive the max_age-only update",
830        );
831
832        // Re-applying the same value is a no-op (no revision-bumping update).
833        assert!(
834            !reconcile_collect_retention(&b.js, 90)
835                .await
836                .expect("idempotent reconcile"),
837            "second reconcile with the same value should be a no-op",
838        );
839    }
840
841    /// `reconcile_object_store_max_bytes` must deliver a cap to a bucket
842    /// that was provisioned WITHOUT one — the exact drift case #1247
843    /// fixes (`OBJ_result_output` at 6.76 GB against a nominal 1 GiB,
844    /// because a cap added in code after the bucket existed never reached
845    /// the broker). Asserts the backing stream's `max_bytes` changes, the
846    /// `max_age` window survives the max_bytes-only update, and the
847    /// idempotent no-op path. Mirrors
848    /// [`reconcile_collect_retention_updates_max_age`].
849    #[tokio::test]
850    #[ignore = "requires nats-server in PATH; cargo test -- --ignored"]
851    async fn reconcile_object_store_max_bytes_updates_cap() {
852        use crate::kv::OBJECT_RESULT_OUTPUT;
853        const SECS_PER_DAY: u64 = 24 * 60 * 60;
854        let b = spawn_broker().await;
855
856        // Provision the way the pre-#518 bucket was born: a 30-day window
857        // but NO size cap (the drifted production state).
858        ensure_object_store(
859            &b.js,
860            ObjectStoreConfig {
861                bucket: OBJECT_RESULT_OUTPUT.into(),
862                max_age: Duration::from_secs(SECS_PER_DAY * 30),
863                ..Default::default()
864            },
865        )
866        .await
867        .expect("fresh uncapped result_output bucket");
868
869        let stream_name = object_store_stream_name(OBJECT_RESULT_OUTPUT);
870
871        // Apply the 1 GiB cap — first call changes the stream.
872        assert!(
873            reconcile_object_store_max_bytes(&b.js, OBJECT_RESULT_OUTPUT, 1024)
874                .await
875                .expect("reconcile to 1024 MiB"),
876            "first reconcile should report a change",
877        );
878        let mut stream = b.js.get_stream(&stream_name).await.expect("stream");
879        let info = stream.info().await.expect("info");
880        assert_eq!(
881            info.config.max_bytes,
882            1024 * 1024 * 1024,
883            "max_bytes must land on the previously-uncapped stream",
884        );
885        assert_eq!(
886            info.config.max_age,
887            Duration::from_secs(SECS_PER_DAY * 30),
888            "the retention window must survive the max_bytes-only update",
889        );
890
891        // Re-applying the same value is a no-op (no revision-bumping update).
892        assert!(
893            !reconcile_object_store_max_bytes(&b.js, OBJECT_RESULT_OUTPUT, 1024)
894                .await
895                .expect("idempotent reconcile"),
896            "second reconcile with the same value should be a no-op",
897        );
898
899        // A different value reconciles again (operator tune-down/tune-up).
900        assert!(
901            reconcile_object_store_max_bytes(&b.js, OBJECT_RESULT_OUTPUT, 2048)
902                .await
903                .expect("reconcile to 2048 MiB"),
904            "changing the cap should report a change",
905        );
906    }
907}