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