Skip to main content

monitord/
varlink_units.rs

1//! # units module
2//!
3//! All main systemd unit statistics. Counts of types of units, unit states and
4//! queued jobs. We also house service specific statistics and system unit states.
5
6use std::str::FromStr;
7use std::sync::Arc;
8
9use tokio::sync::RwLock;
10use tracing::debug;
11
12use tracing::warn;
13
14use crate::unit_constants::{is_unit_unhealthy, SystemdUnitActiveState, SystemdUnitLoadState};
15use crate::units::SystemdUnitStats;
16use crate::varlink::metrics::{ListOutput, Metrics};
17use crate::MachineStats;
18use futures_util::stream::TryStreamExt;
19use zlink::unix;
20
21pub const METRICS_SOCKET_PATH: &str = "/run/systemd/report/io.systemd.Manager";
22
23/// Parse a string value from a metric into an enum type, warning on failure
24fn parse_metric_enum<T: FromStr>(metric: &ListOutput) -> Option<T> {
25    if !metric.value().is_string() {
26        warn!(
27            "Metric {} has non-string value: {:?}",
28            metric.name(),
29            metric.value()
30        );
31        return None;
32    }
33    let value_str = metric.value_as_string();
34    // Normalize hyphens to underscores to match enum variant names (e.g. "not-found" -> "not_found"),
35    // mirroring the same replacement done in the D-Bus path (units.rs::parse_state).
36    let normalized = value_str.replace('-', "_");
37    match T::from_str(&normalized) {
38        Ok(v) => Some(v),
39        Err(_) => {
40            warn!(
41                "Metric {} has unrecognized value: {:?}",
42                metric.name(),
43                value_str
44            );
45            None
46        }
47    }
48}
49
50/// Check if a unit name should be skipped based on allowlist/blocklist
51fn should_skip_unit(object_name: &str, config: &crate::config::UnitsConfig) -> bool {
52    if config.state_stats_blocklist.contains(object_name) {
53        debug!("Skipping state stats for {} due to blocklist", object_name);
54        return true;
55    }
56    if !config.state_stats_allowlist.is_empty()
57        && !config.state_stats_allowlist.contains(object_name)
58    {
59        return true;
60    }
61    false
62}
63
64/// Parse state of a unit into our unit_states hash
65pub fn parse_one_metric(
66    stats: &mut SystemdUnitStats,
67    metric: &ListOutput,
68    config: &crate::config::UnitsConfig,
69) -> anyhow::Result<()> {
70    let metric_name_suffix = metric.name_suffix();
71    let object_name = metric.object_name();
72
73    match metric_name_suffix {
74        "UnitActiveState" => {
75            if !config.state_stats || should_skip_unit(&object_name, config) {
76                return Ok(());
77            }
78            let active_state: SystemdUnitActiveState = match parse_metric_enum(metric) {
79                Some(v) => v,
80                None => return Ok(()),
81            };
82            let unit_state = stats
83                .unit_states
84                .entry(object_name.to_string())
85                .or_default();
86            unit_state.active_state = active_state;
87            unit_state.unhealthy =
88                is_unit_unhealthy(unit_state.active_state, unit_state.load_state);
89        }
90        "UnitLoadState" => {
91            let load_state: SystemdUnitLoadState = match parse_metric_enum(metric) {
92                Some(v) => v,
93                None => return Ok(()),
94            };
95            // Always count aggregate load state totals, matching D-Bus parse_unit() behaviour
96            // which counts every unit regardless of the state_stats allowlist.
97            match load_state {
98                SystemdUnitLoadState::loaded => stats.loaded_units += 1,
99                SystemdUnitLoadState::masked => stats.masked_units += 1,
100                SystemdUnitLoadState::not_found => stats.not_found_units += 1,
101                _ => {}
102            }
103            // Per-unit state tracking is gated by config.
104            if !config.state_stats || should_skip_unit(&object_name, config) {
105                return Ok(());
106            }
107            let unit_state = stats
108                .unit_states
109                .entry(object_name.to_string())
110                .or_default();
111            unit_state.load_state = load_state;
112            unit_state.unhealthy =
113                is_unit_unhealthy(unit_state.active_state, unit_state.load_state);
114        }
115        "NRestarts" => {
116            if !config.state_stats || should_skip_unit(&object_name, config) {
117                return Ok(());
118            }
119            if !metric.value().is_i64() {
120                warn!(
121                    "Metric {} has non-integer value: {:?}",
122                    metric.name(),
123                    metric.value()
124                );
125                return Ok(());
126            }
127            let value = metric.value_as_int();
128            let nrestarts: u32 = match value.try_into() {
129                Ok(v) => v,
130                Err(_) => {
131                    warn!(
132                        "Metric {} has out-of-range value for u32: {}",
133                        metric.name(),
134                        value
135                    );
136                    return Ok(());
137                }
138            };
139            stats
140                .service_stats
141                .entry(object_name.to_string())
142                .or_default()
143                .nrestarts = nrestarts;
144        }
145        "UnitsByTypeTotal" => {
146            if let Some(type_str) = metric.get_field_as_str("type") {
147                if !metric.value().is_i64() {
148                    warn!(
149                        "Metric {} has non-integer value: {:?}",
150                        metric.name(),
151                        metric.value()
152                    );
153                    return Ok(());
154                }
155                let value = metric.value_as_int();
156                let value: u64 = match value.try_into() {
157                    Ok(v) => v,
158                    Err(_) => {
159                        warn!("Metric {} has negative value: {}", metric.name(), value);
160                        return Ok(());
161                    }
162                };
163                match type_str {
164                    "automount" => stats.automount_units = value,
165                    "device" => stats.device_units = value,
166                    "mount" => stats.mount_units = value,
167                    "path" => stats.path_units = value,
168                    "scope" => stats.scope_units = value,
169                    "service" => stats.service_units = value,
170                    "slice" => stats.slice_units = value,
171                    "socket" => stats.socket_units = value,
172                    "target" => stats.target_units = value,
173                    "timer" => stats.timer_units = value,
174                    _ => debug!("Found unhandled unit type: {:?}", type_str),
175                }
176            }
177        }
178        "UnitsByStateTotal" => {
179            if let Some(state_str) = metric.get_field_as_str("state") {
180                if !metric.value().is_i64() {
181                    warn!(
182                        "Metric {} has non-integer value: {:?}",
183                        metric.name(),
184                        metric.value()
185                    );
186                    return Ok(());
187                }
188                let value = metric.value_as_int();
189                let value: u64 = match value.try_into() {
190                    Ok(v) => v,
191                    Err(_) => {
192                        warn!("Metric {} has negative value: {}", metric.name(), value);
193                        return Ok(());
194                    }
195                };
196                match state_str {
197                    "active" => stats.active_units = value,
198                    "failed" => stats.failed_units = value,
199                    "inactive" => stats.inactive_units = value,
200                    _ => debug!("Found unhandled unit state: {:?}", state_str),
201                }
202            }
203        }
204        _ => debug!("Found unhandled metric: {:?}", metric.name()),
205    }
206
207    Ok(())
208}
209
210/// Collect all metrics from the varlink socket.
211/// Runs on a blocking thread with a dedicated runtime because the zlink
212/// stream is !Send and cannot be held across await points in a Send future.
213async fn collect_metrics(socket_path: String) -> anyhow::Result<Vec<ListOutput>> {
214    tokio::task::spawn_blocking(move || {
215        let rt = tokio::runtime::Builder::new_current_thread()
216            .enable_all()
217            .build()?;
218        rt.block_on(async move {
219            let mut conn = unix::connect(&socket_path).await?;
220            let stream = conn.list().await?;
221            futures_util::pin_mut!(stream);
222
223            let mut metrics = Vec::new();
224            let mut count = 0;
225            while let Some(result) = stream.try_next().await? {
226                let result: std::result::Result<ListOutput, _> = result;
227                match result {
228                    Ok(metric) => {
229                        debug!("Metrics {}: {:?}", count, metric);
230                        count += 1;
231                        metrics.push(metric);
232                    }
233                    Err(e) => {
234                        debug!("Error deserializing metric {}: {:?}", count, e);
235                        return Err(anyhow::anyhow!(e));
236                    }
237                }
238            }
239            Ok(metrics)
240        })
241    })
242    .await?
243}
244
245pub async fn parse_metrics(
246    stats: &mut SystemdUnitStats,
247    socket_path: &str,
248    config: &crate::config::UnitsConfig,
249) -> anyhow::Result<()> {
250    let metrics = collect_metrics(socket_path.to_string()).await?;
251
252    for metric in &metrics {
253        parse_one_metric(stats, metric, config)?;
254    }
255
256    Ok(())
257}
258
259pub async fn get_unit_stats(
260    config: &crate::config::Config,
261    socket_path: &str,
262) -> anyhow::Result<SystemdUnitStats> {
263    if !config.units.state_stats_allowlist.is_empty() {
264        debug!(
265            "Using unit state allowlist: {:?}",
266            config.units.state_stats_allowlist
267        );
268    }
269
270    if !config.units.state_stats_blocklist.is_empty() {
271        debug!(
272            "Using unit state blocklist: {:?}",
273            config.units.state_stats_blocklist,
274        );
275    }
276
277    let mut stats = SystemdUnitStats::default();
278
279    // Always collect metrics to get aggregate counts (UnitsByTypeTotal, UnitsByStateTotal)
280    // as well as per-unit state data when config.units.state_stats is enabled.
281    parse_metrics(&mut stats, socket_path, &config.units).await?;
282
283    // Derive total_units from the sum of all per-type counts, mirroring what the D-Bus
284    // path computes as `units.len()` from list_units().
285    stats.total_units = stats.automount_units
286        + stats.device_units
287        + stats.mount_units
288        + stats.path_units
289        + stats.scope_units
290        + stats.service_units
291        + stats.slice_units
292        + stats.socket_units
293        + stats.target_units
294        + stats.timer_units;
295
296    debug!("unit stats: {:?}", stats);
297    Ok(stats)
298}
299
300/// Async wrapper that can update unit stats when passed a locked struct.
301pub async fn update_unit_stats(
302    config: Arc<crate::config::Config>,
303    locked_machine_stats: Arc<RwLock<MachineStats>>,
304    socket_path: String,
305) -> anyhow::Result<()> {
306    let units_stats = get_unit_stats(&config, &socket_path).await?;
307    let mut machine_stats = locked_machine_stats.write().await;
308    machine_stats.units = units_stats;
309    Ok(())
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use std::collections::HashSet;
316
317    fn string_value(s: &str) -> serde_json::Value {
318        serde_json::json!(s)
319    }
320
321    fn int_value(i: i64) -> serde_json::Value {
322        serde_json::json!(i)
323    }
324
325    fn empty_value() -> serde_json::Value {
326        serde_json::Value::Null
327    }
328
329    fn default_units_config() -> crate::config::UnitsConfig {
330        crate::config::UnitsConfig {
331            enabled: true,
332            state_stats: true,
333            state_stats_allowlist: HashSet::new(),
334            state_stats_blocklist: HashSet::new(),
335            state_stats_time_in_state: false,
336        }
337    }
338
339    #[tokio::test]
340    async fn test_parse_one_metric_unit_active_state() {
341        let mut stats = SystemdUnitStats::default();
342        let config = default_units_config();
343
344        let metric = ListOutput {
345            name: "io.systemd.Manager.UnitActiveState".to_string(),
346            value: string_value("active"),
347            object: Some("my-service.service".to_string()),
348            fields: None,
349        };
350
351        parse_one_metric(&mut stats, &metric, &config).unwrap();
352
353        assert_eq!(
354            stats
355                .unit_states
356                .get("my-service.service")
357                .unwrap()
358                .active_state,
359            SystemdUnitActiveState::active
360        );
361    }
362
363    #[tokio::test]
364    async fn test_parse_one_metric_unit_load_state() {
365        let mut stats = SystemdUnitStats::default();
366        let config = default_units_config();
367
368        // systemd sends "not-found" with a hyphen over both D-Bus and varlink;
369        // parse_metric_enum must normalize it to "not_found" before enum parsing.
370        let metric = ListOutput {
371            name: "io.systemd.Manager.UnitLoadState".to_string(),
372            value: string_value("not-found"),
373            object: Some("missing.service".to_string()),
374            fields: None,
375        };
376
377        parse_one_metric(&mut stats, &metric, &config).unwrap();
378
379        assert_eq!(
380            stats.unit_states.get("missing.service").unwrap().load_state,
381            SystemdUnitLoadState::not_found
382        );
383    }
384
385    #[tokio::test]
386    async fn test_parse_one_metric_nrestarts() {
387        let mut stats = SystemdUnitStats::default();
388        let config = default_units_config();
389
390        let metric = ListOutput {
391            name: "io.systemd.Manager.NRestarts".to_string(),
392            value: int_value(5),
393            object: Some("my-service.service".to_string()),
394            fields: None,
395        };
396
397        parse_one_metric(&mut stats, &metric, &config).unwrap();
398
399        assert_eq!(
400            stats
401                .service_stats
402                .get("my-service.service")
403                .unwrap()
404                .nrestarts,
405            5
406        );
407    }
408
409    #[tokio::test]
410    async fn test_parse_aggregated_metrics() {
411        let mut stats = SystemdUnitStats::default();
412        let config = default_units_config();
413
414        // Test UnitsByTypeTotal
415        let type_metric = ListOutput {
416            name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
417            value: int_value(42),
418            object: None,
419            fields: Some(std::collections::HashMap::from([(
420                "type".to_string(),
421                serde_json::json!("service"),
422            )])),
423        };
424        parse_one_metric(&mut stats, &type_metric, &config).unwrap();
425        assert_eq!(stats.service_units, 42);
426
427        // Test UnitsByStateTotal
428        let state_metric = ListOutput {
429            name: "io.systemd.Manager.UnitsByStateTotal".to_string(),
430            value: int_value(10),
431            object: None,
432            fields: Some(std::collections::HashMap::from([(
433                "state".to_string(),
434                serde_json::json!("active"),
435            )])),
436        };
437        parse_one_metric(&mut stats, &state_metric, &config).unwrap();
438        assert_eq!(stats.active_units, 10);
439    }
440
441    #[tokio::test]
442    async fn test_parse_multiple_units() {
443        let mut stats = SystemdUnitStats::default();
444        let config = default_units_config();
445
446        let metrics = vec![
447            ListOutput {
448                name: "io.systemd.Manager.UnitActiveState".to_string(),
449                value: string_value("active"),
450                object: Some("service1.service".to_string()),
451                fields: None,
452            },
453            ListOutput {
454                name: "io.systemd.Manager.UnitLoadState".to_string(),
455                value: string_value("loaded"),
456                object: Some("service1.service".to_string()),
457                fields: None,
458            },
459            ListOutput {
460                name: "io.systemd.Manager.UnitActiveState".to_string(),
461                value: string_value("failed"),
462                object: Some("service-2.service".to_string()),
463                fields: None,
464            },
465        ];
466
467        for metric in metrics {
468            parse_one_metric(&mut stats, &metric, &config).unwrap();
469        }
470
471        assert_eq!(stats.unit_states.len(), 2);
472        assert_eq!(
473            stats
474                .unit_states
475                .get("service1.service")
476                .unwrap()
477                .active_state,
478            SystemdUnitActiveState::active
479        );
480        assert_eq!(
481            stats
482                .unit_states
483                .get("service1.service")
484                .unwrap()
485                .load_state,
486            SystemdUnitLoadState::loaded
487        );
488        assert_eq!(
489            stats
490                .unit_states
491                .get("service-2.service")
492                .unwrap()
493                .active_state,
494            SystemdUnitActiveState::failed
495        );
496    }
497
498    #[tokio::test]
499    async fn test_parse_unknown_and_missing_values() {
500        let mut stats = SystemdUnitStats::default();
501        let config = default_units_config();
502
503        // Unknown active state is skipped (not silently defaulted)
504        let metric1 = ListOutput {
505            name: "io.systemd.Manager.UnitActiveState".to_string(),
506            value: string_value("invalid_state"),
507            object: Some("test.service".to_string()),
508            fields: None,
509        };
510        parse_one_metric(&mut stats, &metric1, &config).unwrap();
511        assert!(
512            !stats.unit_states.contains_key("test.service"),
513            "invalid state should be skipped"
514        );
515
516        // Missing nrestarts value (null) is skipped
517        let metric2 = ListOutput {
518            name: "io.systemd.Manager.NRestarts".to_string(),
519            value: empty_value(),
520            object: Some("test2.service".to_string()),
521            fields: None,
522        };
523        parse_one_metric(&mut stats, &metric2, &config).unwrap();
524        assert!(
525            !stats.service_stats.contains_key("test2.service"),
526            "null value should be skipped"
527        );
528    }
529
530    #[tokio::test]
531    async fn test_parse_edge_cases() {
532        let mut stats = SystemdUnitStats::default();
533        let config = default_units_config();
534
535        // Unknown unit type is ignored gracefully
536        let metric1 = ListOutput {
537            name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
538            value: int_value(999),
539            object: None,
540            fields: Some(std::collections::HashMap::from([(
541                "type".to_string(),
542                serde_json::json!("unknown_type"),
543            )])),
544        };
545        parse_one_metric(&mut stats, &metric1, &config).unwrap();
546        assert_eq!(stats.service_units, 0);
547
548        // Metric with no fields is handled gracefully
549        let metric2 = ListOutput {
550            name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
551            value: int_value(42),
552            object: None,
553            fields: None,
554        };
555        parse_one_metric(&mut stats, &metric2, &config).unwrap();
556
557        // Non-string field value is ignored
558        let metric3 = ListOutput {
559            name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
560            value: int_value(42),
561            object: None,
562            fields: Some(std::collections::HashMap::from([(
563                "type".to_string(),
564                serde_json::json!(123),
565            )])),
566        };
567        parse_one_metric(&mut stats, &metric3, &config).unwrap();
568
569        // Unhandled metric name is ignored
570        let metric4 = ListOutput {
571            name: "io.systemd.Manager.UnknownMetric".to_string(),
572            value: int_value(999),
573            object: Some("test.service".to_string()),
574            fields: None,
575        };
576        parse_one_metric(&mut stats, &metric4, &config).unwrap();
577    }
578
579    #[test]
580    fn test_state_stats_disabled_skips_per_unit_data() {
581        // When state_stats=false, UnitActiveState / UnitLoadState / NRestarts should be
582        // skipped by parse_one_metric so that unit_states and service_stats remain empty.
583        let config = crate::config::UnitsConfig {
584            enabled: true,
585            state_stats: false,
586            state_stats_allowlist: HashSet::new(),
587            state_stats_blocklist: HashSet::new(),
588            state_stats_time_in_state: true,
589        };
590        let mut stats = SystemdUnitStats::default();
591
592        let active_state_metric = ListOutput {
593            name: "io.systemd.Manager.UnitActiveState".to_string(),
594            value: string_value("active"),
595            object: Some("test.service".to_string()),
596            fields: None,
597        };
598        parse_one_metric(&mut stats, &active_state_metric, &config).unwrap();
599
600        let load_state_metric = ListOutput {
601            name: "io.systemd.Manager.UnitLoadState".to_string(),
602            value: string_value("loaded"),
603            object: Some("test.service".to_string()),
604            fields: None,
605        };
606        parse_one_metric(&mut stats, &load_state_metric, &config).unwrap();
607
608        let nrestarts_metric = ListOutput {
609            name: "io.systemd.Manager.NRestarts".to_string(),
610            value: int_value(3),
611            object: Some("test.service".to_string()),
612            fields: None,
613        };
614        parse_one_metric(&mut stats, &nrestarts_metric, &config).unwrap();
615
616        // Per-unit state data must be absent when state_stats=false
617        assert_eq!(stats.unit_states.len(), 0);
618        assert_eq!(stats.service_stats.len(), 0);
619
620        // But aggregate type/state counts must still be processed (they are not gated on state_stats)
621        let type_metric = ListOutput {
622            name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
623            value: int_value(10),
624            object: None,
625            fields: Some(std::collections::HashMap::from([(
626                "type".to_string(),
627                serde_json::json!("service"),
628            )])),
629        };
630        parse_one_metric(&mut stats, &type_metric, &config).unwrap();
631        assert_eq!(stats.service_units, 10);
632    }
633
634    #[test]
635    fn test_parse_metric_enum() {
636        let metric_active = ListOutput {
637            name: "io.systemd.Manager.UnitActiveState".to_string(),
638            value: string_value("active"),
639            object: Some("test.service".to_string()),
640            fields: None,
641        };
642        assert_eq!(
643            parse_metric_enum::<SystemdUnitActiveState>(&metric_active),
644            Some(SystemdUnitActiveState::active)
645        );
646
647        let metric_loaded = ListOutput {
648            name: "io.systemd.Manager.UnitLoadState".to_string(),
649            value: string_value("loaded"),
650            object: Some("test.service".to_string()),
651            fields: None,
652        };
653        assert_eq!(
654            parse_metric_enum::<SystemdUnitLoadState>(&metric_loaded),
655            Some(SystemdUnitLoadState::loaded)
656        );
657
658        // Invalid value returns None
659        let metric_invalid = ListOutput {
660            name: "io.systemd.Manager.UnitActiveState".to_string(),
661            value: string_value("invalid"),
662            object: Some("test.service".to_string()),
663            fields: None,
664        };
665        assert_eq!(
666            parse_metric_enum::<SystemdUnitActiveState>(&metric_invalid),
667            None
668        );
669
670        // Null value returns None
671        let metric_empty = ListOutput {
672            name: "io.systemd.Manager.UnitActiveState".to_string(),
673            value: empty_value(),
674            object: Some("test.service".to_string()),
675            fields: None,
676        };
677        assert_eq!(
678            parse_metric_enum::<SystemdUnitActiveState>(&metric_empty),
679            None
680        );
681    }
682
683    #[test]
684    fn test_parse_metric_enum_all_states() {
685        // Test all active states
686        let active_states = vec![
687            ("active", SystemdUnitActiveState::active),
688            ("reloading", SystemdUnitActiveState::reloading),
689            ("inactive", SystemdUnitActiveState::inactive),
690            ("failed", SystemdUnitActiveState::failed),
691            ("activating", SystemdUnitActiveState::activating),
692            ("deactivating", SystemdUnitActiveState::deactivating),
693        ];
694
695        for (state_str, expected) in active_states {
696            let metric = ListOutput {
697                name: "io.systemd.Manager.UnitActiveState".to_string(),
698                value: string_value(state_str),
699                object: Some("test.service".to_string()),
700                fields: None,
701            };
702            assert_eq!(
703                parse_metric_enum::<SystemdUnitActiveState>(&metric),
704                Some(expected)
705            );
706        }
707
708        // Test all load states
709        let load_states = vec![
710            ("loaded", SystemdUnitLoadState::loaded),
711            ("error", SystemdUnitLoadState::error),
712            ("masked", SystemdUnitLoadState::masked),
713            ("not_found", SystemdUnitLoadState::not_found),
714        ];
715
716        for (state_str, expected) in load_states {
717            let metric = ListOutput {
718                name: "io.systemd.Manager.UnitLoadState".to_string(),
719                value: string_value(state_str),
720                object: Some("test.service".to_string()),
721                fields: None,
722            };
723            assert_eq!(
724                parse_metric_enum::<SystemdUnitLoadState>(&metric),
725                Some(expected)
726            );
727        }
728    }
729
730    #[tokio::test]
731    async fn test_parse_state_updates() {
732        let mut stats = SystemdUnitStats::default();
733        let config = default_units_config();
734
735        // Parse initial state
736        let metric1 = ListOutput {
737            name: "io.systemd.Manager.UnitActiveState".to_string(),
738            value: string_value("inactive"),
739            object: Some("test.service".to_string()),
740            fields: None,
741        };
742        parse_one_metric(&mut stats, &metric1, &config).unwrap();
743        assert_eq!(
744            stats.unit_states.get("test.service").unwrap().active_state,
745            SystemdUnitActiveState::inactive
746        );
747
748        // Update to active state
749        let metric2 = ListOutput {
750            name: "io.systemd.Manager.UnitActiveState".to_string(),
751            value: string_value("active"),
752            object: Some("test.service".to_string()),
753            fields: None,
754        };
755        parse_one_metric(&mut stats, &metric2, &config).unwrap();
756        assert_eq!(
757            stats.unit_states.get("test.service").unwrap().active_state,
758            SystemdUnitActiveState::active
759        );
760    }
761
762    #[tokio::test]
763    async fn test_unhealthy_computed() {
764        let mut stats = SystemdUnitStats::default();
765        let config = default_units_config();
766
767        // Set active state to failed
768        let metric1 = ListOutput {
769            name: "io.systemd.Manager.UnitActiveState".to_string(),
770            value: string_value("failed"),
771            object: Some("broken.service".to_string()),
772            fields: None,
773        };
774        parse_one_metric(&mut stats, &metric1, &config).unwrap();
775
776        // Set load state to loaded
777        let metric2 = ListOutput {
778            name: "io.systemd.Manager.UnitLoadState".to_string(),
779            value: string_value("loaded"),
780            object: Some("broken.service".to_string()),
781            fields: None,
782        };
783        parse_one_metric(&mut stats, &metric2, &config).unwrap();
784
785        // Should be unhealthy: loaded + failed
786        assert!(stats.unit_states.get("broken.service").unwrap().unhealthy);
787
788        // Set active state to active
789        let metric3 = ListOutput {
790            name: "io.systemd.Manager.UnitActiveState".to_string(),
791            value: string_value("active"),
792            object: Some("healthy.service".to_string()),
793            fields: None,
794        };
795        parse_one_metric(&mut stats, &metric3, &config).unwrap();
796
797        // Set load state to loaded
798        let metric4 = ListOutput {
799            name: "io.systemd.Manager.UnitLoadState".to_string(),
800            value: string_value("loaded"),
801            object: Some("healthy.service".to_string()),
802            fields: None,
803        };
804        parse_one_metric(&mut stats, &metric4, &config).unwrap();
805
806        // Should be healthy: loaded + active
807        assert!(!stats.unit_states.get("healthy.service").unwrap().unhealthy);
808    }
809
810    #[tokio::test]
811    async fn test_allowlist_filtering() {
812        let mut stats = SystemdUnitStats::default();
813        let config = crate::config::UnitsConfig {
814            enabled: true,
815            state_stats: true,
816            state_stats_allowlist: HashSet::from(["allowed.service".to_string()]),
817            state_stats_blocklist: HashSet::new(),
818            state_stats_time_in_state: false,
819        };
820
821        // Allowed unit should be tracked
822        let metric1 = ListOutput {
823            name: "io.systemd.Manager.UnitActiveState".to_string(),
824            value: string_value("active"),
825            object: Some("allowed.service".to_string()),
826            fields: None,
827        };
828        parse_one_metric(&mut stats, &metric1, &config).unwrap();
829        assert!(stats.unit_states.contains_key("allowed.service"));
830
831        // Non-allowed unit should be skipped
832        let metric2 = ListOutput {
833            name: "io.systemd.Manager.UnitActiveState".to_string(),
834            value: string_value("active"),
835            object: Some("not-allowed.service".to_string()),
836            fields: None,
837        };
838        parse_one_metric(&mut stats, &metric2, &config).unwrap();
839        assert!(!stats.unit_states.contains_key("not-allowed.service"));
840    }
841
842    #[tokio::test]
843    async fn test_blocklist_filtering() {
844        let mut stats = SystemdUnitStats::default();
845        let config = crate::config::UnitsConfig {
846            enabled: true,
847            state_stats: true,
848            state_stats_allowlist: HashSet::new(),
849            state_stats_blocklist: HashSet::from(["blocked.service".to_string()]),
850            state_stats_time_in_state: false,
851        };
852
853        // Blocked unit should be skipped
854        let metric1 = ListOutput {
855            name: "io.systemd.Manager.UnitActiveState".to_string(),
856            value: string_value("active"),
857            object: Some("blocked.service".to_string()),
858            fields: None,
859        };
860        parse_one_metric(&mut stats, &metric1, &config).unwrap();
861        assert!(!stats.unit_states.contains_key("blocked.service"));
862
863        // Non-blocked unit should be tracked
864        let metric2 = ListOutput {
865            name: "io.systemd.Manager.UnitActiveState".to_string(),
866            value: string_value("active"),
867            object: Some("ok.service".to_string()),
868            fields: None,
869        };
870        parse_one_metric(&mut stats, &metric2, &config).unwrap();
871        assert!(stats.unit_states.contains_key("ok.service"));
872    }
873
874    #[tokio::test]
875    async fn test_blocklist_overrides_allowlist() {
876        let mut stats = SystemdUnitStats::default();
877        let config = crate::config::UnitsConfig {
878            enabled: true,
879            state_stats: true,
880            state_stats_allowlist: HashSet::from(["both.service".to_string()]),
881            state_stats_blocklist: HashSet::from(["both.service".to_string()]),
882            state_stats_time_in_state: false,
883        };
884
885        // Unit in both lists should be blocked (blocklist takes priority)
886        let metric = ListOutput {
887            name: "io.systemd.Manager.UnitActiveState".to_string(),
888            value: string_value("active"),
889            object: Some("both.service".to_string()),
890            fields: None,
891        };
892        parse_one_metric(&mut stats, &metric, &config).unwrap();
893        assert!(!stats.unit_states.contains_key("both.service"));
894    }
895
896    #[test]
897    fn test_load_state_counts_bypass_allowlist() {
898        // loaded_units/masked_units/not_found_units must be counted for every unit,
899        // regardless of the state_stats allowlist (matching D-Bus parse_unit() behaviour).
900        let config = crate::config::UnitsConfig {
901            enabled: true,
902            state_stats: true,
903            // Only "allowed.service" is in the allowlist
904            state_stats_allowlist: HashSet::from(["allowed.service".to_string()]),
905            state_stats_blocklist: HashSet::new(),
906            state_stats_time_in_state: false,
907        };
908        let mut stats = SystemdUnitStats::default();
909
910        let metrics = vec![
911            // allowed unit → counts AND stored in unit_states
912            ListOutput {
913                name: "io.systemd.Manager.UnitLoadState".to_string(),
914                value: string_value("loaded"),
915                object: Some("allowed.service".to_string()),
916                fields: None,
917            },
918            // non-allowed unit → only counted, NOT stored in unit_states
919            ListOutput {
920                name: "io.systemd.Manager.UnitLoadState".to_string(),
921                value: string_value("loaded"),
922                object: Some("other.service".to_string()),
923                fields: None,
924            },
925            ListOutput {
926                name: "io.systemd.Manager.UnitLoadState".to_string(),
927                value: string_value("not-found"), // systemd sends hyphenated form over the wire
928                object: Some("missing.service".to_string()),
929                fields: None,
930            },
931            ListOutput {
932                name: "io.systemd.Manager.UnitLoadState".to_string(),
933                value: string_value("masked"),
934                object: Some("masked.service".to_string()),
935                fields: None,
936            },
937        ];
938        for m in metrics {
939            parse_one_metric(&mut stats, &m, &config).unwrap();
940        }
941
942        // Aggregate counts include ALL units regardless of allowlist
943        assert_eq!(stats.loaded_units, 2);
944        assert_eq!(stats.not_found_units, 1);
945        assert_eq!(stats.masked_units, 1);
946        // per-unit state only tracked for the allowed unit
947        assert_eq!(stats.unit_states.len(), 1);
948        assert!(stats.unit_states.contains_key("allowed.service"));
949    }
950
951    #[test]
952    fn test_load_state_counts_when_state_stats_disabled() {
953        // Even when state_stats=false, aggregate load state counts must be populated.
954        let config = crate::config::UnitsConfig {
955            enabled: true,
956            state_stats: false,
957            state_stats_allowlist: HashSet::new(),
958            state_stats_blocklist: HashSet::new(),
959            state_stats_time_in_state: false,
960        };
961        let mut stats = SystemdUnitStats::default();
962
963        let metrics = vec![
964            ListOutput {
965                name: "io.systemd.Manager.UnitLoadState".to_string(),
966                value: string_value("loaded"),
967                object: Some("svc1.service".to_string()),
968                fields: None,
969            },
970            ListOutput {
971                name: "io.systemd.Manager.UnitLoadState".to_string(),
972                value: string_value("not-found"), // systemd sends hyphenated form over the wire
973                object: Some("svc2.service".to_string()),
974                fields: None,
975            },
976        ];
977        for m in metrics {
978            parse_one_metric(&mut stats, &m, &config).unwrap();
979        }
980
981        assert_eq!(stats.loaded_units, 1);
982        assert_eq!(stats.not_found_units, 1);
983        // No per-unit state tracking when state_stats=false
984        assert_eq!(stats.unit_states.len(), 0);
985    }
986}