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        }
386    }
387
388    #[tokio::test]
389    async fn test_parse_one_metric_unit_active_state() {
390        let mut stats = SystemdUnitStats::default();
391        let config = default_units_config();
392
393        let metric = ListOutput {
394            name: "io.systemd.Manager.UnitActiveState".to_string(),
395            value: string_value("active"),
396            object: Some("my-service.service".to_string()),
397            fields: None,
398        };
399
400        parse_one_metric(&mut stats, &metric, &config).unwrap();
401
402        assert_eq!(
403            stats
404                .unit_states
405                .get("my-service.service")
406                .unwrap()
407                .active_state,
408            SystemdUnitActiveState::active
409        );
410    }
411
412    #[tokio::test]
413    async fn test_parse_one_metric_unit_load_state() {
414        let mut stats = SystemdUnitStats::default();
415        let config = default_units_config();
416
417        // systemd sends "not-found" with a hyphen over both D-Bus and varlink;
418        // parse_metric_enum must normalize it to "not_found" before enum parsing.
419        let metric = ListOutput {
420            name: "io.systemd.Manager.UnitLoadState".to_string(),
421            value: string_value("not-found"),
422            object: Some("missing.service".to_string()),
423            fields: None,
424        };
425
426        parse_one_metric(&mut stats, &metric, &config).unwrap();
427
428        assert_eq!(
429            stats.unit_states.get("missing.service").unwrap().load_state,
430            SystemdUnitLoadState::not_found
431        );
432    }
433
434    #[tokio::test]
435    async fn test_parse_one_metric_nrestarts() {
436        let mut stats = SystemdUnitStats::default();
437        let config = default_units_config();
438
439        let metric = ListOutput {
440            name: "io.systemd.Manager.NRestarts".to_string(),
441            value: int_value(5),
442            object: Some("my-service.service".to_string()),
443            fields: None,
444        };
445
446        parse_one_metric(&mut stats, &metric, &config).unwrap();
447
448        assert_eq!(
449            stats
450                .service_stats
451                .get("my-service.service")
452                .unwrap()
453                .nrestarts,
454            5
455        );
456    }
457
458    #[tokio::test]
459    async fn test_parse_aggregated_metrics() {
460        let mut stats = SystemdUnitStats::default();
461        let config = default_units_config();
462
463        // Test UnitsByTypeTotal
464        let type_metric = ListOutput {
465            name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
466            value: int_value(42),
467            object: None,
468            fields: Some(std::collections::HashMap::from([(
469                "type".to_string(),
470                serde_json::json!("service"),
471            )])),
472        };
473        parse_one_metric(&mut stats, &type_metric, &config).unwrap();
474        assert_eq!(stats.service_units, 42);
475
476        // Test UnitsByStateTotal
477        let state_metric = ListOutput {
478            name: "io.systemd.Manager.UnitsByStateTotal".to_string(),
479            value: int_value(10),
480            object: None,
481            fields: Some(std::collections::HashMap::from([(
482                "state".to_string(),
483                serde_json::json!("active"),
484            )])),
485        };
486        parse_one_metric(&mut stats, &state_metric, &config).unwrap();
487        assert_eq!(stats.active_units, 10);
488    }
489
490    #[tokio::test]
491    async fn test_parse_multiple_units() {
492        let mut stats = SystemdUnitStats::default();
493        let config = default_units_config();
494
495        let metrics = vec![
496            ListOutput {
497                name: "io.systemd.Manager.UnitActiveState".to_string(),
498                value: string_value("active"),
499                object: Some("service1.service".to_string()),
500                fields: None,
501            },
502            ListOutput {
503                name: "io.systemd.Manager.UnitLoadState".to_string(),
504                value: string_value("loaded"),
505                object: Some("service1.service".to_string()),
506                fields: None,
507            },
508            ListOutput {
509                name: "io.systemd.Manager.UnitActiveState".to_string(),
510                value: string_value("failed"),
511                object: Some("service-2.service".to_string()),
512                fields: None,
513            },
514        ];
515
516        for metric in metrics {
517            parse_one_metric(&mut stats, &metric, &config).unwrap();
518        }
519
520        assert_eq!(stats.unit_states.len(), 2);
521        assert_eq!(
522            stats
523                .unit_states
524                .get("service1.service")
525                .unwrap()
526                .active_state,
527            SystemdUnitActiveState::active
528        );
529        assert_eq!(
530            stats
531                .unit_states
532                .get("service1.service")
533                .unwrap()
534                .load_state,
535            SystemdUnitLoadState::loaded
536        );
537        assert_eq!(
538            stats
539                .unit_states
540                .get("service-2.service")
541                .unwrap()
542                .active_state,
543            SystemdUnitActiveState::failed
544        );
545    }
546
547    #[tokio::test]
548    async fn test_parse_unknown_and_missing_values() {
549        let mut stats = SystemdUnitStats::default();
550        let config = default_units_config();
551
552        // Unknown active state is skipped (not silently defaulted)
553        let metric1 = ListOutput {
554            name: "io.systemd.Manager.UnitActiveState".to_string(),
555            value: string_value("invalid_state"),
556            object: Some("test.service".to_string()),
557            fields: None,
558        };
559        parse_one_metric(&mut stats, &metric1, &config).unwrap();
560        assert!(
561            !stats.unit_states.contains_key("test.service"),
562            "invalid state should be skipped"
563        );
564
565        // Missing nrestarts value (null) is skipped
566        let metric2 = ListOutput {
567            name: "io.systemd.Manager.NRestarts".to_string(),
568            value: empty_value(),
569            object: Some("test2.service".to_string()),
570            fields: None,
571        };
572        parse_one_metric(&mut stats, &metric2, &config).unwrap();
573        assert!(
574            !stats.service_stats.contains_key("test2.service"),
575            "null value should be skipped"
576        );
577    }
578
579    #[tokio::test]
580    async fn test_parse_edge_cases() {
581        let mut stats = SystemdUnitStats::default();
582        let config = default_units_config();
583
584        // Unknown unit type is ignored gracefully
585        let metric1 = ListOutput {
586            name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
587            value: int_value(999),
588            object: None,
589            fields: Some(std::collections::HashMap::from([(
590                "type".to_string(),
591                serde_json::json!("unknown_type"),
592            )])),
593        };
594        parse_one_metric(&mut stats, &metric1, &config).unwrap();
595        assert_eq!(stats.service_units, 0);
596
597        // Metric with no fields is handled gracefully
598        let metric2 = ListOutput {
599            name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
600            value: int_value(42),
601            object: None,
602            fields: None,
603        };
604        parse_one_metric(&mut stats, &metric2, &config).unwrap();
605
606        // Non-string field value is ignored
607        let metric3 = ListOutput {
608            name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
609            value: int_value(42),
610            object: None,
611            fields: Some(std::collections::HashMap::from([(
612                "type".to_string(),
613                serde_json::json!(123),
614            )])),
615        };
616        parse_one_metric(&mut stats, &metric3, &config).unwrap();
617
618        // Unhandled metric name is ignored
619        let metric4 = ListOutput {
620            name: "io.systemd.Manager.UnknownMetric".to_string(),
621            value: int_value(999),
622            object: Some("test.service".to_string()),
623            fields: None,
624        };
625        parse_one_metric(&mut stats, &metric4, &config).unwrap();
626    }
627
628    #[test]
629    fn test_state_stats_disabled_skips_per_unit_data() {
630        // When state_stats=false, UnitActiveState / UnitLoadState / NRestarts should be
631        // skipped by parse_one_metric so that unit_states and service_stats remain empty.
632        let config = crate::config::UnitsConfig {
633            enabled: true,
634            state_stats: false,
635            state_stats_allowlist: HashSet::new(),
636            state_stats_blocklist: HashSet::new(),
637            state_stats_time_in_state: true,
638            ignore_inactive_oneshot_services: true,
639            unit_files: true,
640        };
641        let mut stats = SystemdUnitStats::default();
642
643        let active_state_metric = ListOutput {
644            name: "io.systemd.Manager.UnitActiveState".to_string(),
645            value: string_value("active"),
646            object: Some("test.service".to_string()),
647            fields: None,
648        };
649        parse_one_metric(&mut stats, &active_state_metric, &config).unwrap();
650
651        let load_state_metric = ListOutput {
652            name: "io.systemd.Manager.UnitLoadState".to_string(),
653            value: string_value("loaded"),
654            object: Some("test.service".to_string()),
655            fields: None,
656        };
657        parse_one_metric(&mut stats, &load_state_metric, &config).unwrap();
658
659        let nrestarts_metric = ListOutput {
660            name: "io.systemd.Manager.NRestarts".to_string(),
661            value: int_value(3),
662            object: Some("test.service".to_string()),
663            fields: None,
664        };
665        parse_one_metric(&mut stats, &nrestarts_metric, &config).unwrap();
666
667        // Per-unit state data must be absent when state_stats=false
668        assert_eq!(stats.unit_states.len(), 0);
669        assert_eq!(stats.service_stats.len(), 0);
670
671        // But aggregate type/state counts must still be processed (they are not gated on state_stats)
672        let type_metric = ListOutput {
673            name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
674            value: int_value(10),
675            object: None,
676            fields: Some(std::collections::HashMap::from([(
677                "type".to_string(),
678                serde_json::json!("service"),
679            )])),
680        };
681        parse_one_metric(&mut stats, &type_metric, &config).unwrap();
682        assert_eq!(stats.service_units, 10);
683    }
684
685    #[test]
686    fn test_parse_metric_enum() {
687        let metric_active = ListOutput {
688            name: "io.systemd.Manager.UnitActiveState".to_string(),
689            value: string_value("active"),
690            object: Some("test.service".to_string()),
691            fields: None,
692        };
693        assert_eq!(
694            parse_metric_enum::<SystemdUnitActiveState>(&metric_active),
695            Some(SystemdUnitActiveState::active)
696        );
697
698        let metric_loaded = ListOutput {
699            name: "io.systemd.Manager.UnitLoadState".to_string(),
700            value: string_value("loaded"),
701            object: Some("test.service".to_string()),
702            fields: None,
703        };
704        assert_eq!(
705            parse_metric_enum::<SystemdUnitLoadState>(&metric_loaded),
706            Some(SystemdUnitLoadState::loaded)
707        );
708
709        // Invalid value returns None
710        let metric_invalid = ListOutput {
711            name: "io.systemd.Manager.UnitActiveState".to_string(),
712            value: string_value("invalid"),
713            object: Some("test.service".to_string()),
714            fields: None,
715        };
716        assert_eq!(
717            parse_metric_enum::<SystemdUnitActiveState>(&metric_invalid),
718            None
719        );
720
721        // Null value returns None
722        let metric_empty = ListOutput {
723            name: "io.systemd.Manager.UnitActiveState".to_string(),
724            value: empty_value(),
725            object: Some("test.service".to_string()),
726            fields: None,
727        };
728        assert_eq!(
729            parse_metric_enum::<SystemdUnitActiveState>(&metric_empty),
730            None
731        );
732    }
733
734    #[test]
735    fn test_parse_metric_enum_all_states() {
736        // Test all active states
737        let active_states = vec![
738            ("active", SystemdUnitActiveState::active),
739            ("reloading", SystemdUnitActiveState::reloading),
740            ("inactive", SystemdUnitActiveState::inactive),
741            ("failed", SystemdUnitActiveState::failed),
742            ("activating", SystemdUnitActiveState::activating),
743            ("deactivating", SystemdUnitActiveState::deactivating),
744        ];
745
746        for (state_str, expected) in active_states {
747            let metric = ListOutput {
748                name: "io.systemd.Manager.UnitActiveState".to_string(),
749                value: string_value(state_str),
750                object: Some("test.service".to_string()),
751                fields: None,
752            };
753            assert_eq!(
754                parse_metric_enum::<SystemdUnitActiveState>(&metric),
755                Some(expected)
756            );
757        }
758
759        // Test all load states
760        let load_states = vec![
761            ("loaded", SystemdUnitLoadState::loaded),
762            ("error", SystemdUnitLoadState::error),
763            ("masked", SystemdUnitLoadState::masked),
764            ("not_found", SystemdUnitLoadState::not_found),
765        ];
766
767        for (state_str, expected) in load_states {
768            let metric = ListOutput {
769                name: "io.systemd.Manager.UnitLoadState".to_string(),
770                value: string_value(state_str),
771                object: Some("test.service".to_string()),
772                fields: None,
773            };
774            assert_eq!(
775                parse_metric_enum::<SystemdUnitLoadState>(&metric),
776                Some(expected)
777            );
778        }
779    }
780
781    #[tokio::test]
782    async fn test_parse_state_updates() {
783        let mut stats = SystemdUnitStats::default();
784        let config = default_units_config();
785
786        // Parse initial state
787        let metric1 = ListOutput {
788            name: "io.systemd.Manager.UnitActiveState".to_string(),
789            value: string_value("inactive"),
790            object: Some("test.service".to_string()),
791            fields: None,
792        };
793        parse_one_metric(&mut stats, &metric1, &config).unwrap();
794        assert_eq!(
795            stats.unit_states.get("test.service").unwrap().active_state,
796            SystemdUnitActiveState::inactive
797        );
798
799        // Update to active state
800        let metric2 = ListOutput {
801            name: "io.systemd.Manager.UnitActiveState".to_string(),
802            value: string_value("active"),
803            object: Some("test.service".to_string()),
804            fields: None,
805        };
806        parse_one_metric(&mut stats, &metric2, &config).unwrap();
807        assert_eq!(
808            stats.unit_states.get("test.service").unwrap().active_state,
809            SystemdUnitActiveState::active
810        );
811    }
812
813    #[tokio::test]
814    async fn test_unhealthy_computed() {
815        let mut stats = SystemdUnitStats::default();
816        let config = default_units_config();
817
818        // Set active state to failed
819        let metric1 = ListOutput {
820            name: "io.systemd.Manager.UnitActiveState".to_string(),
821            value: string_value("failed"),
822            object: Some("broken.service".to_string()),
823            fields: None,
824        };
825        parse_one_metric(&mut stats, &metric1, &config).unwrap();
826
827        // Set load state to loaded
828        let metric2 = ListOutput {
829            name: "io.systemd.Manager.UnitLoadState".to_string(),
830            value: string_value("loaded"),
831            object: Some("broken.service".to_string()),
832            fields: None,
833        };
834        parse_one_metric(&mut stats, &metric2, &config).unwrap();
835
836        // Should be unhealthy: loaded + failed
837        assert!(stats.unit_states.get("broken.service").unwrap().unhealthy);
838
839        // Set active state to active
840        let metric3 = ListOutput {
841            name: "io.systemd.Manager.UnitActiveState".to_string(),
842            value: string_value("active"),
843            object: Some("healthy.service".to_string()),
844            fields: None,
845        };
846        parse_one_metric(&mut stats, &metric3, &config).unwrap();
847
848        // Set load state to loaded
849        let metric4 = ListOutput {
850            name: "io.systemd.Manager.UnitLoadState".to_string(),
851            value: string_value("loaded"),
852            object: Some("healthy.service".to_string()),
853            fields: None,
854        };
855        parse_one_metric(&mut stats, &metric4, &config).unwrap();
856
857        // Should be healthy: loaded + active
858        assert!(!stats.unit_states.get("healthy.service").unwrap().unhealthy);
859    }
860
861    #[tokio::test]
862    async fn test_parse_metrics_oneshot_inactive_not_unhealthy() {
863        let mut stats = SystemdUnitStats::default();
864        let config = default_units_config();
865        let metrics = vec![
866            ListOutput {
867                name: "io.systemd.Manager.UnitActiveState".to_string(),
868                value: string_value("inactive"),
869                object: Some("done.service".to_string()),
870                fields: None,
871            },
872            ListOutput {
873                name: "io.systemd.Manager.UnitLoadState".to_string(),
874                value: string_value("loaded"),
875                object: Some("done.service".to_string()),
876                fields: None,
877            },
878            ListOutput {
879                name: "io.systemd.Service.Type".to_string(),
880                value: string_value("oneshot"),
881                object: Some("done.service".to_string()),
882                fields: None,
883            },
884        ];
885
886        for metric in &metrics {
887            parse_one_metric(&mut stats, metric, &config).unwrap();
888        }
889        apply_oneshot_service_health_override(&mut stats, &metrics, &config);
890
891        assert!(!stats.unit_states.get("done.service").unwrap().unhealthy);
892    }
893
894    #[tokio::test]
895    async fn test_parse_metrics_oneshot_override_can_be_disabled() {
896        let mut stats = SystemdUnitStats::default();
897        let mut config = default_units_config();
898        config.ignore_inactive_oneshot_services = false;
899        let metrics = vec![
900            ListOutput {
901                name: "io.systemd.Manager.UnitActiveState".to_string(),
902                value: string_value("inactive"),
903                object: Some("done.service".to_string()),
904                fields: None,
905            },
906            ListOutput {
907                name: "io.systemd.Manager.UnitLoadState".to_string(),
908                value: string_value("loaded"),
909                object: Some("done.service".to_string()),
910                fields: None,
911            },
912            ListOutput {
913                name: "io.systemd.Service.Type".to_string(),
914                value: string_value("oneshot"),
915                object: Some("done.service".to_string()),
916                fields: None,
917            },
918        ];
919
920        for metric in &metrics {
921            parse_one_metric(&mut stats, metric, &config).unwrap();
922        }
923        apply_oneshot_service_health_override(&mut stats, &metrics, &config);
924
925        assert!(stats.unit_states.get("done.service").unwrap().unhealthy);
926    }
927
928    #[tokio::test]
929    async fn test_allowlist_filtering() {
930        let mut stats = SystemdUnitStats::default();
931        let config = crate::config::UnitsConfig {
932            enabled: true,
933            state_stats: true,
934            state_stats_allowlist: HashSet::from(["allowed.service".to_string()]),
935            state_stats_blocklist: HashSet::new(),
936            state_stats_time_in_state: false,
937            ignore_inactive_oneshot_services: true,
938            unit_files: true,
939        };
940
941        // Allowed unit should be tracked
942        let metric1 = ListOutput {
943            name: "io.systemd.Manager.UnitActiveState".to_string(),
944            value: string_value("active"),
945            object: Some("allowed.service".to_string()),
946            fields: None,
947        };
948        parse_one_metric(&mut stats, &metric1, &config).unwrap();
949        assert!(stats.unit_states.contains_key("allowed.service"));
950
951        // Non-allowed unit should be skipped
952        let metric2 = ListOutput {
953            name: "io.systemd.Manager.UnitActiveState".to_string(),
954            value: string_value("active"),
955            object: Some("not-allowed.service".to_string()),
956            fields: None,
957        };
958        parse_one_metric(&mut stats, &metric2, &config).unwrap();
959        assert!(!stats.unit_states.contains_key("not-allowed.service"));
960    }
961
962    #[tokio::test]
963    async fn test_blocklist_filtering() {
964        let mut stats = SystemdUnitStats::default();
965        let config = crate::config::UnitsConfig {
966            enabled: true,
967            state_stats: true,
968            state_stats_allowlist: HashSet::new(),
969            state_stats_blocklist: HashSet::from(["blocked.service".to_string()]),
970            state_stats_time_in_state: false,
971            ignore_inactive_oneshot_services: true,
972            unit_files: true,
973        };
974
975        // Blocked unit should be skipped
976        let metric1 = ListOutput {
977            name: "io.systemd.Manager.UnitActiveState".to_string(),
978            value: string_value("active"),
979            object: Some("blocked.service".to_string()),
980            fields: None,
981        };
982        parse_one_metric(&mut stats, &metric1, &config).unwrap();
983        assert!(!stats.unit_states.contains_key("blocked.service"));
984
985        // Non-blocked unit should be tracked
986        let metric2 = ListOutput {
987            name: "io.systemd.Manager.UnitActiveState".to_string(),
988            value: string_value("active"),
989            object: Some("ok.service".to_string()),
990            fields: None,
991        };
992        parse_one_metric(&mut stats, &metric2, &config).unwrap();
993        assert!(stats.unit_states.contains_key("ok.service"));
994    }
995
996    #[tokio::test]
997    async fn test_blocklist_overrides_allowlist() {
998        let mut stats = SystemdUnitStats::default();
999        let config = crate::config::UnitsConfig {
1000            enabled: true,
1001            state_stats: true,
1002            state_stats_allowlist: HashSet::from(["both.service".to_string()]),
1003            state_stats_blocklist: HashSet::from(["both.service".to_string()]),
1004            state_stats_time_in_state: false,
1005            ignore_inactive_oneshot_services: true,
1006            unit_files: true,
1007        };
1008
1009        // Unit in both lists should be blocked (blocklist takes priority)
1010        let metric = ListOutput {
1011            name: "io.systemd.Manager.UnitActiveState".to_string(),
1012            value: string_value("active"),
1013            object: Some("both.service".to_string()),
1014            fields: None,
1015        };
1016        parse_one_metric(&mut stats, &metric, &config).unwrap();
1017        assert!(!stats.unit_states.contains_key("both.service"));
1018    }
1019
1020    #[test]
1021    fn test_load_state_counts_bypass_allowlist() {
1022        // loaded_units/masked_units/not_found_units must be counted for every unit,
1023        // regardless of the state_stats allowlist (matching D-Bus parse_unit() behaviour).
1024        let config = crate::config::UnitsConfig {
1025            enabled: true,
1026            state_stats: true,
1027            // Only "allowed.service" is in the allowlist
1028            state_stats_allowlist: HashSet::from(["allowed.service".to_string()]),
1029            state_stats_blocklist: HashSet::new(),
1030            state_stats_time_in_state: false,
1031            ignore_inactive_oneshot_services: true,
1032            unit_files: true,
1033        };
1034        let mut stats = SystemdUnitStats::default();
1035
1036        let metrics = vec![
1037            // allowed unit → counts AND stored in unit_states
1038            ListOutput {
1039                name: "io.systemd.Manager.UnitLoadState".to_string(),
1040                value: string_value("loaded"),
1041                object: Some("allowed.service".to_string()),
1042                fields: None,
1043            },
1044            // non-allowed unit → only counted, NOT stored in unit_states
1045            ListOutput {
1046                name: "io.systemd.Manager.UnitLoadState".to_string(),
1047                value: string_value("loaded"),
1048                object: Some("other.service".to_string()),
1049                fields: None,
1050            },
1051            ListOutput {
1052                name: "io.systemd.Manager.UnitLoadState".to_string(),
1053                value: string_value("not-found"), // systemd sends hyphenated form over the wire
1054                object: Some("missing.service".to_string()),
1055                fields: None,
1056            },
1057            ListOutput {
1058                name: "io.systemd.Manager.UnitLoadState".to_string(),
1059                value: string_value("masked"),
1060                object: Some("masked.service".to_string()),
1061                fields: None,
1062            },
1063        ];
1064        for m in metrics {
1065            parse_one_metric(&mut stats, &m, &config).unwrap();
1066        }
1067
1068        // Aggregate counts include ALL units regardless of allowlist
1069        assert_eq!(stats.loaded_units, 2);
1070        assert_eq!(stats.not_found_units, 1);
1071        assert_eq!(stats.masked_units, 1);
1072        // per-unit state only tracked for the allowed unit
1073        assert_eq!(stats.unit_states.len(), 1);
1074        assert!(stats.unit_states.contains_key("allowed.service"));
1075    }
1076
1077    #[test]
1078    fn test_load_state_counts_when_state_stats_disabled() {
1079        // Even when state_stats=false, aggregate load state counts must be populated.
1080        let config = crate::config::UnitsConfig {
1081            enabled: true,
1082            state_stats: false,
1083            state_stats_allowlist: HashSet::new(),
1084            state_stats_blocklist: HashSet::new(),
1085            state_stats_time_in_state: false,
1086            ignore_inactive_oneshot_services: true,
1087            unit_files: true,
1088        };
1089        let mut stats = SystemdUnitStats::default();
1090
1091        let metrics = vec![
1092            ListOutput {
1093                name: "io.systemd.Manager.UnitLoadState".to_string(),
1094                value: string_value("loaded"),
1095                object: Some("svc1.service".to_string()),
1096                fields: None,
1097            },
1098            ListOutput {
1099                name: "io.systemd.Manager.UnitLoadState".to_string(),
1100                value: string_value("not-found"), // systemd sends hyphenated form over the wire
1101                object: Some("svc2.service".to_string()),
1102                fields: None,
1103            },
1104        ];
1105        for m in metrics {
1106            parse_one_metric(&mut stats, &m, &config).unwrap();
1107        }
1108
1109        assert_eq!(stats.loaded_units, 1);
1110        assert_eq!(stats.not_found_units, 1);
1111        // No per-unit state tracking when state_stats=false
1112        assert_eq!(stats.unit_states.len(), 0);
1113    }
1114}