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