monitord/
varlink_units.rs

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