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}