Skip to main content

kanade_shared/wire/
server_settings.rs

1use serde::{Deserialize, Serialize};
2
3/// Upper bound on [`ServerSettings::agent_prune_days`] (100 years). A
4/// value this large already means "effectively never", and it keeps the
5/// cleanup task's `now - Duration::days(n)` subtraction comfortably inside
6/// `chrono::DateTime`'s representable range — `DateTime - Duration` panics
7/// on overflow, and an unbounded `u32` (~11.7 M years) would trip it.
8/// Enforced two ways: the PUT handler rejects a larger value, and
9/// [`ServerSettings::effective_agent_prune_days`] clamps to it so even a
10/// hand-written KV value can never panic the cleanup task.
11pub const MAX_AGENT_PRUNE_DAYS: u32 = 36_500;
12
13/// Value stored in the `server_settings` KV bucket under the single key
14/// [`crate::kv::KEY_SERVER_SETTINGS`]. Operator-editable, backend-side
15/// server configuration that isn't per-agent (so it doesn't belong in
16/// `agent_config`'s layered scopes) and isn't a fleet-wide switch every
17/// agent watches (so it doesn't belong in `fleet_config`). Managed via
18/// the SPA Settings page's "server settings" tab.
19///
20/// Every field is `Option<_>`: `None` (the default / the JSON value
21/// `null` / the field simply absent) means **unset — fall back to the
22/// built-in default** ([`ServerSettings::defaults`]), exactly like the
23/// agent layered-config scopes. The SPA renders the built-in default as a
24/// faint placeholder so a blank field shows what it resolves to, and when
25/// a real default is introduced here it appears in the UI (and takes
26/// effect for already-deployed-but-unset fleets) for free.
27///
28/// `#[serde(default)]` on the container keeps the document backward/forward
29/// compatible: a freshly-created or missing key decodes to all-`None`
30/// (pre-feature behaviour), an older backend reading a newer document
31/// ignores unknown fields, and a newer backend reading an older document
32/// fills the missing field with `None`. Keep that invariant — never add a
33/// field whose `None` doesn't mean "behave as before".
34#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
35#[serde(default)]
36pub struct ServerSettings {
37    /// Days a dead agent (one whose heartbeat stopped arriving) may
38    /// linger in the `agents` registry before the backend cleanup task
39    /// prunes its row.
40    ///
41    /// `None` (unset) falls back to the built-in default; with no default
42    /// configured that resolves to pruning **disabled** (see
43    /// [`ServerSettings::effective_agent_prune_days`]). A positive value
44    /// makes the cleanup sweep delete rows whose `last_heartbeat` is older
45    /// than that many days. The `agents` table is a projection of the
46    /// heartbeat stream, so a machine that's merely offline (not gone)
47    /// reappears on its next heartbeat (~30s cadence); only
48    /// genuinely-retired machines stay gone.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub agent_prune_days: Option<u32>,
51
52    /// Agent group whose members are the trusted **controller-tier**
53    /// runners. A job with `tier: controller` (e.g. a `feed:` job that
54    /// fetches an external URL) is dispatched ONLY to members of this group;
55    /// `None` (unset) means controller-tier jobs run **nowhere** (fail-safe,
56    /// so an external fetch never lands on an employee endpoint by accident).
57    /// See `crate::manifest::Tier`. `None` ⇒ no controller runners
58    /// configured, which is the safe default for a fresh deployment.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub controller_group: Option<String>,
61}
62
63impl ServerSettings {
64    /// Built-in defaults applied when a field is unset (`None`) in the
65    /// stored document. Currently every field is `None`: there's no
66    /// fleet-meaningful default prune window, so leaving it blank means
67    /// "disabled" rather than some arbitrary number of days.
68    ///
69    /// Exposed via `GET /api/server-settings/defaults` so the SPA renders
70    /// these as faint placeholders (mirroring the agent layered-config
71    /// page's built-in floor). Introducing a real default later is a
72    /// one-line change here that automatically shows up in the UI and
73    /// applies to every deployment that hasn't overridden the field.
74    pub fn defaults() -> Self {
75        Self {
76            agent_prune_days: None,
77            controller_group: None,
78        }
79    }
80
81    /// The configured controller-tier runner group, trimmed, or `None` when
82    /// unset / blank. `None` ⇒ controller-tier jobs run nowhere (fail-safe).
83    pub fn effective_controller_group(&self) -> Option<&str> {
84        self.controller_group
85            .as_deref()
86            .map(str::trim)
87            .filter(|g| !g.is_empty())
88    }
89
90    /// The effective dead-agent prune window in days: the stored value if
91    /// set, else the built-in default, else `0` (= pruning disabled). The
92    /// final `unwrap_or(0)` is the absent-everywhere floor, not a
93    /// user-facing default — the cleanup task treats `0` as "don't prune".
94    ///
95    /// Clamped to [`MAX_AGENT_PRUNE_DAYS`] so the cleanup task's
96    /// `now - Duration::days(n)` can never overflow `DateTime` (and panic
97    /// the task), even if a value larger than the PUT handler allows was
98    /// written to the KV out-of-band.
99    pub fn effective_agent_prune_days(&self) -> u32 {
100        self.agent_prune_days
101            .or(Self::defaults().agent_prune_days)
102            .unwrap_or(0)
103            .min(MAX_AGENT_PRUNE_DAYS)
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn default_is_unset() {
113        assert_eq!(ServerSettings::default().agent_prune_days, None);
114    }
115
116    #[test]
117    fn unset_resolves_to_disabled() {
118        // No stored value + no built-in default ⇒ effective 0 (disabled).
119        assert_eq!(ServerSettings::default().effective_agent_prune_days(), 0);
120    }
121
122    #[test]
123    fn stored_value_wins_over_default() {
124        let s = ServerSettings {
125            agent_prune_days: Some(30),
126            ..Default::default()
127        };
128        assert_eq!(s.effective_agent_prune_days(), 30);
129    }
130
131    #[test]
132    fn effective_clamps_to_max() {
133        // An out-of-band KV write larger than the PUT cap must not reach
134        // the cleanup task unclamped (else its DateTime subtraction panics).
135        let s = ServerSettings {
136            agent_prune_days: Some(u32::MAX),
137            ..Default::default()
138        };
139        assert_eq!(s.effective_agent_prune_days(), MAX_AGENT_PRUNE_DAYS);
140    }
141
142    #[test]
143    fn round_trips_through_json() {
144        let s = ServerSettings {
145            agent_prune_days: Some(30),
146            ..Default::default()
147        };
148        let json = serde_json::to_string(&s).unwrap();
149        assert_eq!(json, r#"{"agent_prune_days":30}"#);
150        let back: ServerSettings = serde_json::from_str(&json).unwrap();
151        assert_eq!(back, s);
152    }
153
154    #[test]
155    fn unset_serialises_to_empty_object() {
156        // `skip_serializing_if` keeps an all-unset doc minimal; it must
157        // round-trip back to all-`None`.
158        let s = ServerSettings::default();
159        let json = serde_json::to_string(&s).unwrap();
160        assert_eq!(json, "{}");
161        let back: ServerSettings = serde_json::from_str(&json).unwrap();
162        assert_eq!(back, s);
163    }
164
165    #[test]
166    fn explicit_null_decodes_to_unset() {
167        let s: ServerSettings = serde_json::from_str(r#"{"agent_prune_days":null}"#).unwrap();
168        assert_eq!(s.agent_prune_days, None);
169    }
170
171    #[test]
172    fn empty_object_decodes_to_default() {
173        // A freshly-created key (or one written by an older backend that
174        // didn't know this field) must read back as the pre-feature
175        // behaviour, not fail to decode.
176        let s: ServerSettings = serde_json::from_str("{}").unwrap();
177        assert_eq!(s, ServerSettings::default());
178    }
179
180    #[test]
181    fn controller_group_effective_trims_and_blank_is_unset() {
182        assert_eq!(ServerSettings::default().effective_controller_group(), None);
183        let s = ServerSettings {
184            controller_group: Some("  feed-runners ".into()),
185            ..Default::default()
186        };
187        assert_eq!(s.effective_controller_group(), Some("feed-runners"));
188        // A blank/whitespace value reads as unset (fail-safe: no runner).
189        let blank = ServerSettings {
190            controller_group: Some("   ".into()),
191            ..Default::default()
192        };
193        assert_eq!(blank.effective_controller_group(), None);
194    }
195
196    #[test]
197    fn controller_group_round_trips_and_omits_when_unset() {
198        let s = ServerSettings {
199            controller_group: Some("infra".into()),
200            ..Default::default()
201        };
202        let json = serde_json::to_string(&s).unwrap();
203        assert_eq!(json, r#"{"controller_group":"infra"}"#);
204        assert_eq!(serde_json::from_str::<ServerSettings>(&json).unwrap(), s);
205        // Unset controller_group is omitted (skip_serializing_if).
206        assert_eq!(
207            serde_json::to_string(&ServerSettings::default()).unwrap(),
208            "{}"
209        );
210    }
211
212    #[test]
213    fn accepts_unknown_fields_for_forward_compat() {
214        // A newer backend may have added knobs this build doesn't know;
215        // decoding must drop them rather than error.
216        let json = r#"{"agent_prune_days":7,"some_future_knob":true}"#;
217        let s: ServerSettings = serde_json::from_str(json).unwrap();
218        assert_eq!(s.agent_prune_days, Some(7));
219    }
220}