1use std::collections::BTreeSet;
7use std::collections::HashMap;
8use std::collections::HashSet;
9use std::str::FromStr;
10use std::sync::Arc;
11use std::time::Instant;
12use std::time::SystemTime;
13use std::time::UNIX_EPOCH;
14
15use tokio::sync::RwLock;
16use tokio::sync::Semaphore;
17use tokio::task::JoinSet;
18use tracing::debug;
19use tracing::error;
20
21use tracing::warn;
22
23use crate::timer::TimerStats;
24use crate::unit_constants::{
25 is_unit_unhealthy, is_unit_unhealthy_for_service, SystemdUnitActiveState, SystemdUnitLoadState,
26 SYSTEMD_SERVICE_SUFFIX, SYSTEMD_TIMER_SUFFIX,
27};
28use crate::units::SystemdUnitStats;
29use crate::units::UnitsCollectionTimings;
30use crate::varlink::metrics::{ListOutput, Metrics};
31use crate::MachineStats;
32use futures_util::stream::TryStreamExt;
33use zlink::unix;
34
35pub const METRICS_SOCKET_PATH: &str = "/run/systemd/report/io.systemd.Manager";
36
37fn parse_metric_enum<T: FromStr>(metric: &ListOutput) -> Option<T> {
39 if !metric.value().is_string() {
40 warn!(
41 "Metric {} has non-string value: {:?}",
42 metric.name(),
43 metric.value()
44 );
45 return None;
46 }
47 let value_str = metric.value_as_string();
48 let normalized = value_str.replace('-', "_");
51 match T::from_str(&normalized) {
52 Ok(v) => Some(v),
53 Err(_) => {
54 warn!(
55 "Metric {} has unrecognized value: {:?}",
56 metric.name(),
57 value_str
58 );
59 None
60 }
61 }
62}
63
64fn should_skip_unit(object_name: &str, config: &crate::config::UnitsConfig) -> bool {
66 if config.state_stats_blocklist.contains(object_name) {
67 debug!("Skipping state stats for {} due to blocklist", object_name);
68 return true;
69 }
70 if !config.state_stats_allowlist.is_empty()
71 && !config.state_stats_allowlist.contains(object_name)
72 {
73 return true;
74 }
75 false
76}
77
78pub fn parse_one_metric(
90 stats: &mut SystemdUnitStats,
91 metric: &ListOutput,
92 config: &crate::config::UnitsConfig,
93 services: &HashSet<String>,
94 has_load_state_totals: bool,
95) -> anyhow::Result<()> {
96 let metric_name_suffix = metric.name_suffix();
97 let object_name = metric.object_name();
98
99 match metric_name_suffix {
100 "UnitActiveState" => {
101 if !config.state_stats || should_skip_unit(&object_name, config) {
102 return Ok(());
103 }
104 let active_state: SystemdUnitActiveState = match parse_metric_enum(metric) {
105 Some(v) => v,
106 None => return Ok(()),
107 };
108 let unit_state = stats
109 .unit_states
110 .entry(object_name.to_string())
111 .or_default();
112 unit_state.active_state = active_state;
113 unit_state.unhealthy =
114 is_unit_unhealthy(unit_state.active_state, unit_state.load_state);
115 }
116 "UnitLoadState" => {
117 let load_state: SystemdUnitLoadState = match parse_metric_enum(metric) {
118 Some(v) => v,
119 None => return Ok(()),
120 };
121 if !has_load_state_totals {
125 match load_state {
126 SystemdUnitLoadState::loaded => stats.loaded_units += 1,
127 SystemdUnitLoadState::masked => stats.masked_units += 1,
128 SystemdUnitLoadState::not_found => stats.not_found_units += 1,
129 _ => {}
130 }
131 }
132 if !config.state_stats || should_skip_unit(&object_name, config) {
134 return Ok(());
135 }
136 let unit_state = stats
137 .unit_states
138 .entry(object_name.to_string())
139 .or_default();
140 unit_state.load_state = load_state;
141 unit_state.unhealthy =
142 is_unit_unhealthy(unit_state.active_state, unit_state.load_state);
143 }
144 "NRestarts" => {
145 if !services.contains(&object_name) {
148 return Ok(());
149 }
150 if !metric.value().is_i64() {
151 warn!(
152 "Metric {} has non-integer value: {:?}",
153 metric.name(),
154 metric.value()
155 );
156 return Ok(());
157 }
158 let value = metric.value_as_int();
159 let nrestarts: u32 = match value.try_into() {
160 Ok(v) => v,
161 Err(_) => {
162 warn!(
163 "Metric {} has out-of-range value for u32: {}",
164 metric.name(),
165 value
166 );
167 return Ok(());
168 }
169 };
170 stats
171 .service_stats
172 .entry(object_name.to_string())
173 .or_default()
174 .nrestarts = nrestarts;
175 }
176 "StatusErrno" => {
177 if !services.contains(&object_name) {
179 return Ok(());
180 }
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 status_errno: i32 = match value.try_into() {
196 Ok(v) => v,
197 Err(_) => {
198 warn!(
199 "Metric {} has out-of-range value for i32: {}",
200 metric.name(),
201 value
202 );
203 return Ok(());
204 }
205 };
206 stats
207 .service_stats
208 .entry(object_name.to_string())
209 .or_default()
210 .status_errno = status_errno;
211 }
212 "UnitsByTypeTotal" => {
213 if let Some(type_str) = metric.get_field_as_str("type") {
214 if !metric.value().is_i64() {
215 warn!(
216 "Metric {} has non-integer value: {:?}",
217 metric.name(),
218 metric.value()
219 );
220 return Ok(());
221 }
222 let value = metric.value_as_int();
223 let value: u64 = match value.try_into() {
224 Ok(v) => v,
225 Err(_) => {
226 warn!("Metric {} has negative value: {}", metric.name(), value);
227 return Ok(());
228 }
229 };
230 match type_str {
231 "automount" => stats.automount_units = value,
232 "device" => stats.device_units = value,
233 "mount" => stats.mount_units = value,
234 "path" => stats.path_units = value,
235 "scope" => stats.scope_units = value,
236 "service" => stats.service_units = value,
237 "slice" => stats.slice_units = value,
238 "socket" => stats.socket_units = value,
239 "target" => stats.target_units = value,
240 "timer" => stats.timer_units = value,
241 _ => debug!("Found unhandled unit type: {:?}", type_str),
242 }
243 }
244 }
245 "UnitsByLoadStateTotal" => {
246 if let Some(load_state_str) = metric.get_field_as_str("load_state") {
247 if !metric.value().is_i64() {
248 warn!(
249 "Metric {} has non-integer value: {:?}",
250 metric.name(),
251 metric.value()
252 );
253 return Ok(());
254 }
255 let value = metric.value_as_int();
256 let value: u64 = match value.try_into() {
257 Ok(v) => v,
258 Err(_) => {
259 warn!("Metric {} has negative value: {}", metric.name(), value);
260 return Ok(());
261 }
262 };
263 match load_state_str {
264 "loaded" => stats.loaded_units = value,
265 "masked" => stats.masked_units = value,
266 "not-found" => stats.not_found_units = value,
267 _ => debug!("Found unhandled unit load state: {:?}", load_state_str),
270 }
271 }
272 }
273 "UnitsByStateTotal" => {
274 if let Some(state_str) = metric.get_field_as_str("state") {
275 if !metric.value().is_i64() {
276 warn!(
277 "Metric {} has non-integer value: {:?}",
278 metric.name(),
279 metric.value()
280 );
281 return Ok(());
282 }
283 let value = metric.value_as_int();
284 let value: u64 = match value.try_into() {
285 Ok(v) => v,
286 Err(_) => {
287 warn!("Metric {} has negative value: {}", metric.name(), value);
288 return Ok(());
289 }
290 };
291 match state_str {
292 "activating" => stats.activating_units = value,
293 "active" => stats.active_units = value,
294 "failed" => stats.failed_units = value,
295 "inactive" => stats.inactive_units = value,
296 _ => debug!("Found unhandled unit state: {:?}", state_str),
300 }
301 }
302 }
303 "JobsQueued" => {
304 if !metric.value().is_i64() {
305 warn!(
306 "Metric {} has non-integer value: {:?}",
307 metric.name(),
308 metric.value()
309 );
310 return Ok(());
311 }
312 let value = metric.value_as_int();
313 match value.try_into() {
314 Ok(value) => stats.jobs_queued = value,
315 Err(_) => {
316 warn!("Metric {} has negative value: {}", metric.name(), value);
317 }
318 }
319 }
320 "UnitsTotal" => {
321 if !metric.value().is_i64() {
322 warn!(
323 "Metric {} has non-integer value: {:?}",
324 metric.name(),
325 metric.value()
326 );
327 return Ok(());
328 }
329 let value = metric.value_as_int();
330 match value.try_into() {
331 Ok(value) => stats.total_units = value,
332 Err(_) => {
333 warn!("Metric {} has negative value: {}", metric.name(), value);
334 }
335 }
336 }
337 "StateChangeTimestamp" => {
338 if !config.state_stats
339 || !config.state_stats_time_in_state
340 || should_skip_unit(&object_name, config)
341 {
342 return Ok(());
343 }
344 if !metric.value().is_i64() {
345 warn!(
346 "Metric {} has non-integer value: {:?}",
347 metric.name(),
348 metric.value()
349 );
350 return Ok(());
351 }
352 let state_change_usec: u64 = match metric.value_as_int().try_into() {
353 Ok(value) => value,
354 Err(_) => {
355 warn!(
356 "Metric {} has out-of-range value: {}",
357 metric.name(),
358 metric.value_as_int()
359 );
360 return Ok(());
361 }
362 };
363 let now_usec = match SystemTime::now().duration_since(UNIX_EPOCH) {
364 Ok(elapsed) => elapsed.as_secs() * 1_000_000 + u64::from(elapsed.subsec_micros()),
365 Err(err) => {
366 warn!("System clock error computing time in state: {:?}", err);
367 return Ok(());
368 }
369 };
370 let Some(elapsed) = crate::units::compute_time_in_state(now_usec, state_change_usec)
373 else {
374 return Ok(());
375 };
376 stats
377 .unit_states
378 .entry(object_name.to_string())
379 .or_default()
380 .time_in_state_usecs = Some(elapsed);
381 }
382 _ => debug!("Found unhandled metric: {:?}", metric.name()),
383 }
384
385 Ok(())
386}
387
388pub(crate) async fn collect_metrics(socket_path: String) -> anyhow::Result<Vec<ListOutput>> {
392 tokio::task::spawn_blocking(move || {
393 let rt = tokio::runtime::Builder::new_current_thread()
394 .enable_all()
395 .build()?;
396 rt.block_on(async move {
397 let mut conn = unix::connect(&socket_path).await?;
398 let stream = conn.list().await?;
399 futures_util::pin_mut!(stream);
400
401 let mut metrics = Vec::new();
402 let mut count = 0;
403 while let Some(result) = stream.try_next().await? {
404 let result: std::result::Result<ListOutput, _> = result;
405 match result {
406 Ok(metric) => {
407 debug!("Metrics {}: {:?}", count, metric);
408 count += 1;
409 metrics.push(metric);
410 }
411 Err(e) => {
412 debug!("Error deserializing metric {}: {:?}", count, e);
413 return Err(anyhow::anyhow!(e));
414 }
415 }
416 }
417 Ok(metrics)
418 })
419 })
420 .await?
421}
422
423pub async fn parse_metrics(
424 stats: &mut SystemdUnitStats,
425 socket_path: &str,
426 config: &crate::config::UnitsConfig,
427 services: &HashSet<String>,
428) -> anyhow::Result<Vec<String>> {
429 let bulk_fetch_start = Instant::now();
437 let metrics = collect_metrics(socket_path.to_string()).await?;
438 let bulk_fetch_elapsed = bulk_fetch_start.elapsed();
439 stats.collection_timings.list_units_ms = bulk_fetch_elapsed.as_secs_f64() * 1000.0;
440
441 let parse_loop_start = Instant::now();
442 let has_load_state_totals = metrics
443 .iter()
444 .any(|metric| metric.name_suffix() == "UnitsByLoadStateTotal");
445 for metric in &metrics {
446 parse_one_metric(stats, metric, config, services, has_load_state_totals)?;
447 }
448 let timer_names: BTreeSet<String> = metrics
453 .iter()
454 .filter_map(|metric| metric.object())
455 .filter(|object| object.ends_with(SYSTEMD_TIMER_SUFFIX))
456 .map(|object| object.to_string())
457 .collect();
458
459 let parse_loop_elapsed = parse_loop_start.elapsed();
460 stats.collection_timings.per_unit_loop_ms = parse_loop_elapsed.as_secs_f64() * 1000.0;
461
462 Ok(timer_names.into_iter().collect())
463}
464
465pub fn select_oneshot_candidates(
472 stats: &SystemdUnitStats,
473 config: &crate::config::UnitsConfig,
474) -> Vec<String> {
475 if !config.ignore_inactive_oneshot_services {
476 return Vec::new();
477 }
478 stats
479 .unit_states
480 .iter()
481 .filter(|(name, state)| {
482 name.ends_with(SYSTEMD_SERVICE_SUFFIX)
483 && matches!(state.active_state, SystemdUnitActiveState::inactive)
484 && matches!(state.load_state, SystemdUnitLoadState::loaded)
485 })
486 .map(|(name, _)| name.clone())
487 .collect()
488}
489
490pub async fn fetch_oneshot_types(
505 connection: &zbus::Connection,
506 candidates: Vec<String>,
507 per_unit_concurrency: u64,
508) -> (HashMap<String, bool>, u64) {
509 let semaphore = Arc::new(Semaphore::new(per_unit_concurrency.max(1) as usize));
510 let mut join_set: JoinSet<(String, Option<bool>)> = JoinSet::new();
511 for name in candidates {
512 let semaphore = Arc::clone(&semaphore);
513 let connection = connection.clone();
514 join_set.spawn(async move {
515 let _permit = semaphore
516 .acquire()
517 .await
518 .expect("semaphore closed unexpectedly");
519 let is_oneshot =
520 match crate::units::is_oneshot_service_by_name(&connection, &name).await {
521 Ok(is_oneshot) => Some(is_oneshot),
522 Err(err) => {
523 warn!(
524 "Unable to get Service.Type for {} (assuming not oneshot): {:?}",
525 name, err
526 );
527 None
528 }
529 };
530 (name, is_oneshot)
531 });
532 }
533 let mut types = HashMap::new();
534 let mut successful_fetches: u64 = 0;
535 while let Some(res) = join_set.join_next().await {
536 match res {
537 Ok((name, Some(is_oneshot))) => {
538 types.insert(name, is_oneshot);
539 successful_fetches += 1;
540 }
541 Ok((_, None)) => {}
542 Err(err) => {
543 warn!("Oneshot type lookup task failed to join: {:?}", err);
544 }
545 }
546 }
547 (types, successful_fetches)
548}
549
550fn record_oneshot_lookup_timings(
557 timings: &mut UnitsCollectionTimings,
558 elapsed_ms: f64,
559 successful_fetches: u64,
560) {
561 timings.per_unit_loop_ms += elapsed_ms;
562 timings.service_dbus_fetches += successful_fetches;
563}
564
565pub fn apply_oneshot_types(
570 stats: &mut SystemdUnitStats,
571 oneshot_types: &HashMap<String, bool>,
572 config: &crate::config::UnitsConfig,
573) {
574 if !config.ignore_inactive_oneshot_services {
575 return;
576 }
577 for (unit_name, unit_state) in stats.unit_states.iter_mut() {
578 let is_oneshot = oneshot_types.get(unit_name).copied().unwrap_or(false);
579 unit_state.unhealthy = is_unit_unhealthy_for_service(
580 unit_state.active_state,
581 unit_state.load_state,
582 is_oneshot,
583 config.ignore_inactive_oneshot_services,
584 );
585 }
586}
587
588pub async fn apply_oneshot_dbus_override(
595 connection: &zbus::Connection,
596 locked_machine_stats: &Arc<RwLock<MachineStats>>,
597 config: &crate::config::UnitsConfig,
598) {
599 let candidates = {
600 let machine_stats = locked_machine_stats.read().await;
601 select_oneshot_candidates(&machine_stats.units, config)
602 };
603 if candidates.is_empty() {
604 return;
605 }
606 let fetch_start = Instant::now();
607 let (oneshot_types, successful_fetches) =
608 fetch_oneshot_types(connection, candidates, config.per_unit_concurrency).await;
609 let fetch_elapsed_ms = fetch_start.elapsed().as_secs_f64() * 1000.0;
610 let mut machine_stats = locked_machine_stats.write().await;
611 apply_oneshot_types(&mut machine_stats.units, &oneshot_types, config);
612 record_oneshot_lookup_timings(
613 &mut machine_stats.units.collection_timings,
614 fetch_elapsed_ms,
615 successful_fetches,
616 );
617}
618
619fn require_unit_context(
631 name: &str,
632 output: &crate::varlink::unit::ListOutput,
633) -> anyhow::Result<()> {
634 let context = output.context.as_ref();
635 let (section, present) = if name.ends_with(SYSTEMD_SERVICE_SUFFIX) {
636 (
637 "Service",
638 context
639 .and_then(|context| context.service.as_ref())
640 .is_some(),
641 )
642 } else if name.ends_with(SYSTEMD_TIMER_SUFFIX) {
643 (
644 "Timer",
645 context.and_then(|context| context.timer.as_ref()).is_some(),
646 )
647 } else {
648 return Ok(());
649 };
650 if !present {
651 anyhow::bail!(
652 "{} came back with no context.{}: this systemd is older than v261, \
653 where io.systemd.Unit gained the per-type context sections",
654 name,
655 section
656 );
657 }
658 Ok(())
659}
660
661fn select_timers(timer_names: &[String], config: &crate::config::TimersConfig) -> Vec<String> {
664 if !config.enabled {
665 return Vec::new();
666 }
667 timer_names
668 .iter()
669 .filter(|name| {
670 if config.blocklist.contains(*name) {
671 debug!("Skipping timer stats for {} due to blocklist", name);
672 return false;
673 }
674 config.allowlist.is_empty() || config.allowlist.contains(*name)
675 })
676 .cloned()
677 .collect()
678}
679
680pub async fn apply_unit_details(
691 socket_path: &str,
692 locked_machine_stats: &Arc<RwLock<MachineStats>>,
693 config: &crate::config::Config,
694 fs_root: &str,
695 timer_names: &[String],
696) -> anyhow::Result<()> {
697 let candidates = {
698 let machine_stats = locked_machine_stats.read().await;
699 select_oneshot_candidates(&machine_stats.units, &config.units)
700 };
701 let timers = select_timers(timer_names, &config.timers);
702 if candidates.is_empty() && config.services.is_empty() && timers.is_empty() {
703 return Ok(());
704 }
705
706 let fetch_start = Instant::now();
707 let mut lookup = crate::varlink_unit::UnitLookup::connect(socket_path).await?;
708
709 let mut service_stats: HashMap<String, crate::units::ServiceStats> = HashMap::new();
710 for name in &config.services {
711 let Some(output) = lookup.get(name).await? else {
712 continue;
713 };
714 let output = output.clone();
716 require_unit_context(name, &output)?;
717 let processes = crate::varlink_unit::count_cgroup_processes(fs_root, &output).await;
718 service_stats.insert(
719 name.clone(),
720 crate::varlink_unit::map_service_stats(&output, processes),
721 );
722 }
723
724 let mut oneshot_types: HashMap<String, bool> = HashMap::new();
725 for name in candidates {
726 if let Some(output) = lookup.get(&name).await? {
727 let output = output.clone();
728 require_unit_context(&name, &output)?;
729 oneshot_types.insert(name, crate::varlink_unit::is_oneshot(&output));
730 }
731 }
732
733 let mut timer_stats: HashMap<String, TimerStats> = HashMap::new();
737 for name in timers {
738 let Some(output) = lookup.get(&name).await? else {
739 continue;
740 };
741 let output = output.clone();
742 require_unit_context(&name, &output)?;
743 let triggered = match crate::varlink_unit::timer_triggered_unit(&output) {
750 Some(triggered) => lookup.get(triggered).await?.cloned(),
751 None => {
752 error!("{}: No service unit name found for timer.", name);
753 None
754 }
755 };
756 timer_stats.insert(
757 name,
758 crate::varlink_unit::map_timer_stats(&output, triggered.as_ref()),
759 );
760 }
761
762 let fetch_elapsed_ms = fetch_start.elapsed().as_secs_f64() * 1000.0;
763 debug!(
764 "Varlink unit details: {} unit(s) fetched for {} service(s) + {} oneshot candidate(s) in {:.2}ms",
765 lookup.fetches(),
766 config.services.len(),
767 oneshot_types.len(),
768 fetch_elapsed_ms
769 );
770 let mut machine_stats = locked_machine_stats.write().await;
771 machine_stats.units.timer_persistent_units = timer_stats
772 .values()
773 .filter(|timer| timer.persistent)
774 .count() as u64;
775 machine_stats.units.timer_remain_after_elapse = timer_stats
776 .values()
777 .filter(|timer| timer.remain_after_elapse)
778 .count() as u64;
779 machine_stats.units.timer_stats = timer_stats;
780 machine_stats.units.service_stats.extend(service_stats);
781 apply_oneshot_types(&mut machine_stats.units, &oneshot_types, &config.units);
782 machine_stats.units.collection_timings.per_unit_loop_ms += fetch_elapsed_ms;
785 Ok(())
786}
787
788fn sum_units_by_type(stats: &SystemdUnitStats) -> u64 {
792 stats.automount_units
793 + stats.device_units
794 + stats.mount_units
795 + stats.path_units
796 + stats.scope_units
797 + stats.service_units
798 + stats.slice_units
799 + stats.socket_units
800 + stats.target_units
801 + stats.timer_units
802}
803
804pub async fn get_unit_stats(
805 config: &crate::config::Config,
806 socket_path: &str,
807) -> anyhow::Result<(SystemdUnitStats, Vec<String>)> {
808 if !config.units.state_stats_allowlist.is_empty() {
809 debug!(
810 "Using unit state allowlist: {:?}",
811 config.units.state_stats_allowlist
812 );
813 }
814
815 if !config.units.state_stats_blocklist.is_empty() {
816 debug!(
817 "Using unit state blocklist: {:?}",
818 config.units.state_stats_blocklist,
819 );
820 }
821
822 let mut stats = SystemdUnitStats::default();
823
824 let timer_names =
827 parse_metrics(&mut stats, socket_path, &config.units, &config.services).await?;
828
829 if stats.total_units == 0 {
833 stats.total_units = sum_units_by_type(&stats);
834 }
835
836 debug!("unit stats: {:?}", stats);
837 Ok((stats, timer_names))
838}
839
840pub async fn update_unit_stats(
842 config: Arc<crate::config::Config>,
843 locked_machine_stats: Arc<RwLock<MachineStats>>,
844 socket_path: String,
845) -> anyhow::Result<Vec<String>> {
846 let (units_stats, timer_names) = get_unit_stats(&config, &socket_path).await?;
847 let mut machine_stats = locked_machine_stats.write().await;
848 machine_stats.units = units_stats;
849 Ok(timer_names)
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855 use std::collections::HashSet;
856
857 fn string_value(s: &str) -> serde_json::Value {
858 serde_json::json!(s)
859 }
860
861 fn int_value(i: i64) -> serde_json::Value {
862 serde_json::json!(i)
863 }
864
865 fn empty_value() -> serde_json::Value {
866 serde_json::Value::Null
867 }
868
869 fn default_units_config() -> crate::config::UnitsConfig {
870 crate::config::UnitsConfig {
871 enabled: true,
872 state_stats: true,
873 state_stats_allowlist: HashSet::new(),
874 state_stats_blocklist: HashSet::new(),
875 state_stats_time_in_state: false,
876 ignore_inactive_oneshot_services: true,
877 unit_files: true,
878 ..Default::default()
879 }
880 }
881
882 #[tokio::test]
883 async fn test_parse_one_metric_unit_active_state() {
884 let mut stats = SystemdUnitStats::default();
885 let config = default_units_config();
886
887 let metric = ListOutput {
888 name: "io.systemd.Manager.UnitActiveState".to_string(),
889 value: string_value("active"),
890 object: Some("my-service.service".to_string()),
891 fields: None,
892 };
893
894 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
895 .expect("metric should parse successfully");
896
897 assert_eq!(
898 stats
899 .unit_states
900 .get("my-service.service")
901 .expect("my-service.service should have a unit_states entry")
902 .active_state,
903 SystemdUnitActiveState::active
904 );
905 }
906
907 #[tokio::test]
908 async fn test_parse_one_metric_unit_load_state() {
909 let mut stats = SystemdUnitStats::default();
910 let config = default_units_config();
911
912 let metric = ListOutput {
915 name: "io.systemd.Manager.UnitLoadState".to_string(),
916 value: string_value("not-found"),
917 object: Some("missing.service".to_string()),
918 fields: None,
919 };
920
921 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
922 .expect("metric should parse successfully");
923
924 assert_eq!(
925 stats
926 .unit_states
927 .get("missing.service")
928 .expect("missing.service should have a unit_states entry")
929 .load_state,
930 SystemdUnitLoadState::not_found
931 );
932 }
933
934 #[test]
935 fn test_parse_one_metric_nrestarts() {
936 let mut stats = SystemdUnitStats::default();
937 let config = default_units_config();
938 let services = HashSet::from(["my-service.service".to_string()]);
940
941 let metric = ListOutput {
942 name: "io.systemd.Manager.NRestarts".to_string(),
943 value: int_value(5),
944 object: Some("my-service.service".to_string()),
945 fields: None,
946 };
947
948 parse_one_metric(&mut stats, &metric, &config, &services, false)
949 .expect("metric should parse successfully");
950
951 assert_eq!(
952 stats
953 .service_stats
954 .get("my-service.service")
955 .expect("my-service.service should have a service_stats entry")
956 .nrestarts,
957 5
958 );
959
960 let other_metric = ListOutput {
962 name: "io.systemd.Manager.NRestarts".to_string(),
963 value: int_value(7),
964 object: Some("other.service".to_string()),
965 fields: None,
966 };
967 parse_one_metric(&mut stats, &other_metric, &config, &services, false)
968 .expect("other_metric should parse successfully");
969 assert!(!stats.service_stats.contains_key("other.service"));
970 }
971
972 #[test]
973 fn test_parse_one_metric_status_errno() {
974 let mut stats = SystemdUnitStats::default();
975 let config = default_units_config();
976 let services = HashSet::from(["my-service.service".to_string()]);
977
978 let metric = ListOutput {
980 name: "io.systemd.Manager.StatusErrno".to_string(),
981 value: int_value(13),
982 object: Some("my-service.service".to_string()),
983 fields: None,
984 };
985 parse_one_metric(&mut stats, &metric, &config, &services, false)
986 .expect("metric should parse successfully");
987 assert_eq!(
988 stats
989 .service_stats
990 .get("my-service.service")
991 .expect("my-service.service should have a service_stats entry")
992 .status_errno,
993 13
994 );
995
996 let out_of_range = ListOutput {
999 name: "io.systemd.Manager.StatusErrno".to_string(),
1000 value: int_value(i64::from(i32::MAX) + 1),
1001 object: Some("my-service.service".to_string()),
1002 fields: None,
1003 };
1004 parse_one_metric(&mut stats, &out_of_range, &config, &services, false)
1005 .expect("out_of_range should parse successfully");
1006 assert_eq!(
1007 stats
1008 .service_stats
1009 .get("my-service.service")
1010 .expect("my-service.service should have a service_stats entry")
1011 .status_errno,
1012 13
1013 );
1014
1015 let other_metric = ListOutput {
1017 name: "io.systemd.Manager.StatusErrno".to_string(),
1018 value: int_value(1),
1019 object: Some("other.service".to_string()),
1020 fields: None,
1021 };
1022 parse_one_metric(&mut stats, &other_metric, &config, &services, false)
1023 .expect("other_metric should parse successfully");
1024 assert!(!stats.service_stats.contains_key("other.service"));
1025 }
1026
1027 #[tokio::test]
1028 async fn test_parse_aggregated_metrics() {
1029 let mut stats = SystemdUnitStats::default();
1030 let config = default_units_config();
1031
1032 let type_metric = ListOutput {
1034 name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
1035 value: int_value(42),
1036 object: None,
1037 fields: Some(std::collections::HashMap::from([(
1038 "type".to_string(),
1039 serde_json::json!("service"),
1040 )])),
1041 };
1042 parse_one_metric(&mut stats, &type_metric, &config, &HashSet::new(), false)
1043 .expect("type_metric should parse successfully");
1044 assert_eq!(stats.service_units, 42);
1045
1046 let state_metric = ListOutput {
1048 name: "io.systemd.Manager.UnitsByStateTotal".to_string(),
1049 value: int_value(10),
1050 object: None,
1051 fields: Some(std::collections::HashMap::from([(
1052 "state".to_string(),
1053 serde_json::json!("active"),
1054 )])),
1055 };
1056 parse_one_metric(&mut stats, &state_metric, &config, &HashSet::new(), false)
1057 .expect("state_metric should parse successfully");
1058 assert_eq!(stats.active_units, 10);
1059
1060 let activating_metric = ListOutput {
1062 name: "io.systemd.Manager.UnitsByStateTotal".to_string(),
1063 value: int_value(1),
1064 object: None,
1065 fields: Some(std::collections::HashMap::from([(
1066 "state".to_string(),
1067 serde_json::json!("activating"),
1068 )])),
1069 };
1070 parse_one_metric(
1071 &mut stats,
1072 &activating_metric,
1073 &config,
1074 &HashSet::new(),
1075 false,
1076 )
1077 .expect("activating_metric should parse successfully");
1078 assert_eq!(stats.activating_units, 1);
1079
1080 let jobs_metric = ListOutput {
1082 name: "io.systemd.Manager.JobsQueued".to_string(),
1083 value: int_value(3),
1084 object: None,
1085 fields: None,
1086 };
1087 parse_one_metric(&mut stats, &jobs_metric, &config, &HashSet::new(), false)
1088 .expect("jobs_metric should parse successfully");
1089 assert_eq!(stats.jobs_queued, 3);
1090
1091 let total_metric = ListOutput {
1093 name: "io.systemd.Manager.UnitsTotal".to_string(),
1094 value: int_value(196),
1095 object: None,
1096 fields: None,
1097 };
1098 parse_one_metric(&mut stats, &total_metric, &config, &HashSet::new(), false)
1099 .expect("total_metric should parse successfully");
1100 assert_eq!(stats.total_units, 196);
1101 }
1102
1103 #[test]
1104 fn test_require_unit_context_rejects_pre_v261_replies() {
1105 use crate::varlink::unit::{
1106 ListOutput as UnitListOutput, ServiceContext, TimerContext, UnitContext,
1107 };
1108
1109 let bare = UnitListOutput {
1114 context: Some(UnitContext {
1115 service: None,
1116 exec: None,
1117 timer: None,
1118 }),
1119 runtime: None,
1120 };
1121 assert!(require_unit_context("foo.service", &bare).is_err());
1122 assert!(require_unit_context("foo.timer", &bare).is_err());
1126 let empty = UnitListOutput {
1128 context: None,
1129 runtime: None,
1130 };
1131 assert!(require_unit_context("foo.service", &empty).is_err());
1132 assert!(require_unit_context("foo.timer", &empty).is_err());
1133
1134 let with_service = UnitListOutput {
1136 context: Some(UnitContext {
1137 service: Some(ServiceContext {
1138 r#type: Some("simple".to_string()),
1139 restart_usec: None,
1140 watchdog_usec: None,
1141 }),
1142 exec: None,
1143 timer: None,
1144 }),
1145 runtime: None,
1146 };
1147 assert!(require_unit_context("foo.service", &with_service).is_ok());
1148 let with_timer = UnitListOutput {
1149 context: Some(UnitContext {
1150 service: None,
1151 exec: None,
1152 timer: Some(TimerContext {
1153 unit: Some("foo.service".to_string()),
1154 accuracy_usec: Some(60_000_000),
1155 randomized_delay_usec: None,
1156 fixed_random_delay: None,
1157 persistent: None,
1158 remain_after_elapse: None,
1159 }),
1160 }),
1161 runtime: None,
1162 };
1163 assert!(require_unit_context("foo.timer", &with_timer).is_ok());
1164
1165 assert!(require_unit_context("foo.socket", &bare).is_ok());
1168 }
1169
1170 #[test]
1171 fn test_sum_units_by_type_fallback() {
1172 let stats = SystemdUnitStats {
1175 service_units: 86,
1176 timer_units: 2,
1177 ..Default::default()
1178 };
1179 assert_eq!(sum_units_by_type(&stats), 88);
1180 assert_eq!(sum_units_by_type(&SystemdUnitStats::default()), 0);
1181 }
1182
1183 #[test]
1184 fn test_state_change_timestamp_sets_time_in_state() {
1185 let mut stats = SystemdUnitStats::default();
1186 let mut config = default_units_config();
1187 config.state_stats_time_in_state = true;
1188 let now_usec = SystemTime::now()
1189 .duration_since(UNIX_EPOCH)
1190 .expect("test clock should work")
1191 .as_secs()
1192 * 1_000_000;
1193 let metric = ListOutput {
1194 name: "io.systemd.Manager.StateChangeTimestamp".to_string(),
1195 value: int_value((now_usec - 5_000_000) as i64),
1196 object: Some("test.service".to_string()),
1197 fields: None,
1198 };
1199
1200 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1201 .expect("timestamp metric should parse successfully");
1202
1203 let elapsed = stats
1204 .unit_states
1205 .get("test.service")
1206 .expect("test.service should have a unit_states entry")
1207 .time_in_state_usecs
1208 .expect("time in state should be set");
1209 assert!(
1210 (5_000_000..6_000_000).contains(&elapsed),
1211 "expected ~5s in state, got {}us",
1212 elapsed
1213 );
1214 }
1215
1216 #[test]
1217 fn test_state_change_timestamp_zero_stays_unknown() {
1218 let mut stats = SystemdUnitStats::default();
1223 let mut config = default_units_config();
1224 config.state_stats_time_in_state = true;
1225 let metric = ListOutput {
1226 name: "io.systemd.Manager.StateChangeTimestamp".to_string(),
1227 value: int_value(0),
1228 object: Some("test.service".to_string()),
1229 fields: None,
1230 };
1231
1232 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1233 .expect("zero timestamp should parse successfully");
1234
1235 assert!(stats.unit_states.get("test.service").is_none());
1236 }
1237
1238 #[test]
1239 fn test_state_change_timestamp_respects_gating() {
1240 let metric = ListOutput {
1241 name: "io.systemd.Manager.StateChangeTimestamp".to_string(),
1242 value: int_value(1_700_000_000_000_000),
1243 object: Some("test.service".to_string()),
1244 fields: None,
1245 };
1246
1247 let mut config = default_units_config();
1249 config.state_stats_time_in_state = false;
1250 let mut stats = SystemdUnitStats::default();
1251 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1252 .expect("metric should parse successfully");
1253 assert!(stats.unit_states.get("test.service").is_none());
1254
1255 let mut config = default_units_config();
1257 config.state_stats = false;
1258 let mut stats = SystemdUnitStats::default();
1259 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1260 .expect("metric should parse successfully");
1261 assert!(stats.unit_states.get("test.service").is_none());
1262
1263 let mut config = default_units_config();
1265 config.state_stats_blocklist = HashSet::from(["test.service".to_string()]);
1266 let mut stats = SystemdUnitStats::default();
1267 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1268 .expect("metric should parse successfully");
1269 assert!(stats.unit_states.get("test.service").is_none());
1270
1271 let mut config = default_units_config();
1275 config.state_stats_time_in_state = true;
1276 let mut stats = SystemdUnitStats::default();
1277 let negative = ListOutput {
1278 name: "io.systemd.Manager.StateChangeTimestamp".to_string(),
1279 value: int_value(-1),
1280 object: Some("test.service".to_string()),
1281 fields: None,
1282 };
1283 parse_one_metric(&mut stats, &negative, &config, &HashSet::new(), false)
1284 .expect("negative timestamp should parse successfully");
1285 let null_value = ListOutput {
1286 name: "io.systemd.Manager.StateChangeTimestamp".to_string(),
1287 value: empty_value(),
1288 object: Some("test.service".to_string()),
1289 fields: None,
1290 };
1291 parse_one_metric(&mut stats, &null_value, &config, &HashSet::new(), false)
1292 .expect("null timestamp should parse successfully");
1293 assert!(stats.unit_states.get("test.service").is_none());
1294 }
1295
1296 #[tokio::test]
1297 async fn test_parse_multiple_units() {
1298 let mut stats = SystemdUnitStats::default();
1299 let config = default_units_config();
1300
1301 let metrics = vec![
1302 ListOutput {
1303 name: "io.systemd.Manager.UnitActiveState".to_string(),
1304 value: string_value("active"),
1305 object: Some("service1.service".to_string()),
1306 fields: None,
1307 },
1308 ListOutput {
1309 name: "io.systemd.Manager.UnitLoadState".to_string(),
1310 value: string_value("loaded"),
1311 object: Some("service1.service".to_string()),
1312 fields: None,
1313 },
1314 ListOutput {
1315 name: "io.systemd.Manager.UnitActiveState".to_string(),
1316 value: string_value("failed"),
1317 object: Some("service-2.service".to_string()),
1318 fields: None,
1319 },
1320 ];
1321
1322 for metric in metrics {
1323 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1324 .expect("metric should parse successfully");
1325 }
1326
1327 assert_eq!(stats.unit_states.len(), 2);
1328 assert_eq!(
1329 stats
1330 .unit_states
1331 .get("service1.service")
1332 .expect("service1.service should have a unit_states entry")
1333 .active_state,
1334 SystemdUnitActiveState::active
1335 );
1336 assert_eq!(
1337 stats
1338 .unit_states
1339 .get("service1.service")
1340 .expect("service1.service should have a unit_states entry")
1341 .load_state,
1342 SystemdUnitLoadState::loaded
1343 );
1344 assert_eq!(
1345 stats
1346 .unit_states
1347 .get("service-2.service")
1348 .expect("service-2.service should have a unit_states entry")
1349 .active_state,
1350 SystemdUnitActiveState::failed
1351 );
1352 }
1353
1354 #[test]
1355 fn test_parse_unknown_and_missing_values() {
1356 let mut stats = SystemdUnitStats::default();
1357 let config = default_units_config();
1358
1359 let metric1 = ListOutput {
1361 name: "io.systemd.Manager.UnitActiveState".to_string(),
1362 value: string_value("invalid_state"),
1363 object: Some("test.service".to_string()),
1364 fields: None,
1365 };
1366 parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
1367 .expect("metric1 should parse successfully");
1368 assert!(
1369 !stats.unit_states.contains_key("test.service"),
1370 "invalid state should be skipped"
1371 );
1372
1373 let metric2 = ListOutput {
1376 name: "io.systemd.Manager.NRestarts".to_string(),
1377 value: empty_value(),
1378 object: Some("test2.service".to_string()),
1379 fields: None,
1380 };
1381 let services = HashSet::from(["test2.service".to_string()]);
1382 parse_one_metric(&mut stats, &metric2, &config, &services, false)
1383 .expect("metric2 should parse successfully");
1384 assert!(
1385 !stats.service_stats.contains_key("test2.service"),
1386 "null value should be skipped"
1387 );
1388 }
1389
1390 #[tokio::test]
1391 async fn test_parse_edge_cases() {
1392 let mut stats = SystemdUnitStats::default();
1393 let config = default_units_config();
1394
1395 let metric1 = ListOutput {
1397 name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
1398 value: int_value(999),
1399 object: None,
1400 fields: Some(std::collections::HashMap::from([(
1401 "type".to_string(),
1402 serde_json::json!("unknown_type"),
1403 )])),
1404 };
1405 parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
1406 .expect("metric1 should parse successfully");
1407 assert_eq!(stats.service_units, 0);
1408
1409 let metric2 = ListOutput {
1411 name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
1412 value: int_value(42),
1413 object: None,
1414 fields: None,
1415 };
1416 parse_one_metric(&mut stats, &metric2, &config, &HashSet::new(), false)
1417 .expect("metric2 should parse successfully");
1418
1419 let metric3 = ListOutput {
1421 name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
1422 value: int_value(42),
1423 object: None,
1424 fields: Some(std::collections::HashMap::from([(
1425 "type".to_string(),
1426 serde_json::json!(123),
1427 )])),
1428 };
1429 parse_one_metric(&mut stats, &metric3, &config, &HashSet::new(), false)
1430 .expect("metric3 should parse successfully");
1431
1432 let metric4 = ListOutput {
1434 name: "io.systemd.Manager.UnknownMetric".to_string(),
1435 value: int_value(999),
1436 object: Some("test.service".to_string()),
1437 fields: None,
1438 };
1439 parse_one_metric(&mut stats, &metric4, &config, &HashSet::new(), false)
1440 .expect("metric4 should parse successfully");
1441 }
1442
1443 #[test]
1444 fn test_state_stats_disabled_skips_unit_states_only() {
1445 let config = crate::config::UnitsConfig {
1449 enabled: true,
1450 state_stats: false,
1451 state_stats_allowlist: HashSet::new(),
1452 state_stats_blocklist: HashSet::new(),
1453 state_stats_time_in_state: true,
1454 ignore_inactive_oneshot_services: true,
1455 unit_files: true,
1456 ..Default::default()
1457 };
1458 let services = HashSet::from(["test.service".to_string()]);
1459 let mut stats = SystemdUnitStats::default();
1460
1461 let active_state_metric = ListOutput {
1462 name: "io.systemd.Manager.UnitActiveState".to_string(),
1463 value: string_value("active"),
1464 object: Some("test.service".to_string()),
1465 fields: None,
1466 };
1467 parse_one_metric(&mut stats, &active_state_metric, &config, &services, false)
1468 .expect("active_state_metric should parse successfully");
1469
1470 let load_state_metric = ListOutput {
1471 name: "io.systemd.Manager.UnitLoadState".to_string(),
1472 value: string_value("loaded"),
1473 object: Some("test.service".to_string()),
1474 fields: None,
1475 };
1476 parse_one_metric(&mut stats, &load_state_metric, &config, &services, false)
1477 .expect("load_state_metric should parse successfully");
1478
1479 let nrestarts_metric = ListOutput {
1480 name: "io.systemd.Manager.NRestarts".to_string(),
1481 value: int_value(3),
1482 object: Some("test.service".to_string()),
1483 fields: None,
1484 };
1485 parse_one_metric(&mut stats, &nrestarts_metric, &config, &services, false)
1486 .expect("nrestarts_metric should parse successfully");
1487
1488 assert_eq!(stats.unit_states.len(), 0);
1491 assert_eq!(
1492 stats
1493 .service_stats
1494 .get("test.service")
1495 .expect("test.service should have a service_stats entry")
1496 .nrestarts,
1497 3
1498 );
1499
1500 let type_metric = ListOutput {
1502 name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
1503 value: int_value(10),
1504 object: None,
1505 fields: Some(std::collections::HashMap::from([(
1506 "type".to_string(),
1507 serde_json::json!("service"),
1508 )])),
1509 };
1510 parse_one_metric(&mut stats, &type_metric, &config, &HashSet::new(), false)
1511 .expect("type_metric should parse successfully");
1512 assert_eq!(stats.service_units, 10);
1513 }
1514
1515 #[test]
1516 fn test_parse_metric_enum() {
1517 let metric_active = ListOutput {
1518 name: "io.systemd.Manager.UnitActiveState".to_string(),
1519 value: string_value("active"),
1520 object: Some("test.service".to_string()),
1521 fields: None,
1522 };
1523 assert_eq!(
1524 parse_metric_enum::<SystemdUnitActiveState>(&metric_active),
1525 Some(SystemdUnitActiveState::active)
1526 );
1527
1528 let metric_loaded = ListOutput {
1529 name: "io.systemd.Manager.UnitLoadState".to_string(),
1530 value: string_value("loaded"),
1531 object: Some("test.service".to_string()),
1532 fields: None,
1533 };
1534 assert_eq!(
1535 parse_metric_enum::<SystemdUnitLoadState>(&metric_loaded),
1536 Some(SystemdUnitLoadState::loaded)
1537 );
1538
1539 let metric_invalid = ListOutput {
1541 name: "io.systemd.Manager.UnitActiveState".to_string(),
1542 value: string_value("invalid"),
1543 object: Some("test.service".to_string()),
1544 fields: None,
1545 };
1546 assert_eq!(
1547 parse_metric_enum::<SystemdUnitActiveState>(&metric_invalid),
1548 None
1549 );
1550
1551 let metric_empty = ListOutput {
1553 name: "io.systemd.Manager.UnitActiveState".to_string(),
1554 value: empty_value(),
1555 object: Some("test.service".to_string()),
1556 fields: None,
1557 };
1558 assert_eq!(
1559 parse_metric_enum::<SystemdUnitActiveState>(&metric_empty),
1560 None
1561 );
1562 }
1563
1564 #[test]
1565 fn test_parse_metric_enum_all_states() {
1566 let active_states = vec![
1568 ("active", SystemdUnitActiveState::active),
1569 ("reloading", SystemdUnitActiveState::reloading),
1570 ("inactive", SystemdUnitActiveState::inactive),
1571 ("failed", SystemdUnitActiveState::failed),
1572 ("activating", SystemdUnitActiveState::activating),
1573 ("deactivating", SystemdUnitActiveState::deactivating),
1574 ];
1575
1576 for (state_str, expected) in active_states {
1577 let metric = ListOutput {
1578 name: "io.systemd.Manager.UnitActiveState".to_string(),
1579 value: string_value(state_str),
1580 object: Some("test.service".to_string()),
1581 fields: None,
1582 };
1583 assert_eq!(
1584 parse_metric_enum::<SystemdUnitActiveState>(&metric),
1585 Some(expected)
1586 );
1587 }
1588
1589 let load_states = vec![
1591 ("loaded", SystemdUnitLoadState::loaded),
1592 ("error", SystemdUnitLoadState::error),
1593 ("masked", SystemdUnitLoadState::masked),
1594 ("not_found", SystemdUnitLoadState::not_found),
1595 ];
1596
1597 for (state_str, expected) in load_states {
1598 let metric = ListOutput {
1599 name: "io.systemd.Manager.UnitLoadState".to_string(),
1600 value: string_value(state_str),
1601 object: Some("test.service".to_string()),
1602 fields: None,
1603 };
1604 assert_eq!(
1605 parse_metric_enum::<SystemdUnitLoadState>(&metric),
1606 Some(expected)
1607 );
1608 }
1609 }
1610
1611 #[tokio::test]
1612 async fn test_parse_state_updates() {
1613 let mut stats = SystemdUnitStats::default();
1614 let config = default_units_config();
1615
1616 let metric1 = ListOutput {
1618 name: "io.systemd.Manager.UnitActiveState".to_string(),
1619 value: string_value("inactive"),
1620 object: Some("test.service".to_string()),
1621 fields: None,
1622 };
1623 parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
1624 .expect("metric1 should parse successfully");
1625 assert_eq!(
1626 stats
1627 .unit_states
1628 .get("test.service")
1629 .expect("test.service should have a unit_states entry")
1630 .active_state,
1631 SystemdUnitActiveState::inactive
1632 );
1633
1634 let metric2 = ListOutput {
1636 name: "io.systemd.Manager.UnitActiveState".to_string(),
1637 value: string_value("active"),
1638 object: Some("test.service".to_string()),
1639 fields: None,
1640 };
1641 parse_one_metric(&mut stats, &metric2, &config, &HashSet::new(), false)
1642 .expect("metric2 should parse successfully");
1643 assert_eq!(
1644 stats
1645 .unit_states
1646 .get("test.service")
1647 .expect("test.service should have a unit_states entry")
1648 .active_state,
1649 SystemdUnitActiveState::active
1650 );
1651 }
1652
1653 #[tokio::test]
1654 async fn test_unhealthy_computed() {
1655 let mut stats = SystemdUnitStats::default();
1656 let config = default_units_config();
1657
1658 let metric1 = ListOutput {
1660 name: "io.systemd.Manager.UnitActiveState".to_string(),
1661 value: string_value("failed"),
1662 object: Some("broken.service".to_string()),
1663 fields: None,
1664 };
1665 parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
1666 .expect("metric1 should parse successfully");
1667
1668 let metric2 = ListOutput {
1670 name: "io.systemd.Manager.UnitLoadState".to_string(),
1671 value: string_value("loaded"),
1672 object: Some("broken.service".to_string()),
1673 fields: None,
1674 };
1675 parse_one_metric(&mut stats, &metric2, &config, &HashSet::new(), false)
1676 .expect("metric2 should parse successfully");
1677
1678 assert!(
1680 stats
1681 .unit_states
1682 .get("broken.service")
1683 .expect("broken.service should have a unit_states entry")
1684 .unhealthy
1685 );
1686
1687 let metric3 = ListOutput {
1689 name: "io.systemd.Manager.UnitActiveState".to_string(),
1690 value: string_value("active"),
1691 object: Some("healthy.service".to_string()),
1692 fields: None,
1693 };
1694 parse_one_metric(&mut stats, &metric3, &config, &HashSet::new(), false)
1695 .expect("metric3 should parse successfully");
1696
1697 let metric4 = ListOutput {
1699 name: "io.systemd.Manager.UnitLoadState".to_string(),
1700 value: string_value("loaded"),
1701 object: Some("healthy.service".to_string()),
1702 fields: None,
1703 };
1704 parse_one_metric(&mut stats, &metric4, &config, &HashSet::new(), false)
1705 .expect("metric4 should parse successfully");
1706
1707 assert!(
1709 !stats
1710 .unit_states
1711 .get("healthy.service")
1712 .expect("healthy.service should have a unit_states entry")
1713 .unhealthy
1714 );
1715 }
1716
1717 #[test]
1718 fn test_oneshot_inactive_service_is_candidate() {
1719 let mut stats = SystemdUnitStats::default();
1720 let config = default_units_config();
1721 let metrics = vec![
1722 ListOutput {
1723 name: "io.systemd.Manager.UnitActiveState".to_string(),
1724 value: string_value("inactive"),
1725 object: Some("done.service".to_string()),
1726 fields: None,
1727 },
1728 ListOutput {
1729 name: "io.systemd.Manager.UnitLoadState".to_string(),
1730 value: string_value("loaded"),
1731 object: Some("done.service".to_string()),
1732 fields: None,
1733 },
1734 ];
1735
1736 for metric in &metrics {
1737 parse_one_metric(&mut stats, metric, &config, &HashSet::new(), false)
1738 .expect("metric should parse successfully");
1739 }
1740
1741 assert_eq!(
1742 select_oneshot_candidates(&stats, &config),
1743 vec!["done.service".to_string()]
1744 );
1745 }
1746
1747 #[test]
1748 fn test_oneshot_candidate_selection_skips_non_candidates() {
1749 let mut stats = SystemdUnitStats::default();
1750 let config = default_units_config();
1751 stats.unit_states.insert(
1753 "running.service".to_string(),
1754 crate::units::UnitStates {
1755 active_state: SystemdUnitActiveState::active,
1756 load_state: SystemdUnitLoadState::loaded,
1757 unhealthy: false,
1758 time_in_state_usecs: None,
1759 },
1760 );
1761 stats.unit_states.insert(
1763 "waiting.timer".to_string(),
1764 crate::units::UnitStates {
1765 active_state: SystemdUnitActiveState::inactive,
1766 load_state: SystemdUnitLoadState::loaded,
1767 unhealthy: true,
1768 time_in_state_usecs: None,
1769 },
1770 );
1771 stats.unit_states.insert(
1773 "masked.service".to_string(),
1774 crate::units::UnitStates {
1775 active_state: SystemdUnitActiveState::inactive,
1776 load_state: SystemdUnitLoadState::masked,
1777 unhealthy: false,
1778 time_in_state_usecs: None,
1779 },
1780 );
1781 stats.unit_states.insert(
1783 "done.service".to_string(),
1784 crate::units::UnitStates {
1785 active_state: SystemdUnitActiveState::inactive,
1786 load_state: SystemdUnitLoadState::loaded,
1787 unhealthy: true,
1788 time_in_state_usecs: None,
1789 },
1790 );
1791
1792 assert_eq!(
1793 select_oneshot_candidates(&stats, &config),
1794 vec!["done.service".to_string()]
1795 );
1796 }
1797
1798 #[test]
1799 fn test_oneshot_override_marks_oneshot_healthy() {
1800 let mut stats = SystemdUnitStats::default();
1801 let config = default_units_config();
1802 stats.unit_states.insert(
1803 "done.service".to_string(),
1804 crate::units::UnitStates {
1805 active_state: SystemdUnitActiveState::inactive,
1806 load_state: SystemdUnitLoadState::loaded,
1807 unhealthy: true,
1808 time_in_state_usecs: None,
1809 },
1810 );
1811 stats.unit_states.insert(
1812 "simple.service".to_string(),
1813 crate::units::UnitStates {
1814 active_state: SystemdUnitActiveState::inactive,
1815 load_state: SystemdUnitLoadState::loaded,
1816 unhealthy: true,
1817 time_in_state_usecs: None,
1818 },
1819 );
1820 stats.unit_states.insert(
1822 "failed.service".to_string(),
1823 crate::units::UnitStates {
1824 active_state: SystemdUnitActiveState::inactive,
1825 load_state: SystemdUnitLoadState::loaded,
1826 unhealthy: true,
1827 time_in_state_usecs: None,
1828 },
1829 );
1830 let types = std::collections::HashMap::from([
1831 ("done.service".to_string(), true),
1832 ("simple.service".to_string(), false),
1833 ]);
1834
1835 apply_oneshot_types(&mut stats, &types, &config);
1836
1837 assert!(
1838 !stats
1839 .unit_states
1840 .get("done.service")
1841 .expect("done.service should have a unit_states entry")
1842 .unhealthy
1843 );
1844 assert!(
1845 stats
1846 .unit_states
1847 .get("simple.service")
1848 .expect("simple.service should have a unit_states entry")
1849 .unhealthy
1850 );
1851 assert!(
1852 stats
1853 .unit_states
1854 .get("failed.service")
1855 .expect("failed.service should have a unit_states entry")
1856 .unhealthy
1857 );
1858 }
1859
1860 #[test]
1861 fn test_oneshot_lookup_timings_accumulate() {
1862 let mut timings = UnitsCollectionTimings {
1865 per_unit_loop_ms: 10.0,
1866 service_dbus_fetches: 2,
1867 ..Default::default()
1868 };
1869
1870 record_oneshot_lookup_timings(&mut timings, 5.0, 3);
1871
1872 assert_eq!(timings.per_unit_loop_ms, 15.0);
1873 assert_eq!(timings.service_dbus_fetches, 5);
1874 assert_eq!(timings.state_dbus_fetches, 0);
1876 assert_eq!(timings.timer_dbus_fetches, 0);
1877 }
1878
1879 #[test]
1880 fn test_oneshot_override_can_be_disabled() {
1881 let mut stats = SystemdUnitStats::default();
1882 let mut config = default_units_config();
1883 config.ignore_inactive_oneshot_services = false;
1884 stats.unit_states.insert(
1885 "done.service".to_string(),
1886 crate::units::UnitStates {
1887 active_state: SystemdUnitActiveState::inactive,
1888 load_state: SystemdUnitLoadState::loaded,
1889 unhealthy: true,
1890 time_in_state_usecs: None,
1891 },
1892 );
1893
1894 assert!(select_oneshot_candidates(&stats, &config).is_empty());
1895
1896 let types = std::collections::HashMap::from([("done.service".to_string(), true)]);
1897 apply_oneshot_types(&mut stats, &types, &config);
1898
1899 assert!(
1900 stats
1901 .unit_states
1902 .get("done.service")
1903 .expect("done.service should have a unit_states entry")
1904 .unhealthy
1905 );
1906 }
1907
1908 #[tokio::test]
1909 async fn test_allowlist_filtering() {
1910 let mut stats = SystemdUnitStats::default();
1911 let config = crate::config::UnitsConfig {
1912 enabled: true,
1913 state_stats: true,
1914 state_stats_allowlist: HashSet::from(["allowed.service".to_string()]),
1915 state_stats_blocklist: HashSet::new(),
1916 state_stats_time_in_state: false,
1917 ignore_inactive_oneshot_services: true,
1918 unit_files: true,
1919 ..Default::default()
1920 };
1921
1922 let metric1 = ListOutput {
1924 name: "io.systemd.Manager.UnitActiveState".to_string(),
1925 value: string_value("active"),
1926 object: Some("allowed.service".to_string()),
1927 fields: None,
1928 };
1929 parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
1930 .expect("metric1 should parse successfully");
1931 assert!(stats.unit_states.contains_key("allowed.service"));
1932
1933 let metric2 = ListOutput {
1935 name: "io.systemd.Manager.UnitActiveState".to_string(),
1936 value: string_value("active"),
1937 object: Some("not-allowed.service".to_string()),
1938 fields: None,
1939 };
1940 parse_one_metric(&mut stats, &metric2, &config, &HashSet::new(), false)
1941 .expect("metric2 should parse successfully");
1942 assert!(!stats.unit_states.contains_key("not-allowed.service"));
1943 }
1944
1945 #[tokio::test]
1946 async fn test_blocklist_filtering() {
1947 let mut stats = SystemdUnitStats::default();
1948 let config = crate::config::UnitsConfig {
1949 enabled: true,
1950 state_stats: true,
1951 state_stats_allowlist: HashSet::new(),
1952 state_stats_blocklist: HashSet::from(["blocked.service".to_string()]),
1953 state_stats_time_in_state: false,
1954 ignore_inactive_oneshot_services: true,
1955 unit_files: true,
1956 ..Default::default()
1957 };
1958
1959 let metric1 = ListOutput {
1961 name: "io.systemd.Manager.UnitActiveState".to_string(),
1962 value: string_value("active"),
1963 object: Some("blocked.service".to_string()),
1964 fields: None,
1965 };
1966 parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
1967 .expect("metric1 should parse successfully");
1968 assert!(!stats.unit_states.contains_key("blocked.service"));
1969
1970 let metric2 = ListOutput {
1972 name: "io.systemd.Manager.UnitActiveState".to_string(),
1973 value: string_value("active"),
1974 object: Some("ok.service".to_string()),
1975 fields: None,
1976 };
1977 parse_one_metric(&mut stats, &metric2, &config, &HashSet::new(), false)
1978 .expect("metric2 should parse successfully");
1979 assert!(stats.unit_states.contains_key("ok.service"));
1980 }
1981
1982 #[tokio::test]
1983 async fn test_blocklist_overrides_allowlist() {
1984 let mut stats = SystemdUnitStats::default();
1985 let config = crate::config::UnitsConfig {
1986 enabled: true,
1987 state_stats: true,
1988 state_stats_allowlist: HashSet::from(["both.service".to_string()]),
1989 state_stats_blocklist: HashSet::from(["both.service".to_string()]),
1990 state_stats_time_in_state: false,
1991 ignore_inactive_oneshot_services: true,
1992 unit_files: true,
1993 ..Default::default()
1994 };
1995
1996 let metric = ListOutput {
1998 name: "io.systemd.Manager.UnitActiveState".to_string(),
1999 value: string_value("active"),
2000 object: Some("both.service".to_string()),
2001 fields: None,
2002 };
2003 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
2004 .expect("metric should parse successfully");
2005 assert!(!stats.unit_states.contains_key("both.service"));
2006 }
2007
2008 #[test]
2009 fn test_load_state_counts_bypass_allowlist() {
2010 let config = crate::config::UnitsConfig {
2014 enabled: true,
2015 state_stats: true,
2016 state_stats_allowlist: HashSet::from(["allowed.service".to_string()]),
2018 state_stats_blocklist: HashSet::new(),
2019 state_stats_time_in_state: false,
2020 ignore_inactive_oneshot_services: true,
2021 unit_files: true,
2022 ..Default::default()
2023 };
2024 let mut stats = SystemdUnitStats::default();
2025
2026 let metrics = vec![
2027 ListOutput {
2029 name: "io.systemd.Manager.UnitLoadState".to_string(),
2030 value: string_value("loaded"),
2031 object: Some("allowed.service".to_string()),
2032 fields: None,
2033 },
2034 ListOutput {
2036 name: "io.systemd.Manager.UnitLoadState".to_string(),
2037 value: string_value("loaded"),
2038 object: Some("other.service".to_string()),
2039 fields: None,
2040 },
2041 ListOutput {
2042 name: "io.systemd.Manager.UnitLoadState".to_string(),
2043 value: string_value("not-found"), object: Some("missing.service".to_string()),
2045 fields: None,
2046 },
2047 ListOutput {
2048 name: "io.systemd.Manager.UnitLoadState".to_string(),
2049 value: string_value("masked"),
2050 object: Some("masked.service".to_string()),
2051 fields: None,
2052 },
2053 ];
2054 for m in metrics {
2055 parse_one_metric(&mut stats, &m, &config, &HashSet::new(), false)
2056 .expect("m should parse successfully");
2057 }
2058
2059 assert_eq!(stats.loaded_units, 2);
2061 assert_eq!(stats.not_found_units, 1);
2062 assert_eq!(stats.masked_units, 1);
2063 assert_eq!(stats.unit_states.len(), 1);
2065 assert!(stats.unit_states.contains_key("allowed.service"));
2066 }
2067
2068 #[test]
2069 fn test_load_state_counts_when_state_stats_disabled() {
2070 let config = crate::config::UnitsConfig {
2072 enabled: true,
2073 state_stats: false,
2074 state_stats_allowlist: HashSet::new(),
2075 state_stats_blocklist: HashSet::new(),
2076 state_stats_time_in_state: false,
2077 ignore_inactive_oneshot_services: true,
2078 unit_files: true,
2079 ..Default::default()
2080 };
2081 let mut stats = SystemdUnitStats::default();
2082
2083 let metrics = vec![
2084 ListOutput {
2085 name: "io.systemd.Manager.UnitLoadState".to_string(),
2086 value: string_value("loaded"),
2087 object: Some("svc1.service".to_string()),
2088 fields: None,
2089 },
2090 ListOutput {
2091 name: "io.systemd.Manager.UnitLoadState".to_string(),
2092 value: string_value("not-found"), object: Some("svc2.service".to_string()),
2094 fields: None,
2095 },
2096 ];
2097 for m in metrics {
2098 parse_one_metric(&mut stats, &m, &config, &HashSet::new(), false)
2099 .expect("m should parse successfully");
2100 }
2101
2102 assert_eq!(stats.loaded_units, 1);
2103 assert_eq!(stats.not_found_units, 1);
2104 assert_eq!(stats.unit_states.len(), 0);
2106 }
2107
2108 #[test]
2109 fn test_units_by_load_state_total() {
2110 let mut stats = SystemdUnitStats::default();
2111 let config = default_units_config();
2112
2113 let totals = [
2114 ("loaded", 191),
2115 ("not-found", 6),
2116 ("masked", 2),
2117 ("stub", 3),
2118 ];
2119 for (load_state, count) in totals {
2120 let metric = ListOutput {
2121 name: "io.systemd.Manager.UnitsByLoadStateTotal".to_string(),
2122 value: int_value(count),
2123 object: None,
2124 fields: Some(std::collections::HashMap::from([(
2125 "load_state".to_string(),
2126 serde_json::json!(load_state),
2127 )])),
2128 };
2129 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), true)
2130 .expect("load state total should parse successfully");
2131 }
2132
2133 assert_eq!(stats.loaded_units, 191);
2134 assert_eq!(stats.not_found_units, 6);
2135 assert_eq!(stats.masked_units, 2);
2136
2137 let per_unit = ListOutput {
2140 name: "io.systemd.Manager.UnitLoadState".to_string(),
2141 value: string_value("loaded"),
2142 object: Some("my-service.service".to_string()),
2143 fields: None,
2144 };
2145 parse_one_metric(&mut stats, &per_unit, &config, &HashSet::new(), true)
2146 .expect("per-unit load state should parse successfully");
2147
2148 assert_eq!(stats.loaded_units, 191);
2149 assert_eq!(
2150 stats
2151 .unit_states
2152 .get("my-service.service")
2153 .expect("my-service.service should have a unit_states entry")
2154 .load_state,
2155 SystemdUnitLoadState::loaded
2156 );
2157 }
2158}