Skip to main content

kanade_shared/wire/
agent_config.rs

1//! Layered fleet configuration that lives in the `agent_config` KV
2//! bucket (Sprint 6).
3//!
4//! Three scopes flow into the agent's effective config, in order of
5//! increasing specificity:
6//!
7//! ```text
8//! built-in default        (compiled in; floor when nothing else is set)
9//!   ↓
10//! agent_config:global     (whole-fleet default)
11//!   ↓
12//! agent_config:groups.<g> (per-group override; one or more apply)
13//!   ↓
14//! agent_config:pcs.<pc>   (per-PC override; final word)
15//! ```
16//!
17//! The wire type for every scope is the same — [`ConfigScope`], a
18//! struct of `Option<T>` fields. `Some` means "this scope sets this
19//! field"; `None` means "fall through to the next layer". JSON
20//! `null` is the same as the field being absent thanks to serde's
21//! struct-level `default`.
22//!
23//! [`resolve`] is the pure functional core that flattens the scope
24//! stack into an [`EffectiveConfig`] (concrete values, no Options).
25//! When the same field is set on more than one group the PC belongs
26//! to, alphabetical group order wins last (CSS-cascade style) and a
27//! [`ResolutionWarning::MultiGroupConflict`] is emitted so the
28//! caller can log it — pre-empts the "why does this PC have value X?
29//! none of my groups say X" debugging session.
30//!
31//! v0.20.0: `inventory_interval` / `inventory_jitter` /
32//! `inventory_enabled` removed. They were leftovers from the
33//! v0.14-retired hardcoded WMI inventory loop; runtime inventory
34//! now lives in operator-defined probe jobs (`configs/jobs/
35//! inventory-*.yaml`), so the layered config no longer carries
36//! anything about it.
37
38use std::collections::BTreeMap;
39use std::time::Duration;
40
41use serde::{Deserialize, Serialize};
42
43/// Per-scope partial config. Every field is `Option<T>`: `Some` =
44/// set, `None` = inherit from the next-less-specific scope. Serde
45/// `default` + `skip_serializing_if` keeps the wire JSON tight —
46/// unset fields don't appear in the bucket value.
47#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
48#[serde(default)]
49pub struct ConfigScope {
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub target_version: Option<String>,
52    /// Random sleep window applied at each agent before it starts
53    /// downloading a new target_version, so a fleet-wide rollout
54    /// doesn't slam the Object Store / broker all at once
55    /// (humantime, e.g. `"30m"`). `"0s"` = no jitter (explicit
56    /// opt-in for canary / single-PC deploys); unset falls back to
57    /// the safe built-in default (10m — #491).
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub target_version_jitter: Option<String>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub heartbeat_interval: Option<String>,
62    /// Cadence for the whole-host perf snapshot loop (`host_perf.<pc_id>`).
63    /// Separate from `heartbeat_interval` because the host-wide
64    /// sysinfo refresh is slightly heavier than the per-process self-
65    /// perf one (memory + disk + network counters in addition to CPU)
66    /// and gappier data is acceptable for graphing. Default 60 s.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub host_perf_interval: Option<String>,
69    /// v0.41 / Phase 2: operator-driven opt-in for the heavy per-
70    /// process snapshot loop (`process_perf.<pc_id>`). Default off
71    /// because walking the full process table is the most expensive
72    /// sysinfo call on Citrix / RDS hosts; flip on only when an
73    /// operator is actively investigating a host. Paired with
74    /// `process_perf_expires_at` to auto-disable after a window —
75    /// see [`EffectiveConfig::process_perf_active_at`].
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub process_perf_enabled: Option<bool>,
78    /// Wall-clock RFC3339 timestamp after which `process_perf_enabled`
79    /// is considered expired and the agent stops publishing process
80    /// snapshots — even if the flag itself is still `true`. Lets the
81    /// SPA toggle "ON for 30 m" without the operator having to come
82    /// back and clear the flag manually. `None` (or the past) +
83    /// enabled=true means "indefinitely on" (rare; mostly a test path).
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub process_perf_expires_at: Option<chrono::DateTime<chrono::Utc>>,
86    /// Top-N processes (ordered by CPU%) the agent publishes per tick.
87    /// 20 by default — enough to cover the usual suspects on a
88    /// constrained host without ballooning the projector row volume
89    /// when several PCs are simultaneously in investigation mode.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub process_perf_top_n: Option<u32>,
92    /// Operator-facing product name the end-user Client App shows in
93    /// its window title, header, Start-Menu shortcut, and toast
94    /// attribution — so each deployment can brand the client for its
95    /// customer (e.g. `"端末管理支援ツール"`) instead of surfacing the
96    /// internal `kanade` name. Flows to the client via the KLP
97    /// handshake (window title / header) and is materialised into the
98    /// all-users Start-Menu shortcut by the agent (Start-Menu label /
99    /// toast sender name). `None` = inherit; the client falls back to
100    /// the built-in default name when nothing sets it.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub client_display_name: Option<String>,
103}
104
105impl ConfigScope {
106    pub fn is_empty(&self) -> bool {
107        self.target_version.is_none()
108            && self.target_version_jitter.is_none()
109            && self.heartbeat_interval.is_none()
110            && self.host_perf_interval.is_none()
111            && self.process_perf_enabled.is_none()
112            && self.process_perf_expires_at.is_none()
113            && self.process_perf_top_n.is_none()
114            && self.client_display_name.is_none()
115    }
116}
117
118/// Concrete config the agent runs against once the scope stack has
119/// been flattened. `target_version` stays `Option` because "no
120/// rollout target set anywhere" is a meaningful state (the agent
121/// just keeps running the version it has); the other fields always
122/// have a value, falling back to [`EffectiveConfig::builtin_defaults`]
123/// when no scope sets them.
124#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
125pub struct EffectiveConfig {
126    pub target_version: Option<String>,
127    pub target_version_jitter: String,
128    pub heartbeat_interval: String,
129    pub host_perf_interval: String,
130    /// v0.41 / Phase 2 — see [`ConfigScope::process_perf_enabled`].
131    pub process_perf_enabled: bool,
132    /// v0.41 / Phase 2 — see [`ConfigScope::process_perf_expires_at`].
133    pub process_perf_expires_at: Option<chrono::DateTime<chrono::Utc>>,
134    /// v0.41 / Phase 2 — see [`ConfigScope::process_perf_top_n`].
135    pub process_perf_top_n: u32,
136    /// Operator-facing client product name — see
137    /// [`ConfigScope::client_display_name`]. Stays `Option` (unlike
138    /// the perf fields) because "no name set anywhere" is a real
139    /// state: the client then falls back to its built-in default name
140    /// rather than the agent inventing one here.
141    pub client_display_name: Option<String>,
142}
143
144impl EffectiveConfig {
145    /// Floor values used when no KV scope sets a given field.
146    pub fn builtin_defaults() -> Self {
147        Self {
148            target_version: None,
149            // #491: safe-by-default. The pre-Sprint-11 "0s" default
150            // meant a fleet-wide target_version flip made every
151            // agent pull the multi-MB binary from the Object Store
152            // at the same instant (3,000 hosts ≈ tens of GB through
153            // one broker NIC) unless the operator remembered
154            // `--jitter` on every rollout. 10m amortises a
155            // 3,000-host fleet to ~5 downloads/s while staying
156            // tolerable for mid-size rollouts. Canary / dev flows
157            // that want the immediate swap opt in explicitly with
158            // `--jitter 0s` (fleet-deploy.ps1 does this for
159            // single-PC deploys).
160            target_version_jitter: "10m".to_string(),
161            heartbeat_interval: "30s".to_string(),
162            // 60 s default: 2× the heartbeat cadence so the chart has
163            // a roughly aligned point every other heartbeat, while
164            // keeping the host-wide sysinfo refresh (which on Citrix /
165            // RDS hosts is the heaviest call we make) out of the
166            // tight 30 s loop.
167            host_perf_interval: "60s".to_string(),
168            // Off by default. Per-process collection walks the full
169            // OS process table — the most expensive sysinfo call —
170            // so the fleet pays nothing until an operator opts a
171            // specific host into "investigation mode".
172            process_perf_enabled: false,
173            process_perf_expires_at: None,
174            process_perf_top_n: 20,
175            // No name set anywhere → the client renders its built-in
176            // default product name. The agent does not invent one here
177            // so "unset" stays distinguishable from "explicitly named".
178            client_display_name: None,
179        }
180    }
181
182    /// Returns true when process-perf collection should actually run
183    /// **right now**: the flag is set AND no expiry has passed.
184    /// Centralised here so agent / backend / SPA all agree on the
185    /// active-vs-expired distinction.
186    pub fn process_perf_active_at(&self, now: chrono::DateTime<chrono::Utc>) -> bool {
187        if !self.process_perf_enabled {
188            return false;
189        }
190        match self.process_perf_expires_at {
191            None => true,
192            Some(deadline) => now < deadline,
193        }
194    }
195
196    /// Parsed `heartbeat_interval`, falling back to the built-in
197    /// 30 s default on a malformed string. Logging the parse error
198    /// is the caller's job (so that test code can stay quiet).
199    pub fn heartbeat_duration(&self) -> Duration {
200        humantime::parse_duration(&self.heartbeat_interval).unwrap_or(Duration::from_secs(30))
201    }
202
203    /// Parsed `host_perf_interval`, falling back to the built-in
204    /// 60 s default on a malformed string.
205    pub fn host_perf_duration(&self) -> Duration {
206        humantime::parse_duration(&self.host_perf_interval).unwrap_or(Duration::from_secs(60))
207    }
208
209    /// Parsed `target_version_jitter`. #491: a malformed string
210    /// falls back to the safe built-in default (10 m), not zero —
211    /// the old ZERO fallback silently turned a `--jitter 30minutes`
212    /// typo into the exact fleet-wide download herd the flag exists
213    /// to prevent. The write boundaries (CLI `config set` /
214    /// `agent rollout`, backend rollout API) now reject malformed
215    /// strings outright, so this fallback only covers values that
216    /// predate that validation.
217    pub fn target_version_jitter_duration(&self) -> Duration {
218        humantime::parse_duration(&self.target_version_jitter)
219            .unwrap_or(Duration::from_secs(10 * 60))
220    }
221}
222
223impl Default for EffectiveConfig {
224    fn default() -> Self {
225        Self::builtin_defaults()
226    }
227}
228
229/// Non-fatal observations from [`resolve`] that the caller should
230/// log. Currently only "two of this PC's groups set the same field
231/// to different values" — useful pre-emptive debugging signal when
232/// canary / wave / dept overlays accidentally overlap.
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum ResolutionWarning {
235    MultiGroupConflict {
236        field: &'static str,
237        /// Group names that set this field, in alphabetical order
238        /// (i.e. the application order — the last name in this list
239        /// is the one whose value actually won).
240        groups: Vec<String>,
241    },
242}
243
244/// Flatten the scope stack into an [`EffectiveConfig`].
245///
246/// * `global` — the `global` key in the `agent_config` bucket
247///   (`None` if no row yet).
248/// * `group_scopes` — every `groups.<name>` row currently in the
249///   bucket (the caller can pass all of them; only the ones whose
250///   name is in `my_groups` are applied).
251/// * `pc_scope` — the `pcs.<pc_id>` row for this agent (`None` if
252///   no row yet).
253/// * `my_groups` — this agent's current memberships (from the
254///   `agent_groups` bucket).
255///
256/// Order of application: built-in default → global → per-group
257/// (alphabetical, last wins) → per-pc. Multi-group conflicts (≥ 2
258/// of `my_groups` setting the same field) are returned as warnings
259/// alongside the resolved config.
260pub fn resolve(
261    global: Option<&ConfigScope>,
262    group_scopes: &BTreeMap<String, ConfigScope>,
263    pc_scope: Option<&ConfigScope>,
264    my_groups: &[String],
265) -> (EffectiveConfig, Vec<ResolutionWarning>) {
266    let mut out = EffectiveConfig::builtin_defaults();
267    let mut warnings = Vec::new();
268
269    if let Some(g) = global {
270        apply_scope(&mut out, g);
271    }
272
273    // Sort + dedup the group list so iteration order is deterministic
274    // and "last wins" is well-defined.
275    let mut sorted_groups: Vec<&str> = my_groups.iter().map(String::as_str).collect();
276    sorted_groups.sort();
277    sorted_groups.dedup();
278
279    // Pass 1: find multi-setter fields so the caller can warn before
280    // pass 2 silently lets the alphabetical-last value win.
281    let mut setters: BTreeMap<&'static str, Vec<String>> = BTreeMap::new();
282    for g in &sorted_groups {
283        let Some(scope) = group_scopes.get(*g) else {
284            continue;
285        };
286        if scope.target_version.is_some() {
287            setters
288                .entry("target_version")
289                .or_default()
290                .push(g.to_string());
291        }
292        if scope.target_version_jitter.is_some() {
293            setters
294                .entry("target_version_jitter")
295                .or_default()
296                .push(g.to_string());
297        }
298        if scope.heartbeat_interval.is_some() {
299            setters
300                .entry("heartbeat_interval")
301                .or_default()
302                .push(g.to_string());
303        }
304        if scope.host_perf_interval.is_some() {
305            setters
306                .entry("host_perf_interval")
307                .or_default()
308                .push(g.to_string());
309        }
310        if scope.process_perf_enabled.is_some() {
311            setters
312                .entry("process_perf_enabled")
313                .or_default()
314                .push(g.to_string());
315        }
316        if scope.process_perf_expires_at.is_some() {
317            setters
318                .entry("process_perf_expires_at")
319                .or_default()
320                .push(g.to_string());
321        }
322        if scope.process_perf_top_n.is_some() {
323            setters
324                .entry("process_perf_top_n")
325                .or_default()
326                .push(g.to_string());
327        }
328        if scope.client_display_name.is_some() {
329            setters
330                .entry("client_display_name")
331                .or_default()
332                .push(g.to_string());
333        }
334    }
335    for (field, groups) in setters {
336        if groups.len() > 1 {
337            warnings.push(ResolutionWarning::MultiGroupConflict { field, groups });
338        }
339    }
340
341    // Pass 2: actually apply, alphabetically. Last-wins by construction.
342    for g in &sorted_groups {
343        if let Some(scope) = group_scopes.get(*g) {
344            apply_scope(&mut out, scope);
345        }
346    }
347
348    if let Some(p) = pc_scope {
349        apply_scope(&mut out, p);
350    }
351
352    (out, warnings)
353}
354
355fn apply_scope(out: &mut EffectiveConfig, s: &ConfigScope) {
356    if let Some(v) = &s.target_version {
357        out.target_version = Some(v.clone());
358    }
359    if let Some(v) = &s.target_version_jitter {
360        out.target_version_jitter = v.clone();
361    }
362    if let Some(v) = &s.heartbeat_interval {
363        out.heartbeat_interval = v.clone();
364    }
365    if let Some(v) = &s.host_perf_interval {
366        out.host_perf_interval = v.clone();
367    }
368    if let Some(v) = s.process_perf_enabled {
369        out.process_perf_enabled = v;
370    }
371    if let Some(v) = s.process_perf_expires_at {
372        out.process_perf_expires_at = Some(v);
373    }
374    if let Some(v) = s.process_perf_top_n {
375        out.process_perf_top_n = v;
376    }
377    if let Some(v) = &s.client_display_name {
378        out.client_display_name = Some(v.clone());
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    fn scope() -> ConfigScope {
387        ConfigScope::default()
388    }
389
390    #[test]
391    fn empty_stack_gives_builtin_defaults() {
392        let (eff, warns) = resolve(None, &BTreeMap::new(), None, &[]);
393        assert_eq!(eff, EffectiveConfig::builtin_defaults());
394        assert!(warns.is_empty());
395    }
396
397    #[test]
398    fn client_display_name_unset_resolves_to_none() {
399        // Nothing sets it → stays None so the client uses its built-in
400        // default product name (the agent never invents one).
401        let (eff, _) = resolve(None, &BTreeMap::new(), None, &[]);
402        assert!(eff.client_display_name.is_none());
403    }
404
405    #[test]
406    fn client_display_name_layers_global_then_pc() {
407        let global = ConfigScope {
408            client_display_name: Some("端末管理支援ツール".into()),
409            ..scope()
410        };
411        let (eff, _) = resolve(Some(&global), &BTreeMap::new(), None, &[]);
412        assert_eq!(
413            eff.client_display_name.as_deref(),
414            Some("端末管理支援ツール")
415        );
416
417        // A per-pc override is the final word — lets one machine carry
418        // a customer-specific name distinct from the fleet default.
419        let pc = ConfigScope {
420            client_display_name: Some("PC専用名".into()),
421            ..scope()
422        };
423        let (eff, _) = resolve(Some(&global), &BTreeMap::new(), Some(&pc), &[]);
424        assert_eq!(eff.client_display_name.as_deref(), Some("PC専用名"));
425    }
426
427    #[test]
428    fn client_display_name_multi_group_conflict_warns() {
429        let mut groups = BTreeMap::new();
430        groups.insert(
431            "site-a".into(),
432            ConfigScope {
433                client_display_name: Some("A社ツール".into()),
434                ..scope()
435            },
436        );
437        groups.insert(
438            "site-b".into(),
439            ConfigScope {
440                client_display_name: Some("B社ツール".into()),
441                ..scope()
442            },
443        );
444        let (eff, warns) = resolve(None, &groups, None, &["site-a".into(), "site-b".into()]);
445        // "site-b" sorts last alphabetically, so it wins.
446        assert_eq!(eff.client_display_name.as_deref(), Some("B社ツール"));
447        assert_eq!(warns.len(), 1);
448        match &warns[0] {
449            ResolutionWarning::MultiGroupConflict { field, .. } => {
450                assert_eq!(*field, "client_display_name");
451            }
452        }
453    }
454
455    #[test]
456    fn global_only() {
457        let g = ConfigScope {
458            heartbeat_interval: Some("60s".into()),
459            ..scope()
460        };
461        let (eff, _) = resolve(Some(&g), &BTreeMap::new(), None, &[]);
462        assert_eq!(eff.heartbeat_interval, "60s");
463        // Unset fields stay at builtin defaults (#491: jitter's
464        // builtin default is the safe 10m, not 0s).
465        assert_eq!(eff.target_version_jitter, "10m");
466        assert!(eff.target_version.is_none());
467    }
468
469    #[test]
470    fn group_overrides_global() {
471        let global = ConfigScope {
472            heartbeat_interval: Some("30s".into()),
473            ..scope()
474        };
475        let mut groups = BTreeMap::new();
476        groups.insert(
477            "canary".into(),
478            ConfigScope {
479                heartbeat_interval: Some("5s".into()),
480                ..scope()
481            },
482        );
483        let (eff, warns) = resolve(Some(&global), &groups, None, &["canary".into()]);
484        assert_eq!(eff.heartbeat_interval, "5s");
485        assert!(warns.is_empty());
486    }
487
488    #[test]
489    fn pc_overrides_group() {
490        let mut groups = BTreeMap::new();
491        groups.insert(
492            "wave1".into(),
493            ConfigScope {
494                heartbeat_interval: Some("30s".into()),
495                ..scope()
496            },
497        );
498        let pc = ConfigScope {
499            heartbeat_interval: Some("5s".into()),
500            ..scope()
501        };
502        let (eff, _) = resolve(None, &groups, Some(&pc), &["wave1".into()]);
503        assert_eq!(eff.heartbeat_interval, "5s");
504    }
505
506    #[test]
507    fn pc_overrides_global_when_no_group_match() {
508        let global = ConfigScope {
509            heartbeat_interval: Some("30s".into()),
510            ..scope()
511        };
512        let pc = ConfigScope {
513            heartbeat_interval: Some("5s".into()),
514            ..scope()
515        };
516        let (eff, _) = resolve(Some(&global), &BTreeMap::new(), Some(&pc), &[]);
517        assert_eq!(eff.heartbeat_interval, "5s");
518    }
519
520    #[test]
521    fn partial_override_only_changes_named_fields() {
522        let global = ConfigScope {
523            target_version_jitter: Some("30m".into()),
524            heartbeat_interval: Some("30s".into()),
525            ..scope()
526        };
527        let pc = ConfigScope {
528            heartbeat_interval: Some("15s".into()),
529            // intentionally not touching target_version_jitter
530            ..scope()
531        };
532        let (eff, _) = resolve(Some(&global), &BTreeMap::new(), Some(&pc), &[]);
533        assert_eq!(eff.target_version_jitter, "30m"); // from global
534        assert_eq!(eff.heartbeat_interval, "15s"); // from pc
535    }
536
537    #[test]
538    fn multi_group_conflict_emits_warning() {
539        let mut groups = BTreeMap::new();
540        groups.insert(
541            "wave1".into(),
542            ConfigScope {
543                heartbeat_interval: Some("5s".into()),
544                ..scope()
545            },
546        );
547        groups.insert(
548            "dept-eng".into(),
549            ConfigScope {
550                heartbeat_interval: Some("60s".into()),
551                ..scope()
552            },
553        );
554        let (eff, warns) = resolve(None, &groups, None, &["wave1".into(), "dept-eng".into()]);
555        // "dept-eng" sorts before "wave1", so wave1 wins (last alphabetical).
556        assert_eq!(eff.heartbeat_interval, "5s");
557        assert_eq!(warns.len(), 1);
558        match &warns[0] {
559            ResolutionWarning::MultiGroupConflict { field, groups } => {
560                assert_eq!(*field, "heartbeat_interval");
561                assert_eq!(groups, &vec!["dept-eng".to_string(), "wave1".to_string()]);
562            }
563        }
564    }
565
566    #[test]
567    fn group_alphabetical_last_wins_no_conflict_when_only_one_sets() {
568        let mut groups = BTreeMap::new();
569        groups.insert(
570            "wave1".into(),
571            ConfigScope {
572                heartbeat_interval: Some("5s".into()),
573                ..scope()
574            },
575        );
576        groups.insert(
577            "dept-eng".into(),
578            ConfigScope {
579                // Different field — doesn't conflict.
580                target_version_jitter: Some("15m".into()),
581                ..scope()
582            },
583        );
584        let (eff, warns) = resolve(None, &groups, None, &["wave1".into(), "dept-eng".into()]);
585        assert_eq!(eff.heartbeat_interval, "5s");
586        assert_eq!(eff.target_version_jitter, "15m");
587        assert!(warns.is_empty());
588    }
589
590    #[test]
591    fn unknown_group_is_silently_ignored() {
592        // my_groups names a group that has no scope row yet. Common
593        // on the first agent that joins a freshly-named group; the
594        // resolver should treat it as a no-op, not an error.
595        let mut groups = BTreeMap::new();
596        groups.insert(
597            "canary".into(),
598            ConfigScope {
599                heartbeat_interval: Some("5s".into()),
600                ..scope()
601            },
602        );
603        let (eff, warns) = resolve(
604            None,
605            &groups,
606            None,
607            &["canary".into(), "ghost-group".into()],
608        );
609        assert_eq!(eff.heartbeat_interval, "5s");
610        assert!(warns.is_empty());
611    }
612
613    #[test]
614    fn group_scope_not_applied_when_pc_not_in_group() {
615        let mut groups = BTreeMap::new();
616        groups.insert(
617            "canary".into(),
618            ConfigScope {
619                target_version: Some("0.3.0".into()),
620                ..scope()
621            },
622        );
623        let (eff, _) = resolve(None, &groups, None, &["dept-eng".into()]);
624        // PC is NOT in canary, so the rollout target shouldn't apply.
625        assert!(eff.target_version.is_none());
626    }
627
628    #[test]
629    fn duplicate_group_names_dedup_silently() {
630        let mut groups = BTreeMap::new();
631        groups.insert(
632            "wave1".into(),
633            ConfigScope {
634                heartbeat_interval: Some("5s".into()),
635                ..scope()
636            },
637        );
638        // my_groups carries the same name twice — the dedup pass
639        // keeps it from looking like a conflict-with-self.
640        let (eff, warns) = resolve(None, &groups, None, &["wave1".into(), "wave1".into()]);
641        assert_eq!(eff.heartbeat_interval, "5s");
642        assert!(warns.is_empty());
643    }
644
645    #[test]
646    fn config_scope_serde_round_trip() {
647        let s = ConfigScope {
648            target_version: Some("0.3.0".into()),
649            heartbeat_interval: Some("15s".into()),
650            ..scope()
651        };
652        let json = serde_json::to_string(&s).unwrap();
653        // Only set fields appear in JSON.
654        assert_eq!(
655            json,
656            r#"{"target_version":"0.3.0","heartbeat_interval":"15s"}"#
657        );
658        let back: ConfigScope = serde_json::from_str(&json).unwrap();
659        assert_eq!(back, s);
660    }
661
662    #[test]
663    fn empty_config_scope_round_trips_as_empty_json() {
664        let s = ConfigScope::default();
665        assert!(s.is_empty());
666        let json = serde_json::to_string(&s).unwrap();
667        assert_eq!(json, "{}");
668        let back: ConfigScope = serde_json::from_str(&json).unwrap();
669        assert_eq!(back, s);
670    }
671
672    #[test]
673    fn deserialize_tolerates_unknown_fields_for_forward_compat() {
674        // Older agent / backend builds should keep parsing in case
675        // we add fields later. v0.20 also relies on this so pre-v0.20
676        // rows that still have inventory_interval / inventory_jitter
677        // / inventory_enabled in the bucket value parse OK as the
678        // new (smaller) ConfigScope — the dropped fields just
679        // dissolve into "unknown, ignored".
680        let json =
681            r#"{"target_version":"0.3.0","inventory_interval":"24h","future_knob":"future_value"}"#;
682        let s: ConfigScope = serde_json::from_str(json).unwrap();
683        assert_eq!(s.target_version.as_deref(), Some("0.3.0"));
684    }
685
686    #[test]
687    fn pc_does_not_override_other_pcs() {
688        // Sanity: pc_scope passed in is by definition the row for THIS
689        // pc; the caller is responsible for picking the right one.
690        // This test guards against a future refactor that accidentally
691        // wires in the wrong scope by ensuring the apply happens last
692        // (after groups), so the PC value is the visible one.
693        let mut groups = BTreeMap::new();
694        groups.insert(
695            "wave1".into(),
696            ConfigScope {
697                heartbeat_interval: Some("30s".into()),
698                ..scope()
699            },
700        );
701        let pc = ConfigScope {
702            heartbeat_interval: Some("5s".into()),
703            ..scope()
704        };
705        let (eff, _) = resolve(None, &groups, Some(&pc), &["wave1".into()]);
706        assert_eq!(eff.heartbeat_interval, "5s");
707    }
708
709    #[test]
710    fn malformed_jitter_falls_back_to_safe_default_not_zero() {
711        // #491: pre-fix this fell back to ZERO, silently turning a
712        // typo'd jitter into a fleet-wide simultaneous download.
713        // (Note "30minutes" is VALID humantime — full unit names
714        // parse — so the malformed sample must be genuinely broken.)
715        let eff = EffectiveConfig {
716            target_version_jitter: "not-a-duration".into(),
717            ..EffectiveConfig::builtin_defaults()
718        };
719        assert_eq!(
720            eff.target_version_jitter_duration(),
721            Duration::from_secs(10 * 60),
722        );
723        // Explicit 0s remains an honoured opt-in.
724        let zero = EffectiveConfig {
725            target_version_jitter: "0s".into(),
726            ..EffectiveConfig::builtin_defaults()
727        };
728        assert_eq!(zero.target_version_jitter_duration(), Duration::ZERO);
729    }
730}