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/// Value stored in the `server_settings` KV bucket under the single key
16/// [`crate::kv::KEY_SERVER_SETTINGS`]. Operator-editable, backend-side
17/// server configuration that isn't per-agent (so it doesn't belong in
18/// `agent_config`'s layered scopes) and isn't a fleet-wide switch every
19/// agent watches (so it doesn't belong in `fleet_config`). Managed via
20/// the SPA Settings page's "server settings" tab.
21///
22/// Every field is `Option<_>`: `None` (the default / the JSON value
23/// `null` / the field simply absent) means **unset — fall back to the
24/// built-in default** ([`ServerSettings::defaults`]), exactly like the
25/// agent layered-config scopes. The SPA renders the built-in default as a
26/// faint placeholder so a blank field shows what it resolves to, and when
27/// a real default is introduced here it appears in the UI (and takes
28/// effect for already-deployed-but-unset fleets) for free.
29///
30/// `#[serde(default)]` on the container keeps the document backward/forward
31/// compatible: a freshly-created or missing key decodes to all-`None`
32/// (pre-feature behaviour), an older backend reading a newer document
33/// ignores unknown fields, and a newer backend reading an older document
34/// fills the missing field with `None`. Keep that invariant — never add a
35/// field whose `None` doesn't mean "behave as before".
36#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
37#[serde(default)]
38pub struct ServerSettings {
39    /// Days a dead agent (one whose heartbeat stopped arriving) may
40    /// linger in the `agents` registry before the backend cleanup task
41    /// prunes its row.
42    ///
43    /// `None` (unset) falls back to the built-in default; with no default
44    /// configured that resolves to pruning **disabled** (see
45    /// [`ServerSettings::effective_agent_prune_days`]). A positive value
46    /// makes the cleanup sweep delete rows whose `last_heartbeat` is older
47    /// than that many days. The `agents` table is a projection of the
48    /// heartbeat stream, so a machine that's merely offline (not gone)
49    /// reappears on its next heartbeat (~30s cadence); only
50    /// genuinely-retired machines stay gone.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub agent_prune_days: Option<u32>,
53
54    /// Agent group whose members are the trusted **controller-tier**
55    /// runners. A job with `tier: controller` (e.g. a `feed:` job that
56    /// fetches an external URL) is dispatched ONLY to members of this group;
57    /// `None` (unset) means controller-tier jobs run **nowhere** (fail-safe,
58    /// so an external fetch never lands on an employee endpoint by accident).
59    /// See `crate::manifest::Tier`. `None` ⇒ no controller runners
60    /// configured, which is the safe default for a fresh deployment.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub controller_group: Option<String>,
63
64    /// Non-secret SMTP relay settings for outbound email (compliance-alert
65    /// notifications, account setup links, …). Lives here (#884) rather than
66    /// in `backend.toml` so an operator can edit it from the SPA without
67    /// shell access to the host.
68    ///
69    /// `None` (unset) ⇒ no relay configured, email is a no-op — the in-app
70    /// / NATS notification path is unaffected. The SMTP **password is
71    /// deliberately not here** (KV is
72    /// readable over NATS): it stays sourced from the `MailPassword`
73    /// registry secret / `$KANADE_MAIL_PASSWORD` and is combined with these
74    /// settings when the backend builds its `Mailer`. Changes take effect on
75    /// the **next backend restart** (the backend builds the `Mailer` once at
76    /// startup — no live rebuild), which is acceptable per the #884
77    /// discussion.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub mail: Option<MailSection>,
80}
81
82impl ServerSettings {
83    /// Built-in defaults applied when a field is unset (`None`) in the
84    /// stored document. Currently every field is `None`: there's no
85    /// fleet-meaningful default prune window, so leaving it blank means
86    /// "disabled" rather than some arbitrary number of days.
87    ///
88    /// Exposed via `GET /api/server-settings/defaults` so the SPA renders
89    /// these as faint placeholders (mirroring the agent layered-config
90    /// page's built-in floor). Introducing a real default later is a
91    /// one-line change here that automatically shows up in the UI and
92    /// applies to every deployment that hasn't overridden the field.
93    pub fn defaults() -> Self {
94        Self {
95            agent_prune_days: None,
96            controller_group: None,
97            mail: None,
98        }
99    }
100
101    /// The configured controller-tier runner group, trimmed, or `None` when
102    /// unset / blank. `None` ⇒ controller-tier jobs run nowhere (fail-safe).
103    pub fn effective_controller_group(&self) -> Option<&str> {
104        self.controller_group
105            .as_deref()
106            .map(str::trim)
107            .filter(|g| !g.is_empty())
108    }
109
110    /// The effective dead-agent prune window in days: the stored value if
111    /// set, else the built-in default, else `0` (= pruning disabled). The
112    /// final `unwrap_or(0)` is the absent-everywhere floor, not a
113    /// user-facing default — the cleanup task treats `0` as "don't prune".
114    ///
115    /// Clamped to [`MAX_AGENT_PRUNE_DAYS`] so the cleanup task's
116    /// `now - Duration::days(n)` can never overflow `DateTime` (and panic
117    /// the task), even if a value larger than the PUT handler allows was
118    /// written to the KV out-of-band.
119    pub fn effective_agent_prune_days(&self) -> u32 {
120        self.agent_prune_days
121            .or(Self::defaults().agent_prune_days)
122            .unwrap_or(0)
123            .min(MAX_AGENT_PRUNE_DAYS)
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn default_is_unset() {
133        assert_eq!(ServerSettings::default().agent_prune_days, None);
134    }
135
136    #[test]
137    fn unset_resolves_to_disabled() {
138        // No stored value + no built-in default ⇒ effective 0 (disabled).
139        assert_eq!(ServerSettings::default().effective_agent_prune_days(), 0);
140    }
141
142    #[test]
143    fn stored_value_wins_over_default() {
144        let s = ServerSettings {
145            agent_prune_days: Some(30),
146            ..Default::default()
147        };
148        assert_eq!(s.effective_agent_prune_days(), 30);
149    }
150
151    #[test]
152    fn effective_clamps_to_max() {
153        // An out-of-band KV write larger than the PUT cap must not reach
154        // the cleanup task unclamped (else its DateTime subtraction panics).
155        let s = ServerSettings {
156            agent_prune_days: Some(u32::MAX),
157            ..Default::default()
158        };
159        assert_eq!(s.effective_agent_prune_days(), MAX_AGENT_PRUNE_DAYS);
160    }
161
162    #[test]
163    fn round_trips_through_json() {
164        let s = ServerSettings {
165            agent_prune_days: Some(30),
166            ..Default::default()
167        };
168        let json = serde_json::to_string(&s).unwrap();
169        assert_eq!(json, r#"{"agent_prune_days":30}"#);
170        let back: ServerSettings = serde_json::from_str(&json).unwrap();
171        assert_eq!(back, s);
172    }
173
174    #[test]
175    fn unset_serialises_to_empty_object() {
176        // `skip_serializing_if` keeps an all-unset doc minimal; it must
177        // round-trip back to all-`None`.
178        let s = ServerSettings::default();
179        let json = serde_json::to_string(&s).unwrap();
180        assert_eq!(json, "{}");
181        let back: ServerSettings = serde_json::from_str(&json).unwrap();
182        assert_eq!(back, s);
183    }
184
185    #[test]
186    fn explicit_null_decodes_to_unset() {
187        let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":null}"#).unwrap();
188        assert_eq!(s.agent_prune_days, None);
189    }
190
191    #[test]
192    fn empty_object_decodes_to_default() {
193        // A freshly-created key (or one written by an older backend that
194        // didn't know this field) must read back as the pre-feature
195        // behaviour, not fail to decode.
196        let s: ServerSettings = serde_json::from_str("{}").unwrap();
197        assert_eq!(s, ServerSettings::default());
198    }
199
200    #[test]
201    fn controller_group_effective_trims_and_blank_is_unset() {
202        assert_eq!(ServerSettings::default().effective_controller_group(), None);
203        let s = ServerSettings {
204            controller_group: Some("  feed-runners ".into()),
205            ..Default::default()
206        };
207        assert_eq!(s.effective_controller_group(), Some("feed-runners"));
208        // A blank/whitespace value reads as unset (fail-safe: no runner).
209        let blank = ServerSettings {
210            controller_group: Some("   ".into()),
211            ..Default::default()
212        };
213        assert_eq!(blank.effective_controller_group(), None);
214    }
215
216    #[test]
217    fn controller_group_round_trips_and_omits_when_unset() {
218        let s = ServerSettings {
219            controller_group: Some("infra".into()),
220            ..Default::default()
221        };
222        let json = serde_json::to_string(&s).unwrap();
223        assert_eq!(json, r#"{"controller_group":"infra"}"#);
224        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
225        // Unset controller_group is omitted (skip_serializing_if).
226        assert_eq!(
227            serde_json::to_string(&ServerSettings::default()).unwrap(),
228            "{}"
229        );
230    }
231
232    #[test]
233    fn mail_round_trips_and_omits_when_unset() {
234        use crate::config::{MailEncryption, MailSection};
235
236        // Unset mail is omitted (skip_serializing_if) — a mail-less doc
237        // stays minimal and decodes back to `None`.
238        assert_eq!(
239            serde_json::to_string(&ServerSettings::default()).unwrap(),
240            "{}"
241        );
242
243        let s = ServerSettings {
244            mail: Some(MailSection {
245                host: "smtp.example.com".into(),
246                port: 587,
247                encryption: MailEncryption::Starttls,
248                from: "kanade-noreply@example.com".into(),
249                username: Some("kanade-noreply".into()),
250            }),
251            ..Default::default()
252        };
253        let json = serde_json::to_string(&s).unwrap();
254        // Encryption serialises lowercase; the password is never present.
255        assert!(json.contains(r#""encryption":"starttls""#), "json: {json}");
256        assert!(!json.contains("password"), "password must never serialise");
257        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
258    }
259
260    #[test]
261    fn mail_defaults_to_unset() {
262        assert_eq!(ServerSettings::default().mail, None);
263        // A doc written before this field existed (no `mail` key) decodes
264        // to `None` — email stays a no-op, the pre-feature behaviour.
265        let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":7}"#).unwrap();
266        assert_eq!(s.mail, None);
267        assert_eq!(s.agent_prune_days, Some(7));
268    }
269
270    #[test]
271    fn accepts_unknown_fields_for_forward_compat() {
272        // A newer backend may have added knobs this build doesn't know;
273        // decoding must drop them rather than error.
274        let json = r#"{"agent_prune_days":7,"some_future_knob":true}"#;
275        let s: ServerSettings = serde_json::from_str(json).unwrap();
276        assert_eq!(s.agent_prune_days, Some(7));
277    }
278}