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