Skip to main content

monitord/
config.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3use std::str::FromStr;
4
5use configparser::ini::Ini;
6use indexmap::map::IndexMap;
7use int_enum::IntEnum;
8use strum_macros::EnumString;
9use thiserror::Error;
10use tracing::{error, warn};
11
12#[derive(Error, Debug)]
13pub enum MonitordConfigError {
14    #[error("Invalid value for '{key}' in '{section}': {reason}")]
15    InvalidValue {
16        section: String,
17        key: String,
18        reason: String,
19    },
20    #[error("Missing key '{key}' in '{section}'")]
21    MissingKey { section: String, key: String },
22}
23
24#[derive(Clone, Debug, Default, EnumString, Eq, IntEnum, PartialEq, strum_macros::Display)]
25#[repr(u8)]
26pub enum MonitordOutputFormat {
27    #[default]
28    #[strum(serialize = "json", serialize = "JSON", serialize = "Json")]
29    Json = 0,
30    #[strum(
31        serialize = "json-flat",
32        serialize = "json_flat",
33        serialize = "jsonflat"
34    )]
35    JsonFlat = 1,
36    #[strum(
37        serialize = "json-pretty",
38        serialize = "json_pretty",
39        serialize = "jsonpretty"
40    )]
41    JsonPretty = 2,
42}
43
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub struct MonitordConfig {
46    pub dbus_address: String,
47    pub daemon: bool,
48    pub daemon_stats_refresh_secs: u64,
49    pub key_prefix: String,
50    pub output_format: MonitordOutputFormat,
51    pub dbus_timeout: u64,
52}
53impl Default for MonitordConfig {
54    fn default() -> Self {
55        MonitordConfig {
56            dbus_address: crate::DEFAULT_DBUS_ADDRESS.into(),
57            daemon: false,
58            daemon_stats_refresh_secs: 30,
59            key_prefix: "".to_string(),
60            output_format: MonitordOutputFormat::default(),
61            dbus_timeout: 30,
62        }
63    }
64}
65
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct NetworkdConfig {
68    pub enabled: bool,
69    /// Use varlink APIs for this collector when the global `[varlink]`
70    /// switch is on. Default true: set false to keep this collector on
71    /// D-Bus/files while the rest move to varlink.
72    pub varlink: bool,
73    pub link_state_dir: PathBuf,
74}
75impl Default for NetworkdConfig {
76    fn default() -> Self {
77        NetworkdConfig {
78            enabled: false,
79            varlink: true,
80            link_state_dir: crate::networkd::NETWORKD_STATE_FILES.into(),
81        }
82    }
83}
84
85#[derive(Clone, Debug, Eq, PartialEq)]
86pub struct Pid1Config {
87    pub enabled: bool,
88}
89impl Default for Pid1Config {
90    fn default() -> Self {
91        Pid1Config { enabled: true }
92    }
93}
94
95#[derive(Clone, Debug, Eq, PartialEq)]
96pub struct SystemStateConfig {
97    pub enabled: bool,
98    /// Use varlink APIs for this collector when the global `[varlink]`
99    /// switch is on. Default true: set false to keep this collector on
100    /// D-Bus while the rest move to varlink. Also gates the always-on
101    /// systemd version collection, which shares the `Manager.Describe` call.
102    pub varlink: bool,
103}
104impl Default for SystemStateConfig {
105    fn default() -> Self {
106        SystemStateConfig {
107            enabled: true,
108            varlink: true,
109        }
110    }
111}
112
113#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct TimersConfig {
115    pub enabled: bool,
116    pub allowlist: HashSet<String>,
117    pub blocklist: HashSet<String>,
118}
119impl Default for TimersConfig {
120    fn default() -> Self {
121        TimersConfig {
122            enabled: true,
123            allowlist: HashSet::new(),
124            blocklist: HashSet::new(),
125        }
126    }
127}
128
129#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct UnitsConfig {
131    pub enabled: bool,
132    /// Use varlink APIs for this collector when the global `[varlink]`
133    /// switch is on. Default true: set false to keep this collector on
134    /// D-Bus while the rest move to varlink.
135    pub varlink: bool,
136    pub state_stats: bool,
137    pub state_stats_allowlist: HashSet<String>,
138    pub state_stats_blocklist: HashSet<String>,
139    pub state_stats_time_in_state: bool,
140    pub ignore_inactive_oneshot_services: bool,
141    pub unit_files: bool,
142    /// Max number of units whose D-Bus work runs concurrently in the per-unit
143    /// collection loop. Bounded (rather than unbounded) so a burst of
144    /// simultaneous D-Bus calls doesn't itself worsen host-level IPC
145    /// contention on hosts where per-call latency is already elevated.
146    pub per_unit_concurrency: u64,
147    /// Number of slowest units (by per-unit collection duration) to record in
148    /// `UnitsCollectionTimings::slowest_units`. Set to 0 to disable.
149    pub slowest_units_count: u64,
150}
151impl Default for UnitsConfig {
152    fn default() -> Self {
153        UnitsConfig {
154            enabled: true,
155            varlink: true,
156            state_stats: false,
157            state_stats_allowlist: HashSet::new(),
158            state_stats_blocklist: HashSet::new(),
159            state_stats_time_in_state: true,
160            ignore_inactive_oneshot_services: true,
161            unit_files: true,
162            per_unit_concurrency: 8,
163            slowest_units_count: 5,
164        }
165    }
166}
167
168#[derive(Clone, Debug, Eq, PartialEq)]
169pub struct MachinesConfig {
170    pub enabled: bool,
171    /// Use varlink APIs inside containers when the global `[varlink]`
172    /// switch is on. Default true: set false to keep all container
173    /// collection on D-Bus. Container paths additionally require the
174    /// matching collector section's own `varlink` toggle. Container varlink
175    /// connects from inside the container's PID namespace (see
176    /// `crate::varlink::machine_connector`), which needs `CAP_SYS_ADMIN`.
177    pub varlink: bool,
178    pub allowlist: HashSet<String>,
179    pub blocklist: HashSet<String>,
180}
181impl Default for MachinesConfig {
182    fn default() -> Self {
183        MachinesConfig {
184            enabled: true,
185            varlink: true,
186            allowlist: HashSet::new(),
187            blocklist: HashSet::new(),
188        }
189    }
190}
191
192#[derive(Clone, Debug, Eq, PartialEq)]
193pub struct DBusStatsConfig {
194    pub enabled: bool,
195    pub stale_fd_stats: bool,
196
197    pub user_stats: bool,
198    pub user_allowlist: HashSet<String>,
199    pub user_blocklist: HashSet<String>,
200
201    pub peer_stats: bool,
202    pub peer_well_known_names_only: bool,
203    pub peer_allowlist: HashSet<String>,
204    pub peer_blocklist: HashSet<String>,
205    /// Max number of well-known bus names whose owner lookup runs concurrently
206    /// when resolving peer names. Bounded (rather than unbounded) for the same
207    /// reason as `UnitsConfig::per_unit_concurrency`: a burst of simultaneous
208    /// D-Bus calls can itself worsen host-level IPC contention.
209    pub peer_name_concurrency: u64,
210
211    pub cgroup_stats: bool,
212    pub cgroup_allowlist: HashSet<String>,
213    pub cgroup_blocklist: HashSet<String>,
214}
215impl Default for DBusStatsConfig {
216    fn default() -> Self {
217        DBusStatsConfig {
218            enabled: true,
219            stale_fd_stats: true,
220
221            user_stats: false,
222            user_allowlist: HashSet::new(),
223            user_blocklist: HashSet::new(),
224
225            peer_stats: false,
226            peer_well_known_names_only: false,
227            peer_allowlist: HashSet::new(),
228            peer_blocklist: HashSet::new(),
229            peer_name_concurrency: 8,
230
231            cgroup_stats: false,
232            cgroup_allowlist: HashSet::new(),
233            cgroup_blocklist: HashSet::new(),
234        }
235    }
236}
237
238#[derive(Clone, Debug, Eq, PartialEq)]
239pub struct BootBlameConfig {
240    pub enabled: bool,
241    /// Use varlink APIs for this collector when the global `[varlink]`
242    /// switch is on. Default true: set false to keep this collector on
243    /// D-Bus while the rest move to varlink.
244    pub varlink: bool,
245    pub cache_enabled: bool,
246    pub cache_dir: String,
247    pub num_slowest_units: u64,
248    pub allowlist: HashSet<String>,
249    pub blocklist: HashSet<String>,
250}
251impl Default for BootBlameConfig {
252    fn default() -> Self {
253        BootBlameConfig {
254            enabled: false,
255            varlink: true,
256            cache_enabled: true,
257            cache_dir: "/run/monitord".to_string(),
258            num_slowest_units: 5,
259            allowlist: HashSet::new(),
260            blocklist: HashSet::new(),
261        }
262    }
263}
264
265#[derive(Clone, Debug, Eq, PartialEq)]
266pub struct VerifyConfig {
267    pub enabled: bool,
268    /// Use varlink APIs for this collector when the global `[varlink]`
269    /// switch is on. Default true: set false to keep this collector on
270    /// D-Bus while the rest move to varlink.
271    pub varlink: bool,
272    pub allowlist: HashSet<String>,
273    pub blocklist: HashSet<String>,
274}
275impl Default for VerifyConfig {
276    fn default() -> Self {
277        VerifyConfig {
278            enabled: false,
279            varlink: true,
280            allowlist: HashSet::new(),
281            blocklist: HashSet::new(),
282        }
283    }
284}
285
286#[derive(Clone, Debug, Default, Eq, PartialEq)]
287pub struct VarlinkConfig {
288    /// Global varlink master switch. Each varlink-capable collector section
289    /// has its own `varlink` opt-out; a collector uses varlink only when
290    /// both this and its section toggle are true.
291    pub enabled: bool,
292    /// Forbid every varlink fallback: any varlink failure is a hard error
293    /// instead of falling back to D-Bus (or files for networkd). Off by
294    /// default; enable in CI to prove a collector set is varlink-clean
295    /// rather than silently D-Bus-served. See `crate::varlink_fallback`
296    /// for the shared enforcement point.
297    pub no_fallback: bool,
298}
299
300/// Config struct
301/// Each section represents an ini file section
302#[derive(Clone, Debug, Default, Eq, PartialEq)]
303pub struct Config {
304    pub machines: MachinesConfig,
305    pub monitord: MonitordConfig,
306    pub networkd: NetworkdConfig,
307    pub pid1: Pid1Config,
308    pub services: HashSet<String>,
309    pub system_state: SystemStateConfig,
310    pub timers: TimersConfig,
311    pub units: UnitsConfig,
312    pub dbus_stats: DBusStatsConfig,
313    pub boot_blame: BootBlameConfig,
314    pub verify: VerifyConfig,
315    pub varlink: VarlinkConfig,
316}
317
318impl Config {
319    /// Whether collectors gated on the given section toggles may use varlink.
320    ///
321    /// The global `[varlink]` master switch ANDed with every section toggle
322    /// passed. Container paths additionally pass `machines.varlink`, since a
323    /// container may run older systemd without the varlink APIs.
324    pub fn use_varlink(&self, sections: &[bool]) -> bool {
325        self.varlink.enabled && sections.iter().all(|toggle| *toggle)
326    }
327}
328
329impl TryFrom<Ini> for Config {
330    type Error = MonitordConfigError;
331
332    fn try_from(ini_config: Ini) -> Result<Self, MonitordConfigError> {
333        let mut config = Config::default();
334
335        // [monitord] section
336        if let Some(dbus_address) = ini_config.get("monitord", "dbus_address") {
337            config.monitord.dbus_address = dbus_address;
338        }
339        if let Ok(Some(dbus_timeout)) = ini_config.getuint("monitord", "dbus_timeout") {
340            config.monitord.dbus_timeout = dbus_timeout;
341        }
342        config.monitord.daemon = read_config_bool(&ini_config, "monitord", "daemon")?;
343        if let Ok(Some(daemon_stats_refresh_secs)) =
344            ini_config.getuint("monitord", "daemon_stats_refresh_secs")
345        {
346            config.monitord.daemon_stats_refresh_secs = daemon_stats_refresh_secs;
347        }
348        if let Some(key_prefix) = ini_config.get("monitord", "key_prefix") {
349            config.monitord.key_prefix = key_prefix;
350        }
351        let output_format_str = ini_config.get("monitord", "output_format").ok_or_else(|| {
352            MonitordConfigError::MissingKey {
353                section: "monitord".into(),
354                key: "output_format".into(),
355            }
356        })?;
357        config.monitord.output_format = MonitordOutputFormat::from_str(&output_format_str)
358            .map_err(|e| MonitordConfigError::InvalidValue {
359                section: "monitord".into(),
360                key: "output_format".into(),
361                reason: e.to_string(),
362            })?;
363
364        // [networkd] section
365        config.networkd.enabled = read_config_bool(&ini_config, "networkd", "enabled")?;
366        if let Some(varlink) = read_config_optional_bool(&ini_config, "networkd", "varlink")? {
367            config.networkd.varlink = varlink;
368        }
369        if let Some(link_state_dir) = ini_config.get("networkd", "link_state_dir") {
370            config.networkd.link_state_dir = link_state_dir.into();
371        }
372
373        // [pid1] section
374        config.pid1.enabled = read_config_bool(&ini_config, "pid1", "enabled")?;
375
376        // [services] section
377        let config_map = ini_config.get_map().unwrap_or(IndexMap::from([]));
378        if let Some(services) = config_map.get("services") {
379            config.services = services.keys().map(|s| s.to_string()).collect();
380        }
381
382        // [system-state] section
383        config.system_state.enabled = read_config_bool(&ini_config, "system-state", "enabled")?;
384        if let Some(varlink) = read_config_optional_bool(&ini_config, "system-state", "varlink")? {
385            config.system_state.varlink = varlink;
386        }
387
388        // [timers] section
389        config.timers.enabled = read_config_bool(&ini_config, "timers", "enabled")?;
390        if let Some(timers_allowlist) = config_map.get("timers.allowlist") {
391            config.timers.allowlist = timers_allowlist.keys().map(|s| s.to_string()).collect();
392        }
393        if let Some(timers_blocklist) = config_map.get("timers.blocklist") {
394            config.timers.blocklist = timers_blocklist.keys().map(|s| s.to_string()).collect();
395        }
396
397        // [units] section
398        config.units.enabled = read_config_bool(&ini_config, "units", "enabled")?;
399        if let Some(varlink) = read_config_optional_bool(&ini_config, "units", "varlink")? {
400            config.units.varlink = varlink;
401        }
402        config.units.state_stats = read_config_bool(&ini_config, "units", "state_stats")?;
403        if let Some(state_stats_allowlist) = config_map.get("units.state_stats.allowlist") {
404            config.units.state_stats_allowlist = state_stats_allowlist
405                .keys()
406                .map(|s| s.to_string())
407                .collect();
408        }
409        if let Some(state_stats_blocklist) = config_map.get("units.state_stats.blocklist") {
410            config.units.state_stats_blocklist = state_stats_blocklist
411                .keys()
412                .map(|s| s.to_string())
413                .collect();
414        }
415        config.units.state_stats_time_in_state =
416            read_config_bool(&ini_config, "units", "state_stats_time_in_state")?;
417        if let Some(ignore_inactive_oneshot_services) =
418            read_config_optional_bool(&ini_config, "units", "ignore_inactive_oneshot_services")?
419        {
420            config.units.ignore_inactive_oneshot_services = ignore_inactive_oneshot_services;
421        }
422        if let Some(unit_files) = read_config_optional_bool(&ini_config, "units", "unit_files")? {
423            config.units.unit_files = unit_files;
424        }
425        if let Ok(Some(per_unit_concurrency)) = ini_config.getuint("units", "per_unit_concurrency")
426        {
427            config.units.per_unit_concurrency = per_unit_concurrency;
428        }
429        if let Ok(Some(slowest_units_count)) = ini_config.getuint("units", "slowest_units_count") {
430            config.units.slowest_units_count = slowest_units_count;
431        }
432
433        // [machines] section
434        config.machines.enabled = read_config_bool(&ini_config, "machines", "enabled")?;
435        if let Some(varlink) = read_config_optional_bool(&ini_config, "machines", "varlink")? {
436            config.machines.varlink = varlink;
437        }
438        if let Some(machines_allowlist) = config_map.get("machines.allowlist") {
439            config.machines.allowlist = machines_allowlist.keys().map(|s| s.to_string()).collect();
440        }
441        if let Some(machines_blocklist) = config_map.get("machines.blocklist") {
442            config.machines.blocklist = machines_blocklist.keys().map(|s| s.to_string()).collect();
443        }
444
445        // [dbus] section
446        config.dbus_stats.enabled = read_config_bool(&ini_config, "dbus", "enabled")?;
447        if let Some(stale_fd_stats) =
448            read_config_optional_bool(&ini_config, "dbus", "stale_fd_stats")?
449        {
450            config.dbus_stats.stale_fd_stats = stale_fd_stats;
451        }
452
453        config.dbus_stats.user_stats = read_config_bool(&ini_config, "dbus", "user_stats")?;
454        if let Some(user_allowlist) = config_map.get("dbus.user.allowlist") {
455            config.dbus_stats.user_allowlist =
456                user_allowlist.keys().map(|s| s.to_string()).collect();
457        }
458        if let Some(user_blocklist) = config_map.get("dbus.user.blocklist") {
459            config.dbus_stats.user_blocklist =
460                user_blocklist.keys().map(|s| s.to_string()).collect();
461        }
462
463        config.dbus_stats.peer_stats = read_config_bool(&ini_config, "dbus", "peer_stats")?;
464        config.dbus_stats.peer_well_known_names_only =
465            read_config_bool(&ini_config, "dbus", "peer_well_known_names_only")?;
466        if let Some(peer_allowlist) = config_map.get("dbus.peer.allowlist") {
467            config.dbus_stats.peer_allowlist =
468                peer_allowlist.keys().map(|s| s.to_string()).collect();
469        }
470        if let Some(peer_blocklist) = config_map.get("dbus.peer.blocklist") {
471            config.dbus_stats.peer_blocklist =
472                peer_blocklist.keys().map(|s| s.to_string()).collect();
473        }
474        if let Ok(Some(peer_name_concurrency)) = ini_config.getuint("dbus", "peer_name_concurrency")
475        {
476            config.dbus_stats.peer_name_concurrency = peer_name_concurrency;
477        }
478
479        config.dbus_stats.cgroup_stats = read_config_bool(&ini_config, "dbus", "cgroup_stats")?;
480        if let Some(cgroup_allowlist) = config_map.get("dbus.cgroup.allowlist") {
481            config.dbus_stats.cgroup_allowlist =
482                cgroup_allowlist.keys().map(|s| s.to_string()).collect();
483        }
484        if let Some(cgroup_blocklist) = config_map.get("dbus.cgroup.blocklist") {
485            config.dbus_stats.cgroup_blocklist =
486                cgroup_blocklist.keys().map(|s| s.to_string()).collect();
487        }
488
489        // [boot] section
490        config.boot_blame.enabled = read_config_bool(&ini_config, "boot", "enabled")?;
491        if let Some(varlink) = read_config_optional_bool(&ini_config, "boot", "varlink")? {
492            config.boot_blame.varlink = varlink;
493        }
494        if let Some(cache_enabled) =
495            read_config_optional_bool(&ini_config, "boot", "cache_enabled")?
496        {
497            config.boot_blame.cache_enabled = cache_enabled;
498        }
499        if let Some(cache_dir) = ini_config.get("boot", "cache_dir") {
500            config.boot_blame.cache_dir = cache_dir;
501        }
502        if let Ok(Some(num_slowest_units)) = ini_config.getuint("boot", "num_slowest_units") {
503            config.boot_blame.num_slowest_units = num_slowest_units;
504        }
505        if let Some(boot_allowlist) = config_map.get("boot.allowlist") {
506            config.boot_blame.allowlist = boot_allowlist.keys().map(|s| s.to_string()).collect();
507        }
508        if let Some(boot_blocklist) = config_map.get("boot.blocklist") {
509            config.boot_blame.blocklist = boot_blocklist.keys().map(|s| s.to_string()).collect();
510        }
511
512        // [verify] section
513        config.verify.enabled = read_config_bool(&ini_config, "verify", "enabled")?;
514        if let Some(varlink) = read_config_optional_bool(&ini_config, "verify", "varlink")? {
515            config.verify.varlink = varlink;
516        }
517        if let Some(verify_allowlist) = config_map.get("verify.allowlist") {
518            config.verify.allowlist = verify_allowlist.keys().map(|s| s.to_string()).collect();
519        }
520        if let Some(verify_blocklist) = config_map.get("verify.blocklist") {
521            config.verify.blocklist = verify_blocklist.keys().map(|s| s.to_string()).collect();
522        }
523
524        // [varlink] section
525        config.varlink.enabled = read_config_bool(&ini_config, "varlink", "enabled")?;
526        if let Some(no_fallback) = read_config_optional_bool(&ini_config, "varlink", "no_fallback")?
527        {
528            config.varlink.no_fallback = no_fallback;
529        }
530        // no_fallback only fires inside varlink code paths, so with the
531        // master switch off it is a silent no-op: warn rather than let a
532        // fully green all-D-Bus run pose as varlink-clean.
533        if config.varlink.no_fallback && !config.varlink.enabled {
534            warn!(
535                "[varlink] no_fallback=true has no effect while [varlink] enabled=false; \
536                 collectors will use D-Bus without a word"
537            );
538        }
539
540        for entry in unknown_config_entries(&config_map) {
541            warn!("Ignoring {entry}; check for a typo'd key or section");
542        }
543
544        Ok(config)
545    }
546}
547
548/// Fixed-key sections and every key monitord reads from each.
549///
550/// Data sections (`[services]`, `*.allowlist`, `*.blocklist`) take arbitrary
551/// entries and are exempt; anything else unknown is warned about so a typo'd
552/// key parses loudly instead of silently doing nothing.
553const KNOWN_SECTION_KEYS: &[(&str, &[&str])] = &[
554    (
555        "monitord",
556        &[
557            "dbus_address",
558            "dbus_timeout",
559            "daemon",
560            "daemon_stats_refresh_secs",
561            "key_prefix",
562            "output_format",
563        ],
564    ),
565    ("networkd", &["enabled", "varlink", "link_state_dir"]),
566    ("pid1", &["enabled"]),
567    ("system-state", &["enabled", "varlink"]),
568    ("timers", &["enabled"]),
569    (
570        "units",
571        &[
572            "enabled",
573            "varlink",
574            "state_stats",
575            "state_stats_time_in_state",
576            "ignore_inactive_oneshot_services",
577            "unit_files",
578            "per_unit_concurrency",
579            "slowest_units_count",
580        ],
581    ),
582    ("machines", &["enabled", "varlink"]),
583    (
584        "dbus",
585        &[
586            "enabled",
587            "stale_fd_stats",
588            "user_stats",
589            "peer_stats",
590            "peer_well_known_names_only",
591            "peer_name_concurrency",
592            "cgroup_stats",
593        ],
594    ),
595    (
596        "boot",
597        &[
598            "enabled",
599            "varlink",
600            "cache_enabled",
601            "cache_dir",
602            "num_slowest_units",
603        ],
604    ),
605    ("verify", &["enabled", "varlink"]),
606    ("varlink", &["enabled", "no_fallback"]),
607];
608
609/// Sections whose entries are data (unit/machine names), not fixed keys.
610const DATA_SECTIONS: &[&str] = &[
611    "services",
612    "timers.allowlist",
613    "timers.blocklist",
614    "units.state_stats.allowlist",
615    "units.state_stats.blocklist",
616    "machines.allowlist",
617    "machines.blocklist",
618    "dbus.user.allowlist",
619    "dbus.user.blocklist",
620    "dbus.peer.allowlist",
621    "dbus.peer.blocklist",
622    "dbus.cgroup.allowlist",
623    "dbus.cgroup.blocklist",
624    "boot.allowlist",
625    "boot.blocklist",
626    "verify.allowlist",
627    "verify.blocklist",
628];
629
630/// Unrecognized sections and keys, for warn-on-typo diagnostics.
631///
632/// Pure (returns messages) so tests can assert on it; the caller logs them.
633fn unknown_config_entries(
634    config_map: &IndexMap<String, IndexMap<String, Option<String>>>,
635) -> Vec<String> {
636    let mut unknown = Vec::new();
637    for (section, keys) in config_map {
638        if DATA_SECTIONS.contains(&section.as_str()) {
639            continue;
640        }
641        match KNOWN_SECTION_KEYS.iter().find(|(name, _)| name == section) {
642            None => unknown.push(format!("unknown section [{section}]")),
643            Some((_, known_keys)) => {
644                for key in keys.keys() {
645                    if !known_keys.contains(&key.as_str()) {
646                        unknown.push(format!("unknown key '{key}' in [{section}]"));
647                    }
648                }
649            }
650        }
651    }
652    unknown
653}
654
655/// Helper function to read "bool" config options
656fn read_config_bool(config: &Ini, section: &str, key: &str) -> Result<bool, MonitordConfigError> {
657    let option_bool =
658        config
659            .getbool(section, key)
660            .map_err(|err| MonitordConfigError::InvalidValue {
661                section: section.into(),
662                key: key.into(),
663                reason: err,
664            })?;
665    match option_bool {
666        Some(bool_value) => Ok(bool_value),
667        None => {
668            error!(
669                "No value for '{}' in '{}' section ... assuming false",
670                key, section
671            );
672            Ok(false)
673        }
674    }
675}
676
677/// Helper function to read optional bool config options while preserving field defaults
678fn read_config_optional_bool(
679    config: &Ini,
680    section: &str,
681    key: &str,
682) -> Result<Option<bool>, MonitordConfigError> {
683    config
684        .getbool(section, key)
685        .map_err(|err| MonitordConfigError::InvalidValue {
686            section: section.into(),
687            key: key.into(),
688            reason: err,
689        })
690}
691
692#[cfg(test)]
693mod tests {
694    use std::io::Write;
695
696    use tempfile::NamedTempFile;
697
698    use super::*;
699
700    const FULL_CONFIG: &str = r###"
701[monitord]
702dbus_address = unix:path=/system_bus_socket
703dbus_timeout = 2
704daemon = true
705daemon_stats_refresh_secs = 0
706key_prefix = unittest
707output_format = json-pretty
708
709[networkd]
710enabled = true
711varlink = false
712link_state_dir = /links
713
714[pid1]
715enabled = true
716
717[services]
718foo.service
719bar.service
720
721[system-state]
722enabled = true
723varlink = false
724
725[timers]
726enabled = true
727
728[timers.allowlist]
729foo.timer
730
731[timers.blocklist]
732bar.timer
733
734[units]
735enabled = true
736varlink = false
737state_stats = true
738state_stats_time_in_state = true
739ignore_inactive_oneshot_services = true
740unit_files = true
741per_unit_concurrency = 16
742slowest_units_count = 3
743
744[units.state_stats.allowlist]
745foo.service
746
747[units.state_stats.blocklist]
748bar.service
749
750[machines]
751enabled = true
752varlink = false
753
754[machines.allowlist]
755foo
756bar
757
758[machines.blocklist]
759foo2
760
761[dbus]
762enabled = true
763stale_fd_stats = true
764user_stats = true
765peer_stats = true
766peer_well_known_names_only = true
767peer_name_concurrency = 12
768cgroup_stats = true
769
770[dbus.user.allowlist]
771foo
772bar
773
774[dbus.user.blocklist]
775foo2
776
777[dbus.peer.allowlist]
778foo
779bar
780
781[dbus.peer.blocklist]
782foo2
783
784[dbus.cgroup.allowlist]
785foo
786bar
787
788[dbus.cgroup.blocklist]
789foo2
790
791[boot]
792enabled = true
793varlink = true
794cache_enabled = false
795cache_dir = /tmp/monitord-test
796num_slowest_units = 10
797
798[boot.allowlist]
799foo.service
800
801[boot.blocklist]
802bar.service
803
804[verify]
805enabled = true
806varlink = false
807
808[varlink]
809enabled = true
810no_fallback = true
811"###;
812
813    const MINIMAL_CONFIG: &str = r###"
814[monitord]
815output_format = json-flat
816"###;
817
818    #[test]
819    fn test_default_config() {
820        assert!(Config::default().units.enabled);
821        // Per-section varlink toggles default to following the global switch
822        let default_config = Config::default();
823        assert!(default_config.units.varlink);
824        assert!(default_config.networkd.varlink);
825        assert!(default_config.system_state.varlink);
826        assert!(default_config.machines.varlink);
827        assert!(default_config.boot_blame.varlink);
828        assert!(default_config.verify.varlink);
829    }
830
831    #[test]
832    fn test_minimal_config() {
833        let mut monitord_config = NamedTempFile::new().expect("Unable to make named tempfile");
834        monitord_config
835            .write_all(MINIMAL_CONFIG.as_bytes())
836            .expect("Unable to write out temp config file");
837
838        let mut ini_config = Ini::new();
839        let _config_map = ini_config
840            .load(monitord_config.path())
841            .expect("Unable to load ini config");
842
843        let expected_config: Config = ini_config.try_into().expect("Failed to parse config");
844        // See our one setting is not the default 'json' enum value
845        assert_eq!(
846            expected_config.monitord.output_format,
847            MonitordOutputFormat::JsonFlat,
848        );
849        // See that one of the enabled bools are false
850        assert!(!expected_config.networkd.enabled);
851        // Boot cache defaults to enabled when not explicitly configured
852        assert!(expected_config.boot_blame.cache_enabled);
853        // Oneshot inactive services are ignored by default
854        assert!(expected_config.units.ignore_inactive_oneshot_services);
855    }
856
857    #[test]
858    fn test_units_ignore_inactive_oneshot_services_override() {
859        let units_override_config = r###"
860[monitord]
861output_format = json
862
863[units]
864ignore_inactive_oneshot_services = false
865"###;
866        let mut monitord_config = NamedTempFile::new().expect("Unable to make named tempfile");
867        monitord_config
868            .write_all(units_override_config.as_bytes())
869            .expect("Unable to write out temp config file");
870
871        let mut ini_config = Ini::new();
872        let _config_map = ini_config
873            .load(monitord_config.path())
874            .expect("Unable to load ini config");
875
876        let parsed_config: Config = ini_config.try_into().expect("Failed to parse config");
877        assert!(!parsed_config.units.ignore_inactive_oneshot_services);
878    }
879
880    #[test]
881    fn test_units_per_unit_concurrency_override() {
882        let units_override_config = r###"
883[monitord]
884output_format = json
885
886[units]
887per_unit_concurrency = 32
888slowest_units_count = 0
889"###;
890        let mut monitord_config = NamedTempFile::new().expect("Unable to make named tempfile");
891        monitord_config
892            .write_all(units_override_config.as_bytes())
893            .expect("Unable to write out temp config file");
894
895        let mut ini_config = Ini::new();
896        let _config_map = ini_config
897            .load(monitord_config.path())
898            .expect("Unable to load ini config");
899
900        let parsed_config: Config = ini_config.try_into().expect("Failed to parse config");
901        assert_eq!(parsed_config.units.per_unit_concurrency, 32);
902        assert_eq!(parsed_config.units.slowest_units_count, 0);
903    }
904
905    #[test]
906    fn test_use_varlink_conjunction() {
907        // The conjunction is the point of the per-section toggles: every
908        // gate must agree, so a global-off with sections on stays off, and
909        // any single opt-out disables its collector (plus the container
910        // triple-AND through machines.varlink).
911        let mut config = Config::default();
912        assert!(!config.use_varlink(&[true]));
913        config.varlink.enabled = true;
914        assert!(config.use_varlink(&[true]));
915        assert!(!config.use_varlink(&[false]));
916        assert!(!config.use_varlink(&[true, false]));
917        assert!(config.use_varlink(&[true, true]));
918        config.varlink.enabled = false;
919        assert!(!config.use_varlink(&[true, true]));
920    }
921
922    #[test]
923    fn test_unknown_config_entries() {
924        // Typo'd keys and sections parse clean, so they must at least warn:
925        // [timers] varlink is the trap (timers follow [units]), and data
926        // sections must stay exempt since their entries are unit names.
927        let typo_config = r###"
928[monitord]
929output_format = json
930
931[units]
932varlnik = false
933
934[timers]
935varlink = false
936
937[services]
938foo.service
939
940[bogus]
941key = value
942"###;
943        let mut monitord_config = NamedTempFile::new().expect("Unable to make named tempfile");
944        monitord_config
945            .write_all(typo_config.as_bytes())
946            .expect("Unable to write out temp config file");
947
948        let mut ini_config = Ini::new();
949        ini_config
950            .load(monitord_config.path())
951            .expect("Unable to load ini config");
952
953        let unknown = unknown_config_entries(&ini_config.get_map().expect("config map"));
954        assert!(unknown.contains(&"unknown key 'varlnik' in [units]".to_string()));
955        assert!(unknown.contains(&"unknown key 'varlink' in [timers]".to_string()));
956        assert!(unknown.contains(&"unknown section [bogus]".to_string()));
957        assert_eq!(unknown.len(), 3);
958    }
959
960    #[test]
961    fn test_no_fallback_parses_and_defaults_off() {
962        // Explicit true parses; absence keeps the derived Default (false),
963        // so stock configs without the key behave exactly as before.
964        let on = r###"
965[monitord]
966output_format = json
967
968[varlink]
969enabled = true
970no_fallback = true
971"###;
972        let mut f = NamedTempFile::new().expect("tempfile");
973        f.write_all(on.as_bytes()).expect("write");
974        let mut ini = Ini::new();
975        ini.load(f.path()).expect("load");
976        let parsed: Config = ini.try_into().expect("parse");
977        assert!(parsed.varlink.no_fallback);
978
979        let off = r###"
980[monitord]
981output_format = json
982
983[varlink]
984enabled = true
985"###;
986        let mut f = NamedTempFile::new().expect("tempfile");
987        f.write_all(off.as_bytes()).expect("write");
988        let mut ini = Ini::new();
989        ini.load(f.path()).expect("load");
990        let parsed: Config = ini.try_into().expect("parse");
991        assert!(!parsed.varlink.no_fallback);
992    }
993
994    #[test]
995    fn test_per_section_varlink_toggle_defaults_and_override() {
996        // Only [units] opts out; every other section keeps the default true,
997        // so collectors can move to varlink one at a time.
998        let varlink_override_config = r###"
999[monitord]
1000output_format = json
1001
1002[varlink]
1003enabled = true
1004
1005[units]
1006varlink = false
1007"###;
1008        let mut monitord_config = NamedTempFile::new().expect("Unable to make named tempfile");
1009        monitord_config
1010            .write_all(varlink_override_config.as_bytes())
1011            .expect("Unable to write out temp config file");
1012
1013        let mut ini_config = Ini::new();
1014        let _config_map = ini_config
1015            .load(monitord_config.path())
1016            .expect("Unable to load ini config");
1017
1018        let parsed_config: Config = ini_config.try_into().expect("Failed to parse config");
1019        assert!(parsed_config.varlink.enabled);
1020        assert!(!parsed_config.units.varlink);
1021        assert!(parsed_config.networkd.varlink);
1022        assert!(parsed_config.system_state.varlink);
1023        assert!(parsed_config.machines.varlink);
1024        assert!(parsed_config.boot_blame.varlink);
1025        assert!(parsed_config.verify.varlink);
1026    }
1027
1028    #[test]
1029    fn test_full_config() {
1030        let expected_config = Config {
1031            monitord: MonitordConfig {
1032                dbus_address: String::from("unix:path=/system_bus_socket"),
1033                daemon: true,
1034                daemon_stats_refresh_secs: u64::MIN,
1035                key_prefix: String::from("unittest"),
1036                output_format: MonitordOutputFormat::JsonPretty,
1037                dbus_timeout: 2 as u64,
1038            },
1039            networkd: NetworkdConfig {
1040                enabled: true,
1041                varlink: false,
1042                link_state_dir: "/links".into(),
1043            },
1044            pid1: Pid1Config { enabled: true },
1045            services: HashSet::from([String::from("foo.service"), String::from("bar.service")]),
1046            system_state: SystemStateConfig {
1047                enabled: true,
1048                varlink: false,
1049            },
1050            timers: TimersConfig {
1051                enabled: true,
1052                allowlist: HashSet::from([String::from("foo.timer")]),
1053                blocklist: HashSet::from([String::from("bar.timer")]),
1054            },
1055            units: UnitsConfig {
1056                enabled: true,
1057                varlink: false,
1058                state_stats: true,
1059                state_stats_allowlist: HashSet::from([String::from("foo.service")]),
1060                state_stats_blocklist: HashSet::from([String::from("bar.service")]),
1061                state_stats_time_in_state: true,
1062                ignore_inactive_oneshot_services: true,
1063                unit_files: true,
1064                per_unit_concurrency: 16,
1065                slowest_units_count: 3,
1066            },
1067            machines: MachinesConfig {
1068                enabled: true,
1069                varlink: false,
1070                allowlist: HashSet::from([String::from("foo"), String::from("bar")]),
1071                blocklist: HashSet::from([String::from("foo2")]),
1072            },
1073            dbus_stats: DBusStatsConfig {
1074                enabled: true,
1075                stale_fd_stats: true,
1076                user_stats: true,
1077                user_allowlist: HashSet::from([String::from("foo"), String::from("bar")]),
1078                user_blocklist: HashSet::from([String::from("foo2")]),
1079                peer_stats: true,
1080                peer_well_known_names_only: true,
1081                peer_allowlist: HashSet::from([String::from("foo"), String::from("bar")]),
1082                peer_blocklist: HashSet::from([String::from("foo2")]),
1083                peer_name_concurrency: 12,
1084                cgroup_stats: true,
1085                cgroup_allowlist: HashSet::from([String::from("foo"), String::from("bar")]),
1086                cgroup_blocklist: HashSet::from([String::from("foo2")]),
1087            },
1088            boot_blame: BootBlameConfig {
1089                enabled: true,
1090                // Explicit true (the other sections pin false): proves the
1091                // key is actually read rather than the default shining
1092                // through.
1093                varlink: true,
1094                cache_enabled: false,
1095                cache_dir: "/tmp/monitord-test".to_string(),
1096                num_slowest_units: 10,
1097                allowlist: HashSet::from([String::from("foo.service")]),
1098                blocklist: HashSet::from([String::from("bar.service")]),
1099            },
1100            verify: VerifyConfig {
1101                enabled: true,
1102                varlink: false,
1103                allowlist: HashSet::new(),
1104                blocklist: HashSet::new(),
1105            },
1106            varlink: VarlinkConfig {
1107                enabled: true,
1108                no_fallback: true,
1109            },
1110        };
1111
1112        let mut monitord_config = NamedTempFile::new().expect("Unable to make named tempfile");
1113        monitord_config
1114            .write_all(FULL_CONFIG.as_bytes())
1115            .expect("Unable to write out temp config file");
1116
1117        let mut ini_config = Ini::new();
1118        let _config_map = ini_config
1119            .load(monitord_config.path())
1120            .expect("Unable to load ini config");
1121
1122        // See everything set / overloaded ...
1123        let actual_config: Config = ini_config.try_into().expect("Failed to parse config");
1124        assert_eq!(expected_config, actual_config);
1125    }
1126
1127    #[test]
1128    fn test_invalid_config_returns_error() {
1129        let invalid_config = "[monitord]\ndaemon = notabool\noutput_format = json\n";
1130        let mut monitord_config = NamedTempFile::new().expect("Unable to make named tempfile");
1131        monitord_config
1132            .write_all(invalid_config.as_bytes())
1133            .expect("Unable to write out temp config file");
1134
1135        let mut ini_config = Ini::new();
1136        let _config_map = ini_config
1137            .load(monitord_config.path())
1138            .expect("Unable to load ini config");
1139
1140        let result: Result<Config, _> = ini_config.try_into();
1141        assert!(result.is_err());
1142    }
1143}