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