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