Skip to main content

kanade_shared/wire/
server_settings.rs

1use serde::{Deserialize, Serialize};
2
3use crate::config::MailSection;
4
5/// Upper bound on [`ServerSettings::agent_prune_days`] (100 years). A
6/// value this large already means "effectively never", and it keeps the
7/// cleanup task's `now - Duration::days(n)` subtraction comfortably inside
8/// `chrono::DateTime`'s representable range — `DateTime - Duration` panics
9/// on overflow, and an unbounded `u32` (~11.7 M years) would trip it.
10/// Enforced two ways: the PUT handler rejects a larger value, and
11/// [`ServerSettings::effective_agent_prune_days`] clamps to it so even a
12/// hand-written KV value can never panic the cleanup task.
13pub const MAX_AGENT_PRUNE_DAYS: u32 = 36_500;
14
15/// Built-in retention window (days) for the `collections` Object Store —
16/// the file bundles a `collect:` job uploads (#219). This is the value the
17/// bootstrap creates a fresh bucket with, and the fallback
18/// [`ServerSettings::effective_collect_retention_days`] resolves to when the
19/// operator hasn't overridden it. Kept here (not just in `bootstrap`) so the
20/// wire default and the bucket's initial `max_age` share one source of truth.
21pub const DEFAULT_COLLECT_RETENTION_DAYS: u32 = 30;
22
23/// Upper bound on [`ServerSettings::collect_retention_days`] (10 years).
24/// Bundles are debugging / audit artifacts, so an operator has no reason to
25/// keep them longer than this; the ceiling stops a fat-fingered value from
26/// pushing the `collections` Object Store's `max_age` to something absurd.
27/// (The bucket's `max_bytes` cap still bounds actual disk regardless.)
28/// Enforced by the PUT handler and clamped in
29/// [`ServerSettings::effective_collect_retention_days`] so even a
30/// hand-written KV value stays sane.
31pub const MAX_COLLECT_RETENTION_DAYS: u32 = 3650;
32
33/// Value stored in the `server_settings` KV bucket under the single key
34/// [`crate::kv::KEY_SERVER_SETTINGS`]. Operator-editable, backend-side
35/// server configuration that isn't per-agent (so it doesn't belong in
36/// `agent_config`'s layered scopes) and isn't a fleet-wide switch every
37/// agent watches (so it doesn't belong in `fleet_config`). Managed via
38/// the SPA Settings page's "server settings" tab.
39///
40/// Every field is `Option<_>`: `None` (the default / the JSON value
41/// `null` / the field simply absent) means **unset — fall back to the
42/// built-in default** ([`ServerSettings::defaults`]), exactly like the
43/// agent layered-config scopes. The SPA renders the built-in default as a
44/// faint placeholder so a blank field shows what it resolves to, and when
45/// a real default is introduced here it appears in the UI (and takes
46/// effect for already-deployed-but-unset fleets) for free.
47///
48/// `#[serde(default)]` on the container keeps the document backward/forward
49/// compatible: a freshly-created or missing key decodes to all-`None`
50/// (pre-feature behaviour), an older backend reading a newer document
51/// ignores unknown fields, and a newer backend reading an older document
52/// fills the missing field with `None`. Keep that invariant — never add a
53/// field whose `None` doesn't mean "behave as before".
54#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
55#[serde(default)]
56pub struct ServerSettings {
57    /// Days a dead agent (one whose heartbeat stopped arriving) may
58    /// linger in the `agents` registry before the backend cleanup task
59    /// prunes its row.
60    ///
61    /// `None` (unset) falls back to the built-in default; with no default
62    /// configured that resolves to pruning **disabled** (see
63    /// [`ServerSettings::effective_agent_prune_days`]). A positive value
64    /// makes the cleanup sweep delete rows whose `last_heartbeat` is older
65    /// than that many days. The `agents` table is a projection of the
66    /// heartbeat stream, so a machine that's merely offline (not gone)
67    /// reappears on its next heartbeat (~30s cadence); only
68    /// genuinely-retired machines stay gone.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub agent_prune_days: Option<u32>,
71
72    /// Agent group whose members are the trusted **controller-tier**
73    /// runners. A job with `tier: controller` (e.g. a `feed:` job that
74    /// fetches an external URL) is dispatched ONLY to members of this group;
75    /// `None` (unset) means controller-tier jobs run **nowhere** (fail-safe,
76    /// so an external fetch never lands on an employee endpoint by accident).
77    /// See `crate::manifest::Tier`. `None` ⇒ no controller runners
78    /// configured, which is the safe default for a fresh deployment.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub controller_group: Option<String>,
81
82    /// Non-secret SMTP relay settings for outbound email (compliance-alert
83    /// notifications, account setup links, …). Lives here (#884) rather than
84    /// in `backend.toml` so an operator can edit it from the SPA without
85    /// shell access to the host.
86    ///
87    /// `None` (unset) ⇒ no relay configured, email is a no-op — the in-app
88    /// / NATS notification path is unaffected. The SMTP **password is
89    /// deliberately not here** (KV is
90    /// readable over NATS): it stays sourced from the `MailPassword`
91    /// registry secret / `$KANADE_MAIL_PASSWORD` and is combined with these
92    /// settings when the backend builds its `Mailer`. Changes take effect on
93    /// the **next backend restart** (the backend builds the `Mailer` once at
94    /// startup — no live rebuild), which is acceptable per the #884
95    /// discussion.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub mail: Option<MailSection>,
98
99    /// Retention window (days) for collected file bundles — the `collect:`
100    /// job archives uploaded to the `collections` Object Store (#219).
101    ///
102    /// Unlike the other fields, this one has a **real built-in default**
103    /// ([`DEFAULT_COLLECT_RETENTION_DAYS`], 30 d): `None` (unset) falls back
104    /// to it, so a blank field preserves the historical behaviour rather than
105    /// disabling retention. A positive value tells the backend to reconcile
106    /// the Object Store's `max_age` to that many days (see
107    /// `bootstrap::reconcile_collect_retention`), applied at boot and again
108    /// whenever this document is saved from the SPA. Clamped to
109    /// [`MAX_COLLECT_RETENTION_DAYS`]. The bucket's `max_bytes` cap is
110    /// untouched, so extending the window can't make the store grow unbounded.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub collect_retention_days: Option<u32>,
113}
114
115impl ServerSettings {
116    /// Built-in defaults applied when a field is unset (`None`) in the
117    /// stored document. Most fields default to `None` (no fleet-meaningful
118    /// default — a blank prune window means "disabled" rather than some
119    /// arbitrary number of days), the exception being
120    /// [`collect_retention_days`](Self::collect_retention_days), which carries
121    /// a real default ([`DEFAULT_COLLECT_RETENTION_DAYS`]) so a blank field
122    /// preserves the historical 30-day retention.
123    ///
124    /// Exposed via `GET /api/server-settings/defaults` so the SPA renders
125    /// these as faint placeholders (mirroring the agent layered-config
126    /// page's built-in floor). Introducing a real default is a one-line
127    /// change here that automatically shows up in the UI and applies to
128    /// every deployment that hasn't overridden the field.
129    pub fn defaults() -> Self {
130        Self {
131            agent_prune_days: None,
132            controller_group: None,
133            mail: None,
134            collect_retention_days: Some(DEFAULT_COLLECT_RETENTION_DAYS),
135        }
136    }
137
138    /// The configured controller-tier runner group, trimmed, or `None` when
139    /// unset / blank. `None` ⇒ controller-tier jobs run nowhere (fail-safe).
140    pub fn effective_controller_group(&self) -> Option<&str> {
141        self.controller_group
142            .as_deref()
143            .map(str::trim)
144            .filter(|g| !g.is_empty())
145    }
146
147    /// The effective dead-agent prune window in days: the stored value if
148    /// set, else the built-in default, else `0` (= pruning disabled). The
149    /// final `unwrap_or(0)` is the absent-everywhere floor, not a
150    /// user-facing default — the cleanup task treats `0` as "don't prune".
151    ///
152    /// Clamped to [`MAX_AGENT_PRUNE_DAYS`] so the cleanup task's
153    /// `now - Duration::days(n)` can never overflow `DateTime` (and panic
154    /// the task), even if a value larger than the PUT handler allows was
155    /// written to the KV out-of-band.
156    pub fn effective_agent_prune_days(&self) -> u32 {
157        self.agent_prune_days
158            .or(Self::defaults().agent_prune_days)
159            .unwrap_or(0)
160            .min(MAX_AGENT_PRUNE_DAYS)
161    }
162
163    /// The effective collect-bundle retention window in days: the stored
164    /// value if set, else the built-in default ([`DEFAULT_COLLECT_RETENTION_DAYS`]).
165    /// Floored at 1 and clamped to [`MAX_COLLECT_RETENTION_DAYS`] so an
166    /// out-of-band KV write can't reach the Object Store `max_age` unsanitised.
167    /// The floor matters specifically because NATS treats `max_age: 0` as
168    /// **unlimited** retention, not "evict immediately": a stray `0` would
169    /// silently make bundles never expire (defeating the auto-expire intent),
170    /// so we coerce it to the shortest real window (1 day) instead. The PUT
171    /// handler already rejects `0` / over-cap, so this is the belt-and-braces
172    /// path for a hand-edited KV value.
173    pub fn effective_collect_retention_days(&self) -> u32 {
174        self.collect_retention_days
175            .or(Self::defaults().collect_retention_days)
176            .unwrap_or(DEFAULT_COLLECT_RETENTION_DAYS)
177            .clamp(1, MAX_COLLECT_RETENTION_DAYS)
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn default_is_unset() {
187        assert_eq!(ServerSettings::default().agent_prune_days, None);
188    }
189
190    #[test]
191    fn unset_resolves_to_disabled() {
192        // No stored value + no built-in default ⇒ effective 0 (disabled).
193        assert_eq!(ServerSettings::default().effective_agent_prune_days(), 0);
194    }
195
196    #[test]
197    fn stored_value_wins_over_default() {
198        let s = ServerSettings {
199            agent_prune_days: Some(30),
200            ..Default::default()
201        };
202        assert_eq!(s.effective_agent_prune_days(), 30);
203    }
204
205    #[test]
206    fn effective_clamps_to_max() {
207        // An out-of-band KV write larger than the PUT cap must not reach
208        // the cleanup task unclamped (else its DateTime subtraction panics).
209        let s = ServerSettings {
210            agent_prune_days: Some(u32::MAX),
211            ..Default::default()
212        };
213        assert_eq!(s.effective_agent_prune_days(), MAX_AGENT_PRUNE_DAYS);
214    }
215
216    #[test]
217    fn round_trips_through_json() {
218        let s = ServerSettings {
219            agent_prune_days: Some(30),
220            ..Default::default()
221        };
222        let json = serde_json::to_string(&s).unwrap();
223        assert_eq!(json, r#"{"agent_prune_days":30}"#);
224        let back: ServerSettings = serde_json::from_str(&json).unwrap();
225        assert_eq!(back, s);
226    }
227
228    #[test]
229    fn unset_serialises_to_empty_object() {
230        // `skip_serializing_if` keeps an all-unset doc minimal; it must
231        // round-trip back to all-`None`.
232        let s = ServerSettings::default();
233        let json = serde_json::to_string(&s).unwrap();
234        assert_eq!(json, "{}");
235        let back: ServerSettings = serde_json::from_str(&json).unwrap();
236        assert_eq!(back, s);
237    }
238
239    #[test]
240    fn explicit_null_decodes_to_unset() {
241        let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":null}"#).unwrap();
242        assert_eq!(s.agent_prune_days, None);
243    }
244
245    #[test]
246    fn empty_object_decodes_to_default() {
247        // A freshly-created key (or one written by an older backend that
248        // didn't know this field) must read back as the pre-feature
249        // behaviour, not fail to decode.
250        let s: ServerSettings = serde_json::from_str("{}").unwrap();
251        assert_eq!(s, ServerSettings::default());
252    }
253
254    #[test]
255    fn controller_group_effective_trims_and_blank_is_unset() {
256        assert_eq!(ServerSettings::default().effective_controller_group(), None);
257        let s = ServerSettings {
258            controller_group: Some("  feed-runners ".into()),
259            ..Default::default()
260        };
261        assert_eq!(s.effective_controller_group(), Some("feed-runners"));
262        // A blank/whitespace value reads as unset (fail-safe: no runner).
263        let blank = ServerSettings {
264            controller_group: Some("   ".into()),
265            ..Default::default()
266        };
267        assert_eq!(blank.effective_controller_group(), None);
268    }
269
270    #[test]
271    fn controller_group_round_trips_and_omits_when_unset() {
272        let s = ServerSettings {
273            controller_group: Some("infra".into()),
274            ..Default::default()
275        };
276        let json = serde_json::to_string(&s).unwrap();
277        assert_eq!(json, r#"{"controller_group":"infra"}"#);
278        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
279        // Unset controller_group is omitted (skip_serializing_if).
280        assert_eq!(
281            serde_json::to_string(&ServerSettings::default()).unwrap(),
282            "{}"
283        );
284    }
285
286    #[test]
287    fn mail_round_trips_and_omits_when_unset() {
288        use crate::config::{MailEncryption, MailSection};
289
290        // Unset mail is omitted (skip_serializing_if) — a mail-less doc
291        // stays minimal and decodes back to `None`.
292        assert_eq!(
293            serde_json::to_string(&ServerSettings::default()).unwrap(),
294            "{}"
295        );
296
297        let s = ServerSettings {
298            mail: Some(MailSection {
299                host: "smtp.example.com".into(),
300                port: 587,
301                encryption: MailEncryption::Starttls,
302                from: "kanade-noreply@example.com".into(),
303                username: Some("kanade-noreply".into()),
304            }),
305            ..Default::default()
306        };
307        let json = serde_json::to_string(&s).unwrap();
308        // Encryption serialises lowercase; the password is never present.
309        assert!(json.contains(r#""encryption":"starttls""#), "json: {json}");
310        assert!(!json.contains("password"), "password must never serialise");
311        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
312    }
313
314    #[test]
315    fn mail_defaults_to_unset() {
316        assert_eq!(ServerSettings::default().mail, None);
317        // A doc written before this field existed (no `mail` key) decodes
318        // to `None` — email stays a no-op, the pre-feature behaviour.
319        let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":7}"#).unwrap();
320        assert_eq!(s.mail, None);
321        assert_eq!(s.agent_prune_days, Some(7));
322    }
323
324    #[test]
325    fn collect_retention_unset_resolves_to_builtin_default() {
326        // Blank (the derived Default) must preserve the historical 30-day
327        // window, not disable retention.
328        assert_eq!(ServerSettings::default().collect_retention_days, None);
329        assert_eq!(
330            ServerSettings::default().effective_collect_retention_days(),
331            DEFAULT_COLLECT_RETENTION_DAYS,
332        );
333        // The defaults() document surfaces the real default so the SPA can
334        // render it as a placeholder.
335        assert_eq!(
336            ServerSettings::defaults().collect_retention_days,
337            Some(DEFAULT_COLLECT_RETENTION_DAYS),
338        );
339    }
340
341    #[test]
342    fn collect_retention_stored_value_wins() {
343        let s = ServerSettings {
344            collect_retention_days: Some(90),
345            ..Default::default()
346        };
347        assert_eq!(s.effective_collect_retention_days(), 90);
348    }
349
350    #[test]
351    fn collect_retention_effective_clamps_out_of_band_writes() {
352        // A hand-written KV value past the PUT cap (or 0) must be clamped so
353        // the reconciled Object Store max_age stays sane.
354        let big = ServerSettings {
355            collect_retention_days: Some(u32::MAX),
356            ..Default::default()
357        };
358        assert_eq!(
359            big.effective_collect_retention_days(),
360            MAX_COLLECT_RETENTION_DAYS,
361        );
362        let zero = ServerSettings {
363            collect_retention_days: Some(0),
364            ..Default::default()
365        };
366        assert_eq!(zero.effective_collect_retention_days(), 1);
367    }
368
369    #[test]
370    fn collect_retention_round_trips_and_omits_when_unset() {
371        let s = ServerSettings {
372            collect_retention_days: Some(90),
373            ..Default::default()
374        };
375        let json = serde_json::to_string(&s).unwrap();
376        assert_eq!(json, r#"{"collect_retention_days":90}"#);
377        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
378        // Unset is omitted so a blank doc stays minimal.
379        assert!(
380            !serde_json::to_string(&ServerSettings::default())
381                .unwrap()
382                .contains("collect_retention_days")
383        );
384    }
385
386    #[test]
387    fn accepts_unknown_fields_for_forward_compat() {
388        // A newer backend may have added knobs this build doesn't know;
389        // decoding must drop them rather than error.
390        let json = r#"{"agent_prune_days":7,"some_future_knob":true}"#;
391        let s: ServerSettings = serde_json::from_str(json).unwrap();
392        assert_eq!(s.agent_prune_days, Some(7));
393    }
394}