1use std::collections::BTreeSet;
7use std::collections::HashMap;
8use std::collections::HashSet;
9use std::str::FromStr;
10use std::sync::Arc;
11use std::sync::LazyLock;
12use std::sync::Mutex;
13use std::time::Instant;
14use std::time::SystemTime;
15use std::time::UNIX_EPOCH;
16
17use tokio::sync::RwLock;
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::varlink::metrics::{ListOutput, Metrics};
30use crate::MachineStats;
31use futures_util::future::{BoxFuture, FutureExt, Shared};
32use futures_util::stream::TryStreamExt;
33
34pub const METRICS_SOCKET_PATH: &str = "/run/systemd/report/io.systemd.Manager";
35
36pub type SharedMetrics =
44 Shared<BoxFuture<'static, Result<Arc<Vec<ListOutput>>, Arc<anyhow::Error>>>>;
45
46fn parse_metric_enum<T: FromStr>(metric: &ListOutput) -> Option<T> {
48 if !metric.value().is_string() {
49 warn!(
50 "Metric {} has non-string value: {:?}",
51 metric.name(),
52 metric.value()
53 );
54 return None;
55 }
56 let value_str = metric.value_as_string();
57 let normalized = value_str.replace('-', "_");
60 match T::from_str(&normalized) {
61 Ok(v) => Some(v),
62 Err(_) => {
63 warn!(
64 "Metric {} has unrecognized value: {:?}",
65 metric.name(),
66 value_str
67 );
68 None
69 }
70 }
71}
72
73fn should_skip_unit(object_name: &str, config: &crate::config::UnitsConfig) -> bool {
75 if config.state_stats_blocklist.contains(object_name) {
76 debug!("Skipping state stats for {} due to blocklist", object_name);
77 return true;
78 }
79 if !config.state_stats_allowlist.is_empty()
80 && !config.state_stats_allowlist.contains(object_name)
81 {
82 return true;
83 }
84 false
85}
86
87pub fn parse_one_metric(
99 stats: &mut SystemdUnitStats,
100 metric: &ListOutput,
101 config: &crate::config::UnitsConfig,
102 services: &HashSet<String>,
103 has_load_state_totals: bool,
104) -> anyhow::Result<()> {
105 let metric_name_suffix = metric.name_suffix();
106 let object_name = metric.object_name();
107
108 match metric_name_suffix {
109 "UnitActiveState" => {
110 if !config.state_stats || should_skip_unit(&object_name, config) {
111 return Ok(());
112 }
113 let active_state: SystemdUnitActiveState = match parse_metric_enum(metric) {
114 Some(v) => v,
115 None => return Ok(()),
116 };
117 let unit_state = stats
118 .unit_states
119 .entry(object_name.to_string())
120 .or_default();
121 unit_state.active_state = active_state;
122 unit_state.unhealthy =
123 is_unit_unhealthy(unit_state.active_state, unit_state.load_state);
124 }
125 "UnitLoadState" => {
126 let load_state: SystemdUnitLoadState = match parse_metric_enum(metric) {
127 Some(v) => v,
128 None => return Ok(()),
129 };
130 if !has_load_state_totals {
134 match load_state {
135 SystemdUnitLoadState::loaded => stats.loaded_units += 1,
136 SystemdUnitLoadState::masked => stats.masked_units += 1,
137 SystemdUnitLoadState::not_found => stats.not_found_units += 1,
138 _ => {}
139 }
140 }
141 if !config.state_stats || should_skip_unit(&object_name, config) {
143 return Ok(());
144 }
145 let unit_state = stats
146 .unit_states
147 .entry(object_name.to_string())
148 .or_default();
149 unit_state.load_state = load_state;
150 unit_state.unhealthy =
151 is_unit_unhealthy(unit_state.active_state, unit_state.load_state);
152 }
153 "NRestarts" => {
154 if !services.contains(&object_name) {
157 return Ok(());
158 }
159 if !metric.value().is_i64() {
160 warn!(
161 "Metric {} has non-integer value: {:?}",
162 metric.name(),
163 metric.value()
164 );
165 return Ok(());
166 }
167 let value = metric.value_as_int();
168 let nrestarts: u32 = match value.try_into() {
169 Ok(v) => v,
170 Err(_) => {
171 warn!(
172 "Metric {} has out-of-range value for u32: {}",
173 metric.name(),
174 value
175 );
176 return Ok(());
177 }
178 };
179 stats
180 .service_stats
181 .entry(object_name.to_string())
182 .or_default()
183 .nrestarts = nrestarts;
184 }
185 "StatusErrno" => {
186 if !services.contains(&object_name) {
188 return Ok(());
189 }
190 if !metric.value().is_i64() {
191 warn!(
192 "Metric {} has non-integer value: {:?}",
193 metric.name(),
194 metric.value()
195 );
196 return Ok(());
197 }
198 let value = metric.value_as_int();
199 let status_errno: i32 = match value.try_into() {
205 Ok(v) => v,
206 Err(_) => {
207 warn!(
208 "Metric {} has out-of-range value for i32: {}",
209 metric.name(),
210 value
211 );
212 return Ok(());
213 }
214 };
215 stats
216 .service_stats
217 .entry(object_name.to_string())
218 .or_default()
219 .status_errno = status_errno;
220 }
221 "UnitsByTypeTotal" => {
222 if let Some(type_str) = metric.get_field_as_str("type") {
223 if !metric.value().is_i64() {
224 warn!(
225 "Metric {} has non-integer value: {:?}",
226 metric.name(),
227 metric.value()
228 );
229 return Ok(());
230 }
231 let value = metric.value_as_int();
232 let value: u64 = match value.try_into() {
233 Ok(v) => v,
234 Err(_) => {
235 warn!("Metric {} has negative value: {}", metric.name(), value);
236 return Ok(());
237 }
238 };
239 match type_str {
240 "automount" => stats.automount_units = value,
241 "device" => stats.device_units = value,
242 "mount" => stats.mount_units = value,
243 "path" => stats.path_units = value,
244 "scope" => stats.scope_units = value,
245 "service" => stats.service_units = value,
246 "slice" => stats.slice_units = value,
247 "socket" => stats.socket_units = value,
248 "target" => stats.target_units = value,
249 "timer" => stats.timer_units = value,
250 _ => debug!("Found unhandled unit type: {:?}", type_str),
251 }
252 }
253 }
254 "UnitsByLoadStateTotal" => {
255 if let Some(load_state_str) = metric.get_field_as_str("load_state") {
256 if !metric.value().is_i64() {
257 warn!(
258 "Metric {} has non-integer value: {:?}",
259 metric.name(),
260 metric.value()
261 );
262 return Ok(());
263 }
264 let value = metric.value_as_int();
265 let value: u64 = match value.try_into() {
266 Ok(v) => v,
267 Err(_) => {
268 warn!("Metric {} has negative value: {}", metric.name(), value);
269 return Ok(());
270 }
271 };
272 match load_state_str {
273 "loaded" => stats.loaded_units = value,
274 "masked" => stats.masked_units = value,
275 "not-found" => stats.not_found_units = value,
276 _ => debug!("Found unhandled unit load state: {:?}", load_state_str),
279 }
280 }
281 }
282 "UnitsByStateTotal" => {
283 if let Some(state_str) = metric.get_field_as_str("state") {
284 if !metric.value().is_i64() {
285 warn!(
286 "Metric {} has non-integer value: {:?}",
287 metric.name(),
288 metric.value()
289 );
290 return Ok(());
291 }
292 let value = metric.value_as_int();
293 let value: u64 = match value.try_into() {
294 Ok(v) => v,
295 Err(_) => {
296 warn!("Metric {} has negative value: {}", metric.name(), value);
297 return Ok(());
298 }
299 };
300 match state_str {
301 "activating" => stats.activating_units = value,
302 "active" => stats.active_units = value,
303 "failed" => stats.failed_units = value,
304 "inactive" => stats.inactive_units = value,
305 _ => debug!("Found unhandled unit state: {:?}", state_str),
309 }
310 }
311 }
312 "JobsQueued" => {
313 if !metric.value().is_i64() {
314 warn!(
315 "Metric {} has non-integer value: {:?}",
316 metric.name(),
317 metric.value()
318 );
319 return Ok(());
320 }
321 let value = metric.value_as_int();
322 match value.try_into() {
323 Ok(value) => stats.jobs_queued = value,
324 Err(_) => {
325 warn!("Metric {} has negative value: {}", metric.name(), value);
326 }
327 }
328 }
329 "UnitsTotal" => {
330 if !metric.value().is_i64() {
331 warn!(
332 "Metric {} has non-integer value: {:?}",
333 metric.name(),
334 metric.value()
335 );
336 return Ok(());
337 }
338 let value = metric.value_as_int();
339 match value.try_into() {
340 Ok(value) => stats.total_units = value,
341 Err(_) => {
342 warn!("Metric {} has negative value: {}", metric.name(), value);
343 }
344 }
345 }
346 "StateChangeTimestamp" => {
347 if !config.state_stats
348 || !config.state_stats_time_in_state
349 || should_skip_unit(&object_name, config)
350 {
351 return Ok(());
352 }
353 if !metric.value().is_i64() {
354 warn!(
355 "Metric {} has non-integer value: {:?}",
356 metric.name(),
357 metric.value()
358 );
359 return Ok(());
360 }
361 let state_change_usec: u64 = match metric.value_as_int().try_into() {
362 Ok(value) => value,
363 Err(_) => {
364 warn!(
365 "Metric {} has out-of-range value: {}",
366 metric.name(),
367 metric.value_as_int()
368 );
369 return Ok(());
370 }
371 };
372 let now_usec = match SystemTime::now().duration_since(UNIX_EPOCH) {
373 Ok(elapsed) => elapsed.as_secs() * 1_000_000 + u64::from(elapsed.subsec_micros()),
374 Err(err) => {
375 warn!("System clock error computing time in state: {:?}", err);
376 return Ok(());
377 }
378 };
379 let Some(elapsed) = crate::units::compute_time_in_state(now_usec, state_change_usec)
382 else {
383 return Ok(());
384 };
385 stats
386 .unit_states
387 .entry(object_name.to_string())
388 .or_default()
389 .time_in_state_usecs = Some(elapsed);
390 }
391 _ => debug!("Found unhandled metric: {:?}", metric.name()),
392 }
393
394 Ok(())
395}
396
397pub(crate) async fn collect_metrics(
401 endpoint: crate::varlink::endpoint::VarlinkEndpoint,
402) -> anyhow::Result<Vec<ListOutput>> {
403 tokio::task::spawn_blocking(move || {
404 let rt = tokio::runtime::Builder::new_current_thread()
405 .enable_all()
406 .build()?;
407 rt.block_on(async move {
408 let mut conn = endpoint.connect().await?;
409 let stream = conn.list().await?;
410 futures_util::pin_mut!(stream);
411
412 let mut metrics = Vec::new();
413 let mut count = 0;
414 while let Some(result) = stream.try_next().await? {
415 let result: std::result::Result<ListOutput, _> = result;
416 match result {
417 Ok(metric) => {
418 debug!("Metrics {}: {:?}", count, metric);
419 count += 1;
420 metrics.push(metric);
421 }
422 Err(e) => {
423 debug!("Error deserializing metric {}: {:?}", count, e);
424 return Err(anyhow::anyhow!(e));
425 }
426 }
427 }
428 Ok(metrics)
429 })
430 })
431 .await?
432}
433
434#[derive(Clone, Debug, Default, PartialEq)]
437pub struct UnitDiscovery {
438 pub timer_names: Vec<String>,
440 pub reload_count: Option<u64>,
442}
443
444const MAX_CACHED_ENDPOINTS: usize = 64;
447
448#[derive(Debug, Default)]
463struct OneshotTypeCache {
464 endpoints: HashMap<String, (u64, HashMap<String, bool>)>,
465}
466
467static ONESHOT_TYPE_CACHE: LazyLock<Mutex<OneshotTypeCache>> = LazyLock::new(Mutex::default);
468
469fn oneshot_type_cache() -> std::sync::MutexGuard<'static, OneshotTypeCache> {
473 ONESHOT_TYPE_CACHE
474 .lock()
475 .unwrap_or_else(std::sync::PoisonError::into_inner)
476}
477
478impl OneshotTypeCache {
479 fn take(&mut self, endpoint: &str, reload_count: Option<u64>) -> HashMap<String, bool> {
482 match (self.endpoints.remove(endpoint), reload_count) {
483 (Some((cached_count, types)), Some(reload_count)) if cached_count == reload_count => {
484 types
485 }
486 _ => HashMap::new(),
487 }
488 }
489
490 fn store(&mut self, endpoint: String, reload_count: Option<u64>, types: HashMap<String, bool>) {
493 let Some(reload_count) = reload_count else {
494 debug!("No ReloadCount metric, not caching oneshot types for {endpoint}");
495 return;
496 };
497 if self.endpoints.len() >= MAX_CACHED_ENDPOINTS {
498 self.endpoints.clear();
499 }
500 self.endpoints.insert(endpoint, (reload_count, types));
501 }
502}
503
504pub fn shared_metrics(endpoint: crate::varlink::endpoint::VarlinkEndpoint) -> SharedMetrics {
509 async move {
510 collect_metrics(endpoint)
511 .await
512 .map(Arc::new)
513 .map_err(Arc::new)
514 }
515 .boxed()
516 .shared()
517}
518
519pub(crate) async fn await_metrics(shared: SharedMetrics) -> anyhow::Result<Arc<Vec<ListOutput>>> {
521 shared
522 .await
523 .map_err(|err| anyhow::anyhow!("{:#}", err))
524 .map_err(|err| err.context("io.systemd.Metrics.List failed"))
525}
526
527pub async fn parse_metrics(
528 stats: &mut SystemdUnitStats,
529 metrics: SharedMetrics,
530 config: &crate::config::UnitsConfig,
531 services: &HashSet<String>,
532) -> anyhow::Result<UnitDiscovery> {
533 let bulk_fetch_start = Instant::now();
541 let metrics = await_metrics(metrics).await?;
542 let bulk_fetch_elapsed = bulk_fetch_start.elapsed();
543 stats.collection_timings.list_units_ms = bulk_fetch_elapsed.as_secs_f64() * 1000.0;
544
545 let parse_loop_start = Instant::now();
546 let has_load_state_totals = metrics
547 .iter()
548 .any(|metric| metric.name_suffix() == "UnitsByLoadStateTotal");
549 for metric in metrics.iter() {
550 parse_one_metric(stats, metric, config, services, has_load_state_totals)?;
551 }
552 let timer_names: BTreeSet<String> = metrics
557 .iter()
558 .filter_map(|metric| metric.object())
559 .filter(|object| object.ends_with(SYSTEMD_TIMER_SUFFIX))
560 .map(|object| object.to_string())
561 .collect();
562
563 let parse_loop_elapsed = parse_loop_start.elapsed();
564 stats.collection_timings.per_unit_loop_ms = parse_loop_elapsed.as_secs_f64() * 1000.0;
565
566 let reload_count = metrics
567 .iter()
568 .find(|metric| metric.name_suffix() == "ReloadCount")
569 .and_then(|metric| metric.value().as_u64());
570
571 Ok(UnitDiscovery {
572 timer_names: timer_names.into_iter().collect(),
573 reload_count,
574 })
575}
576
577pub fn select_oneshot_candidates(
584 stats: &SystemdUnitStats,
585 config: &crate::config::UnitsConfig,
586) -> Vec<String> {
587 if !config.ignore_inactive_oneshot_services {
588 return Vec::new();
589 }
590 stats
591 .unit_states
592 .iter()
593 .filter(|(name, state)| {
594 name.ends_with(SYSTEMD_SERVICE_SUFFIX)
595 && matches!(state.active_state, SystemdUnitActiveState::inactive)
596 && matches!(state.load_state, SystemdUnitLoadState::loaded)
597 })
598 .map(|(name, _)| name.clone())
599 .collect()
600}
601
602pub fn apply_oneshot_types(
607 stats: &mut SystemdUnitStats,
608 oneshot_types: &HashMap<String, bool>,
609 config: &crate::config::UnitsConfig,
610) {
611 if !config.ignore_inactive_oneshot_services {
612 return;
613 }
614 for (unit_name, unit_state) in stats.unit_states.iter_mut() {
615 let is_oneshot = oneshot_types.get(unit_name).copied().unwrap_or(false);
616 unit_state.unhealthy = is_unit_unhealthy_for_service(
617 unit_state.active_state,
618 unit_state.load_state,
619 is_oneshot,
620 config.ignore_inactive_oneshot_services,
621 );
622 }
623}
624
625fn require_unit_context(
637 name: &str,
638 output: &crate::varlink::unit::ListOutput,
639) -> anyhow::Result<()> {
640 let context = output.context.as_ref();
641 let (section, present) = if name.ends_with(SYSTEMD_SERVICE_SUFFIX) {
642 (
643 "Service",
644 context
645 .and_then(|context| context.service.as_ref())
646 .is_some(),
647 )
648 } else if name.ends_with(SYSTEMD_TIMER_SUFFIX) {
649 (
650 "Timer",
651 context.and_then(|context| context.timer.as_ref()).is_some(),
652 )
653 } else {
654 return Ok(());
655 };
656 if !present {
657 anyhow::bail!(
658 "{} came back with no context.{}: this systemd is older than v261, \
659 where io.systemd.Unit gained the per-type context sections",
660 name,
661 section
662 );
663 }
664 Ok(())
665}
666
667fn select_timers(timer_names: &[String], config: &crate::config::TimersConfig) -> Vec<String> {
670 if !config.enabled {
671 return Vec::new();
672 }
673 timer_names
674 .iter()
675 .filter(|name| {
676 if config.blocklist.contains(*name) {
677 debug!("Skipping timer stats for {} due to blocklist", name);
678 return false;
679 }
680 config.allowlist.is_empty() || config.allowlist.contains(*name)
681 })
682 .cloned()
683 .collect()
684}
685
686pub async fn apply_unit_details(
697 endpoint: &crate::varlink::endpoint::VarlinkEndpoint,
698 locked_machine_stats: &Arc<RwLock<MachineStats>>,
699 config: &crate::config::Config,
700 fs_root: &str,
701 discovery: &UnitDiscovery,
702) -> anyhow::Result<()> {
703 let candidates = {
704 let machine_stats = locked_machine_stats.read().await;
705 select_oneshot_candidates(&machine_stats.units, &config.units)
706 };
707 let timers = select_timers(&discovery.timer_names, &config.timers);
708 if candidates.is_empty() && config.services.is_empty() && timers.is_empty() {
709 return Ok(());
710 }
711
712 let fetch_start = Instant::now();
713 let mut lookup = crate::varlink_unit::UnitLookup::connect(endpoint).await?;
714
715 let mut service_outputs: Vec<(String, crate::varlink::unit::ListOutput)> = Vec::new();
718 for name in &config.services {
719 let Some(output) = lookup.get(name).await? else {
720 continue;
721 };
722 let output = output.clone();
724 require_unit_context(name, &output)?;
725 service_outputs.push((name.clone(), output));
726 }
727
728 let host_memory = crate::cgroup::read_host_memory().await;
733 let service_cgroups =
734 futures_util::future::join_all(service_outputs.iter().map(|(_, output)| {
735 let cgroup_path = output
736 .runtime
737 .as_ref()
738 .and_then(|runtime| runtime.cgroup.as_ref())
739 .and_then(|cgroup| cgroup.path.as_deref())
740 .unwrap_or("");
741 let service = output
744 .runtime
745 .as_ref()
746 .and_then(|runtime| runtime.service.as_ref());
747 let pid = |pick: fn(
748 &crate::varlink::unit::ServiceRuntime,
749 ) -> Option<&crate::varlink::unit::ProcessId>| {
750 service.and_then(pick).and_then(|process| process.pid)
751 };
752 let extra_pids: Vec<u32> = [
756 pid(|service| service.main_pid.as_ref()),
757 pid(|service| service.control_pid.as_ref()),
758 ]
759 .into_iter()
760 .flatten()
761 .collect();
762 async move {
763 crate::cgroup::read_service_cgroup(fs_root, cgroup_path, &extra_pids, host_memory)
764 .await
765 }
766 }))
767 .await;
768
769 let mut service_stats: HashMap<String, crate::units::ServiceStats> = HashMap::new();
770 for ((name, output), cgroup) in service_outputs.into_iter().zip(service_cgroups) {
771 service_stats.insert(
772 name,
773 crate::varlink_unit::map_service_stats(&output, &cgroup),
774 );
775 }
776
777 let cache_key = endpoint.to_string();
778 let known_types = oneshot_type_cache().take(&cache_key, discovery.reload_count);
779 let mut oneshot_types: HashMap<String, bool> = HashMap::new();
780 for name in candidates {
781 if let Some(is_oneshot) = known_types.get(&name) {
782 oneshot_types.insert(name, *is_oneshot);
783 } else if let Some(output) = lookup.get(&name).await? {
784 let output = output.clone();
785 require_unit_context(&name, &output)?;
786 oneshot_types.insert(name, crate::varlink_unit::is_oneshot(&output));
787 }
788 }
789 oneshot_type_cache().store(cache_key, discovery.reload_count, oneshot_types.clone());
790
791 let mut timer_stats: HashMap<String, TimerStats> = HashMap::new();
795 for name in timers {
796 let Some(output) = lookup.get(&name).await? else {
797 continue;
798 };
799 let output = output.clone();
800 require_unit_context(&name, &output)?;
801 let triggered = match crate::varlink_unit::timer_triggered_unit(&output) {
808 Some(triggered) => lookup.get(triggered).await?.cloned(),
809 None => {
810 error!("{}: No service unit name found for timer.", name);
811 None
812 }
813 };
814 timer_stats.insert(
815 name,
816 crate::varlink_unit::map_timer_stats(&output, triggered.as_ref()),
817 );
818 }
819
820 let fetch_elapsed_ms = fetch_start.elapsed().as_secs_f64() * 1000.0;
821 debug!(
822 "Varlink unit details: {} unit(s) fetched for {} service(s) + {} oneshot candidate(s) ({} cached) in {:.2}ms",
823 lookup.fetches(),
824 config.services.len(),
825 oneshot_types.len(),
826 known_types.len(),
827 fetch_elapsed_ms
828 );
829 let mut machine_stats = locked_machine_stats.write().await;
830 machine_stats.units.timer_persistent_units = timer_stats
831 .values()
832 .filter(|timer| timer.persistent)
833 .count() as u64;
834 machine_stats.units.timer_remain_after_elapse = timer_stats
835 .values()
836 .filter(|timer| timer.remain_after_elapse)
837 .count() as u64;
838 machine_stats.units.timer_stats = timer_stats;
839 machine_stats.units.service_stats.extend(service_stats);
840 apply_oneshot_types(&mut machine_stats.units, &oneshot_types, &config.units);
841 machine_stats.units.collection_timings.per_unit_loop_ms += fetch_elapsed_ms;
844 Ok(())
845}
846
847fn sum_units_by_type(stats: &SystemdUnitStats) -> u64 {
851 stats.automount_units
852 + stats.device_units
853 + stats.mount_units
854 + stats.path_units
855 + stats.scope_units
856 + stats.service_units
857 + stats.slice_units
858 + stats.socket_units
859 + stats.target_units
860 + stats.timer_units
861}
862
863pub async fn get_unit_stats(
864 config: &crate::config::Config,
865 metrics: SharedMetrics,
866) -> anyhow::Result<(SystemdUnitStats, UnitDiscovery)> {
867 if !config.units.state_stats_allowlist.is_empty() {
868 debug!(
869 "Using unit state allowlist: {:?}",
870 config.units.state_stats_allowlist
871 );
872 }
873
874 if !config.units.state_stats_blocklist.is_empty() {
875 debug!(
876 "Using unit state blocklist: {:?}",
877 config.units.state_stats_blocklist,
878 );
879 }
880
881 let mut stats = SystemdUnitStats::default();
882
883 let discovery = parse_metrics(&mut stats, metrics, &config.units, &config.services).await?;
886
887 if stats.total_units == 0 {
891 stats.total_units = sum_units_by_type(&stats);
892 }
893
894 debug!("unit stats: {:?}", stats);
895 Ok((stats, discovery))
896}
897
898pub async fn update_unit_stats(
900 config: Arc<crate::config::Config>,
901 locked_machine_stats: Arc<RwLock<MachineStats>>,
902 metrics: SharedMetrics,
903) -> anyhow::Result<UnitDiscovery> {
904 let (units_stats, discovery) = get_unit_stats(&config, metrics).await?;
905 let mut machine_stats = locked_machine_stats.write().await;
906 machine_stats.units = units_stats;
907 Ok(discovery)
908}
909
910#[cfg(test)]
911mod tests {
912 use super::*;
913 use std::collections::HashSet;
914 use std::time::Duration;
915
916 #[tokio::test]
917 async fn test_shared_metrics_streams_once_for_every_awaiter() {
918 let dir = tempfile::tempdir().expect("tempdir");
921 let path = dir.path().join("metrics.sock");
922 let listener = tokio::net::UnixListener::bind(&path).expect("bind");
923 let server = tokio::spawn(async move {
924 let (mut socket, _) = listener.accept().await.expect("accept");
925 let mut request = vec![0u8; 4096];
926 let _ = tokio::io::AsyncReadExt::read(&mut socket, &mut request).await;
927 tokio::io::AsyncWriteExt::write_all(
928 &mut socket,
929 b"{\"parameters\":{\"name\":\"io.systemd.Manager.UnitsTotal\",\"value\":1}}\0",
930 )
931 .await
932 .expect("write reply");
933 listener
936 });
937
938 let shared = shared_metrics(path.to_str().expect("utf-8 path").into());
939 let (first, second) = tokio::time::timeout(Duration::from_secs(5), async {
940 tokio::join!(await_metrics(shared.clone()), await_metrics(shared))
941 })
942 .await
943 .expect("a second stream was opened");
944 let (first, second) = (first.expect("first"), second.expect("second"));
945 assert_eq!(first.len(), 1);
946 assert!(Arc::ptr_eq(&first, &second));
947 server.await.expect("server");
948 }
949
950 fn types(names: &[(&str, bool)]) -> HashMap<String, bool> {
951 names
952 .iter()
953 .map(|(name, is_oneshot)| (name.to_string(), *is_oneshot))
954 .collect()
955 }
956
957 #[test]
958 fn test_oneshot_cache_hits_while_reload_count_holds() {
959 let mut cache = OneshotTypeCache::default();
960 assert!(cache.take("host", Some(9)).is_empty());
961 cache.store("host".to_string(), Some(9), types(&[("a.service", true)]));
962 assert_eq!(cache.take("host", Some(9)), types(&[("a.service", true)]));
963 }
964
965 #[test]
966 fn test_oneshot_cache_invalidated_by_any_reload_count_change() {
967 for next in [10, 0] {
970 let mut cache = OneshotTypeCache::default();
971 cache.store("host".to_string(), Some(9), types(&[("a.service", true)]));
972 assert!(cache.take("host", Some(next)).is_empty());
973 }
974 }
975
976 #[test]
977 fn test_oneshot_cache_needs_a_reload_count() {
978 let mut cache = OneshotTypeCache::default();
980 cache.store("host".to_string(), None, types(&[("a.service", true)]));
981 assert!(cache.take("host", None).is_empty());
982 cache.store("host".to_string(), Some(1), types(&[("a.service", true)]));
983 assert!(cache.take("host", None).is_empty());
984 }
985
986 #[test]
987 fn test_oneshot_cache_keeps_endpoints_apart() {
988 let mut cache = OneshotTypeCache::default();
989 cache.store("host".to_string(), Some(1), types(&[("a.service", true)]));
990 assert!(cache.take("machine", Some(1)).is_empty());
991 assert!(!cache.take("host", Some(1)).is_empty());
992 }
993
994 #[test]
995 fn test_oneshot_cache_stays_bounded() {
996 let mut cache = OneshotTypeCache::default();
997 for leader in 0..MAX_CACHED_ENDPOINTS * 3 {
998 cache.store(format!("machine {leader}"), Some(1), HashMap::new());
999 assert!(cache.endpoints.len() <= MAX_CACHED_ENDPOINTS);
1000 }
1001 }
1002
1003 #[tokio::test]
1004 async fn test_shared_metrics_failure_reaches_every_awaiter() {
1005 let shared = shared_metrics("/nonexistent/io.systemd.Manager".into());
1007 let (first, second) = tokio::join!(await_metrics(shared.clone()), await_metrics(shared));
1008 assert!(first.is_err());
1009 assert!(second.is_err());
1010 }
1011
1012 fn string_value(s: &str) -> serde_json::Value {
1013 serde_json::json!(s)
1014 }
1015
1016 fn int_value(i: i64) -> serde_json::Value {
1017 serde_json::json!(i)
1018 }
1019
1020 fn empty_value() -> serde_json::Value {
1021 serde_json::Value::Null
1022 }
1023
1024 fn default_units_config() -> crate::config::UnitsConfig {
1025 crate::config::UnitsConfig {
1026 enabled: true,
1027 state_stats: true,
1028 state_stats_allowlist: HashSet::new(),
1029 state_stats_blocklist: HashSet::new(),
1030 state_stats_time_in_state: false,
1031 ignore_inactive_oneshot_services: true,
1032 unit_files: true,
1033 ..Default::default()
1034 }
1035 }
1036
1037 #[tokio::test]
1038 async fn test_parse_one_metric_unit_active_state() {
1039 let mut stats = SystemdUnitStats::default();
1040 let config = default_units_config();
1041
1042 let metric = ListOutput {
1043 name: "io.systemd.Manager.UnitActiveState".to_string(),
1044 value: string_value("active"),
1045 object: Some("my-service.service".to_string()),
1046 fields: None,
1047 };
1048
1049 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1050 .expect("metric should parse successfully");
1051
1052 assert_eq!(
1053 stats
1054 .unit_states
1055 .get("my-service.service")
1056 .expect("my-service.service should have a unit_states entry")
1057 .active_state,
1058 SystemdUnitActiveState::active
1059 );
1060 }
1061
1062 #[tokio::test]
1063 async fn test_parse_one_metric_unit_load_state() {
1064 let mut stats = SystemdUnitStats::default();
1065 let config = default_units_config();
1066
1067 let metric = ListOutput {
1070 name: "io.systemd.Manager.UnitLoadState".to_string(),
1071 value: string_value("not-found"),
1072 object: Some("missing.service".to_string()),
1073 fields: None,
1074 };
1075
1076 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1077 .expect("metric should parse successfully");
1078
1079 assert_eq!(
1080 stats
1081 .unit_states
1082 .get("missing.service")
1083 .expect("missing.service should have a unit_states entry")
1084 .load_state,
1085 SystemdUnitLoadState::not_found
1086 );
1087 }
1088
1089 #[test]
1090 fn test_parse_one_metric_nrestarts() {
1091 let mut stats = SystemdUnitStats::default();
1092 let config = default_units_config();
1093 let services = HashSet::from(["my-service.service".to_string()]);
1095
1096 let metric = ListOutput {
1097 name: "io.systemd.Manager.NRestarts".to_string(),
1098 value: int_value(5),
1099 object: Some("my-service.service".to_string()),
1100 fields: None,
1101 };
1102
1103 parse_one_metric(&mut stats, &metric, &config, &services, false)
1104 .expect("metric should parse successfully");
1105
1106 assert_eq!(
1107 stats
1108 .service_stats
1109 .get("my-service.service")
1110 .expect("my-service.service should have a service_stats entry")
1111 .nrestarts,
1112 5
1113 );
1114
1115 let other_metric = ListOutput {
1117 name: "io.systemd.Manager.NRestarts".to_string(),
1118 value: int_value(7),
1119 object: Some("other.service".to_string()),
1120 fields: None,
1121 };
1122 parse_one_metric(&mut stats, &other_metric, &config, &services, false)
1123 .expect("other_metric should parse successfully");
1124 assert!(!stats.service_stats.contains_key("other.service"));
1125 }
1126
1127 #[test]
1128 fn test_parse_one_metric_status_errno() {
1129 let mut stats = SystemdUnitStats::default();
1130 let config = default_units_config();
1131 let services = HashSet::from(["my-service.service".to_string()]);
1132
1133 let metric = ListOutput {
1135 name: "io.systemd.Manager.StatusErrno".to_string(),
1136 value: int_value(13),
1137 object: Some("my-service.service".to_string()),
1138 fields: None,
1139 };
1140 parse_one_metric(&mut stats, &metric, &config, &services, false)
1141 .expect("metric should parse successfully");
1142 assert_eq!(
1143 stats
1144 .service_stats
1145 .get("my-service.service")
1146 .expect("my-service.service should have a service_stats entry")
1147 .status_errno,
1148 13
1149 );
1150
1151 let out_of_range = ListOutput {
1154 name: "io.systemd.Manager.StatusErrno".to_string(),
1155 value: int_value(i64::from(i32::MAX) + 1),
1156 object: Some("my-service.service".to_string()),
1157 fields: None,
1158 };
1159 parse_one_metric(&mut stats, &out_of_range, &config, &services, false)
1160 .expect("out_of_range should parse successfully");
1161 assert_eq!(
1162 stats
1163 .service_stats
1164 .get("my-service.service")
1165 .expect("my-service.service should have a service_stats entry")
1166 .status_errno,
1167 13
1168 );
1169
1170 let other_metric = ListOutput {
1172 name: "io.systemd.Manager.StatusErrno".to_string(),
1173 value: int_value(1),
1174 object: Some("other.service".to_string()),
1175 fields: None,
1176 };
1177 parse_one_metric(&mut stats, &other_metric, &config, &services, false)
1178 .expect("other_metric should parse successfully");
1179 assert!(!stats.service_stats.contains_key("other.service"));
1180 }
1181
1182 #[tokio::test]
1183 async fn test_parse_aggregated_metrics() {
1184 let mut stats = SystemdUnitStats::default();
1185 let config = default_units_config();
1186
1187 let type_metric = ListOutput {
1189 name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
1190 value: int_value(42),
1191 object: None,
1192 fields: Some(std::collections::HashMap::from([(
1193 "type".to_string(),
1194 serde_json::json!("service"),
1195 )])),
1196 };
1197 parse_one_metric(&mut stats, &type_metric, &config, &HashSet::new(), false)
1198 .expect("type_metric should parse successfully");
1199 assert_eq!(stats.service_units, 42);
1200
1201 let state_metric = ListOutput {
1203 name: "io.systemd.Manager.UnitsByStateTotal".to_string(),
1204 value: int_value(10),
1205 object: None,
1206 fields: Some(std::collections::HashMap::from([(
1207 "state".to_string(),
1208 serde_json::json!("active"),
1209 )])),
1210 };
1211 parse_one_metric(&mut stats, &state_metric, &config, &HashSet::new(), false)
1212 .expect("state_metric should parse successfully");
1213 assert_eq!(stats.active_units, 10);
1214
1215 let activating_metric = ListOutput {
1217 name: "io.systemd.Manager.UnitsByStateTotal".to_string(),
1218 value: int_value(1),
1219 object: None,
1220 fields: Some(std::collections::HashMap::from([(
1221 "state".to_string(),
1222 serde_json::json!("activating"),
1223 )])),
1224 };
1225 parse_one_metric(
1226 &mut stats,
1227 &activating_metric,
1228 &config,
1229 &HashSet::new(),
1230 false,
1231 )
1232 .expect("activating_metric should parse successfully");
1233 assert_eq!(stats.activating_units, 1);
1234
1235 let jobs_metric = ListOutput {
1237 name: "io.systemd.Manager.JobsQueued".to_string(),
1238 value: int_value(3),
1239 object: None,
1240 fields: None,
1241 };
1242 parse_one_metric(&mut stats, &jobs_metric, &config, &HashSet::new(), false)
1243 .expect("jobs_metric should parse successfully");
1244 assert_eq!(stats.jobs_queued, 3);
1245
1246 let total_metric = ListOutput {
1248 name: "io.systemd.Manager.UnitsTotal".to_string(),
1249 value: int_value(196),
1250 object: None,
1251 fields: None,
1252 };
1253 parse_one_metric(&mut stats, &total_metric, &config, &HashSet::new(), false)
1254 .expect("total_metric should parse successfully");
1255 assert_eq!(stats.total_units, 196);
1256 }
1257
1258 #[test]
1259 fn test_require_unit_context_rejects_pre_v261_replies() {
1260 use crate::varlink::unit::{
1261 ListOutput as UnitListOutput, ServiceContext, TimerContext, UnitContext,
1262 };
1263
1264 let bare = UnitListOutput {
1269 context: Some(UnitContext {
1270 service: None,
1271 exec: None,
1272 timer: None,
1273 }),
1274 runtime: None,
1275 };
1276 assert!(require_unit_context("foo.service", &bare).is_err());
1277 assert!(require_unit_context("foo.timer", &bare).is_err());
1281 let empty = UnitListOutput {
1283 context: None,
1284 runtime: None,
1285 };
1286 assert!(require_unit_context("foo.service", &empty).is_err());
1287 assert!(require_unit_context("foo.timer", &empty).is_err());
1288
1289 let with_service = UnitListOutput {
1291 context: Some(UnitContext {
1292 service: Some(ServiceContext {
1293 r#type: Some("simple".to_string()),
1294 restart_usec: None,
1295 watchdog_usec: None,
1296 }),
1297 exec: None,
1298 timer: None,
1299 }),
1300 runtime: None,
1301 };
1302 assert!(require_unit_context("foo.service", &with_service).is_ok());
1303 let with_timer = UnitListOutput {
1304 context: Some(UnitContext {
1305 service: None,
1306 exec: None,
1307 timer: Some(TimerContext {
1308 unit: Some("foo.service".to_string()),
1309 accuracy_usec: Some(60_000_000),
1310 randomized_delay_usec: None,
1311 fixed_random_delay: None,
1312 persistent: None,
1313 remain_after_elapse: None,
1314 }),
1315 }),
1316 runtime: None,
1317 };
1318 assert!(require_unit_context("foo.timer", &with_timer).is_ok());
1319
1320 assert!(require_unit_context("foo.socket", &bare).is_ok());
1323 }
1324
1325 #[test]
1326 fn test_sum_units_by_type_fallback() {
1327 let stats = SystemdUnitStats {
1330 service_units: 86,
1331 timer_units: 2,
1332 ..Default::default()
1333 };
1334 assert_eq!(sum_units_by_type(&stats), 88);
1335 assert_eq!(sum_units_by_type(&SystemdUnitStats::default()), 0);
1336 }
1337
1338 #[test]
1339 fn test_state_change_timestamp_sets_time_in_state() {
1340 let mut stats = SystemdUnitStats::default();
1341 let mut config = default_units_config();
1342 config.state_stats_time_in_state = true;
1343 let now_usec = SystemTime::now()
1344 .duration_since(UNIX_EPOCH)
1345 .expect("test clock should work")
1346 .as_secs()
1347 * 1_000_000;
1348 let metric = ListOutput {
1349 name: "io.systemd.Manager.StateChangeTimestamp".to_string(),
1350 value: int_value((now_usec - 5_000_000) as i64),
1351 object: Some("test.service".to_string()),
1352 fields: None,
1353 };
1354
1355 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1356 .expect("timestamp metric should parse successfully");
1357
1358 let elapsed = stats
1359 .unit_states
1360 .get("test.service")
1361 .expect("test.service should have a unit_states entry")
1362 .time_in_state_usecs
1363 .expect("time in state should be set");
1364 assert!(
1365 (5_000_000..6_000_000).contains(&elapsed),
1366 "expected ~5s in state, got {}us",
1367 elapsed
1368 );
1369 }
1370
1371 #[test]
1372 fn test_state_change_timestamp_zero_stays_unknown() {
1373 let mut stats = SystemdUnitStats::default();
1378 let mut config = default_units_config();
1379 config.state_stats_time_in_state = true;
1380 let metric = ListOutput {
1381 name: "io.systemd.Manager.StateChangeTimestamp".to_string(),
1382 value: int_value(0),
1383 object: Some("test.service".to_string()),
1384 fields: None,
1385 };
1386
1387 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1388 .expect("zero timestamp should parse successfully");
1389
1390 assert!(stats.unit_states.get("test.service").is_none());
1391 }
1392
1393 #[test]
1394 fn test_state_change_timestamp_respects_gating() {
1395 let metric = ListOutput {
1396 name: "io.systemd.Manager.StateChangeTimestamp".to_string(),
1397 value: int_value(1_700_000_000_000_000),
1398 object: Some("test.service".to_string()),
1399 fields: None,
1400 };
1401
1402 let mut config = default_units_config();
1404 config.state_stats_time_in_state = false;
1405 let mut stats = SystemdUnitStats::default();
1406 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1407 .expect("metric should parse successfully");
1408 assert!(stats.unit_states.get("test.service").is_none());
1409
1410 let mut config = default_units_config();
1412 config.state_stats = false;
1413 let mut stats = SystemdUnitStats::default();
1414 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1415 .expect("metric should parse successfully");
1416 assert!(stats.unit_states.get("test.service").is_none());
1417
1418 let mut config = default_units_config();
1420 config.state_stats_blocklist = HashSet::from(["test.service".to_string()]);
1421 let mut stats = SystemdUnitStats::default();
1422 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1423 .expect("metric should parse successfully");
1424 assert!(stats.unit_states.get("test.service").is_none());
1425
1426 let mut config = default_units_config();
1430 config.state_stats_time_in_state = true;
1431 let mut stats = SystemdUnitStats::default();
1432 let negative = ListOutput {
1433 name: "io.systemd.Manager.StateChangeTimestamp".to_string(),
1434 value: int_value(-1),
1435 object: Some("test.service".to_string()),
1436 fields: None,
1437 };
1438 parse_one_metric(&mut stats, &negative, &config, &HashSet::new(), false)
1439 .expect("negative timestamp should parse successfully");
1440 let null_value = ListOutput {
1441 name: "io.systemd.Manager.StateChangeTimestamp".to_string(),
1442 value: empty_value(),
1443 object: Some("test.service".to_string()),
1444 fields: None,
1445 };
1446 parse_one_metric(&mut stats, &null_value, &config, &HashSet::new(), false)
1447 .expect("null timestamp should parse successfully");
1448 assert!(stats.unit_states.get("test.service").is_none());
1449 }
1450
1451 #[tokio::test]
1452 async fn test_parse_multiple_units() {
1453 let mut stats = SystemdUnitStats::default();
1454 let config = default_units_config();
1455
1456 let metrics = vec![
1457 ListOutput {
1458 name: "io.systemd.Manager.UnitActiveState".to_string(),
1459 value: string_value("active"),
1460 object: Some("service1.service".to_string()),
1461 fields: None,
1462 },
1463 ListOutput {
1464 name: "io.systemd.Manager.UnitLoadState".to_string(),
1465 value: string_value("loaded"),
1466 object: Some("service1.service".to_string()),
1467 fields: None,
1468 },
1469 ListOutput {
1470 name: "io.systemd.Manager.UnitActiveState".to_string(),
1471 value: string_value("failed"),
1472 object: Some("service-2.service".to_string()),
1473 fields: None,
1474 },
1475 ];
1476
1477 for metric in metrics {
1478 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
1479 .expect("metric should parse successfully");
1480 }
1481
1482 assert_eq!(stats.unit_states.len(), 2);
1483 assert_eq!(
1484 stats
1485 .unit_states
1486 .get("service1.service")
1487 .expect("service1.service should have a unit_states entry")
1488 .active_state,
1489 SystemdUnitActiveState::active
1490 );
1491 assert_eq!(
1492 stats
1493 .unit_states
1494 .get("service1.service")
1495 .expect("service1.service should have a unit_states entry")
1496 .load_state,
1497 SystemdUnitLoadState::loaded
1498 );
1499 assert_eq!(
1500 stats
1501 .unit_states
1502 .get("service-2.service")
1503 .expect("service-2.service should have a unit_states entry")
1504 .active_state,
1505 SystemdUnitActiveState::failed
1506 );
1507 }
1508
1509 #[test]
1510 fn test_parse_unknown_and_missing_values() {
1511 let mut stats = SystemdUnitStats::default();
1512 let config = default_units_config();
1513
1514 let metric1 = ListOutput {
1516 name: "io.systemd.Manager.UnitActiveState".to_string(),
1517 value: string_value("invalid_state"),
1518 object: Some("test.service".to_string()),
1519 fields: None,
1520 };
1521 parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
1522 .expect("metric1 should parse successfully");
1523 assert!(
1524 !stats.unit_states.contains_key("test.service"),
1525 "invalid state should be skipped"
1526 );
1527
1528 let metric2 = ListOutput {
1531 name: "io.systemd.Manager.NRestarts".to_string(),
1532 value: empty_value(),
1533 object: Some("test2.service".to_string()),
1534 fields: None,
1535 };
1536 let services = HashSet::from(["test2.service".to_string()]);
1537 parse_one_metric(&mut stats, &metric2, &config, &services, false)
1538 .expect("metric2 should parse successfully");
1539 assert!(
1540 !stats.service_stats.contains_key("test2.service"),
1541 "null value should be skipped"
1542 );
1543 }
1544
1545 #[tokio::test]
1546 async fn test_parse_edge_cases() {
1547 let mut stats = SystemdUnitStats::default();
1548 let config = default_units_config();
1549
1550 let metric1 = ListOutput {
1552 name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
1553 value: int_value(999),
1554 object: None,
1555 fields: Some(std::collections::HashMap::from([(
1556 "type".to_string(),
1557 serde_json::json!("unknown_type"),
1558 )])),
1559 };
1560 parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
1561 .expect("metric1 should parse successfully");
1562 assert_eq!(stats.service_units, 0);
1563
1564 let metric2 = ListOutput {
1566 name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
1567 value: int_value(42),
1568 object: None,
1569 fields: None,
1570 };
1571 parse_one_metric(&mut stats, &metric2, &config, &HashSet::new(), false)
1572 .expect("metric2 should parse successfully");
1573
1574 let metric3 = ListOutput {
1576 name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
1577 value: int_value(42),
1578 object: None,
1579 fields: Some(std::collections::HashMap::from([(
1580 "type".to_string(),
1581 serde_json::json!(123),
1582 )])),
1583 };
1584 parse_one_metric(&mut stats, &metric3, &config, &HashSet::new(), false)
1585 .expect("metric3 should parse successfully");
1586
1587 let metric4 = ListOutput {
1589 name: "io.systemd.Manager.UnknownMetric".to_string(),
1590 value: int_value(999),
1591 object: Some("test.service".to_string()),
1592 fields: None,
1593 };
1594 parse_one_metric(&mut stats, &metric4, &config, &HashSet::new(), false)
1595 .expect("metric4 should parse successfully");
1596 }
1597
1598 #[test]
1599 fn test_state_stats_disabled_skips_unit_states_only() {
1600 let config = crate::config::UnitsConfig {
1604 enabled: true,
1605 state_stats: false,
1606 state_stats_allowlist: HashSet::new(),
1607 state_stats_blocklist: HashSet::new(),
1608 state_stats_time_in_state: true,
1609 ignore_inactive_oneshot_services: true,
1610 unit_files: true,
1611 ..Default::default()
1612 };
1613 let services = HashSet::from(["test.service".to_string()]);
1614 let mut stats = SystemdUnitStats::default();
1615
1616 let active_state_metric = ListOutput {
1617 name: "io.systemd.Manager.UnitActiveState".to_string(),
1618 value: string_value("active"),
1619 object: Some("test.service".to_string()),
1620 fields: None,
1621 };
1622 parse_one_metric(&mut stats, &active_state_metric, &config, &services, false)
1623 .expect("active_state_metric should parse successfully");
1624
1625 let load_state_metric = ListOutput {
1626 name: "io.systemd.Manager.UnitLoadState".to_string(),
1627 value: string_value("loaded"),
1628 object: Some("test.service".to_string()),
1629 fields: None,
1630 };
1631 parse_one_metric(&mut stats, &load_state_metric, &config, &services, false)
1632 .expect("load_state_metric should parse successfully");
1633
1634 let nrestarts_metric = ListOutput {
1635 name: "io.systemd.Manager.NRestarts".to_string(),
1636 value: int_value(3),
1637 object: Some("test.service".to_string()),
1638 fields: None,
1639 };
1640 parse_one_metric(&mut stats, &nrestarts_metric, &config, &services, false)
1641 .expect("nrestarts_metric should parse successfully");
1642
1643 assert_eq!(stats.unit_states.len(), 0);
1646 assert_eq!(
1647 stats
1648 .service_stats
1649 .get("test.service")
1650 .expect("test.service should have a service_stats entry")
1651 .nrestarts,
1652 3
1653 );
1654
1655 let type_metric = ListOutput {
1657 name: "io.systemd.Manager.UnitsByTypeTotal".to_string(),
1658 value: int_value(10),
1659 object: None,
1660 fields: Some(std::collections::HashMap::from([(
1661 "type".to_string(),
1662 serde_json::json!("service"),
1663 )])),
1664 };
1665 parse_one_metric(&mut stats, &type_metric, &config, &HashSet::new(), false)
1666 .expect("type_metric should parse successfully");
1667 assert_eq!(stats.service_units, 10);
1668 }
1669
1670 #[test]
1671 fn test_parse_metric_enum() {
1672 let metric_active = ListOutput {
1673 name: "io.systemd.Manager.UnitActiveState".to_string(),
1674 value: string_value("active"),
1675 object: Some("test.service".to_string()),
1676 fields: None,
1677 };
1678 assert_eq!(
1679 parse_metric_enum::<SystemdUnitActiveState>(&metric_active),
1680 Some(SystemdUnitActiveState::active)
1681 );
1682
1683 let metric_loaded = ListOutput {
1684 name: "io.systemd.Manager.UnitLoadState".to_string(),
1685 value: string_value("loaded"),
1686 object: Some("test.service".to_string()),
1687 fields: None,
1688 };
1689 assert_eq!(
1690 parse_metric_enum::<SystemdUnitLoadState>(&metric_loaded),
1691 Some(SystemdUnitLoadState::loaded)
1692 );
1693
1694 let metric_invalid = ListOutput {
1696 name: "io.systemd.Manager.UnitActiveState".to_string(),
1697 value: string_value("invalid"),
1698 object: Some("test.service".to_string()),
1699 fields: None,
1700 };
1701 assert_eq!(
1702 parse_metric_enum::<SystemdUnitActiveState>(&metric_invalid),
1703 None
1704 );
1705
1706 let metric_empty = ListOutput {
1708 name: "io.systemd.Manager.UnitActiveState".to_string(),
1709 value: empty_value(),
1710 object: Some("test.service".to_string()),
1711 fields: None,
1712 };
1713 assert_eq!(
1714 parse_metric_enum::<SystemdUnitActiveState>(&metric_empty),
1715 None
1716 );
1717 }
1718
1719 #[test]
1720 fn test_parse_metric_enum_all_states() {
1721 let active_states = vec![
1723 ("active", SystemdUnitActiveState::active),
1724 ("reloading", SystemdUnitActiveState::reloading),
1725 ("inactive", SystemdUnitActiveState::inactive),
1726 ("failed", SystemdUnitActiveState::failed),
1727 ("activating", SystemdUnitActiveState::activating),
1728 ("deactivating", SystemdUnitActiveState::deactivating),
1729 ];
1730
1731 for (state_str, expected) in active_states {
1732 let metric = ListOutput {
1733 name: "io.systemd.Manager.UnitActiveState".to_string(),
1734 value: string_value(state_str),
1735 object: Some("test.service".to_string()),
1736 fields: None,
1737 };
1738 assert_eq!(
1739 parse_metric_enum::<SystemdUnitActiveState>(&metric),
1740 Some(expected)
1741 );
1742 }
1743
1744 let load_states = vec![
1746 ("loaded", SystemdUnitLoadState::loaded),
1747 ("error", SystemdUnitLoadState::error),
1748 ("masked", SystemdUnitLoadState::masked),
1749 ("not_found", SystemdUnitLoadState::not_found),
1750 ];
1751
1752 for (state_str, expected) in load_states {
1753 let metric = ListOutput {
1754 name: "io.systemd.Manager.UnitLoadState".to_string(),
1755 value: string_value(state_str),
1756 object: Some("test.service".to_string()),
1757 fields: None,
1758 };
1759 assert_eq!(
1760 parse_metric_enum::<SystemdUnitLoadState>(&metric),
1761 Some(expected)
1762 );
1763 }
1764 }
1765
1766 #[tokio::test]
1767 async fn test_parse_state_updates() {
1768 let mut stats = SystemdUnitStats::default();
1769 let config = default_units_config();
1770
1771 let metric1 = ListOutput {
1773 name: "io.systemd.Manager.UnitActiveState".to_string(),
1774 value: string_value("inactive"),
1775 object: Some("test.service".to_string()),
1776 fields: None,
1777 };
1778 parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
1779 .expect("metric1 should parse successfully");
1780 assert_eq!(
1781 stats
1782 .unit_states
1783 .get("test.service")
1784 .expect("test.service should have a unit_states entry")
1785 .active_state,
1786 SystemdUnitActiveState::inactive
1787 );
1788
1789 let metric2 = ListOutput {
1791 name: "io.systemd.Manager.UnitActiveState".to_string(),
1792 value: string_value("active"),
1793 object: Some("test.service".to_string()),
1794 fields: None,
1795 };
1796 parse_one_metric(&mut stats, &metric2, &config, &HashSet::new(), false)
1797 .expect("metric2 should parse successfully");
1798 assert_eq!(
1799 stats
1800 .unit_states
1801 .get("test.service")
1802 .expect("test.service should have a unit_states entry")
1803 .active_state,
1804 SystemdUnitActiveState::active
1805 );
1806 }
1807
1808 #[tokio::test]
1809 async fn test_unhealthy_computed() {
1810 let mut stats = SystemdUnitStats::default();
1811 let config = default_units_config();
1812
1813 let metric1 = ListOutput {
1815 name: "io.systemd.Manager.UnitActiveState".to_string(),
1816 value: string_value("failed"),
1817 object: Some("broken.service".to_string()),
1818 fields: None,
1819 };
1820 parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
1821 .expect("metric1 should parse successfully");
1822
1823 let metric2 = ListOutput {
1825 name: "io.systemd.Manager.UnitLoadState".to_string(),
1826 value: string_value("loaded"),
1827 object: Some("broken.service".to_string()),
1828 fields: None,
1829 };
1830 parse_one_metric(&mut stats, &metric2, &config, &HashSet::new(), false)
1831 .expect("metric2 should parse successfully");
1832
1833 assert!(
1835 stats
1836 .unit_states
1837 .get("broken.service")
1838 .expect("broken.service should have a unit_states entry")
1839 .unhealthy
1840 );
1841
1842 let metric3 = ListOutput {
1844 name: "io.systemd.Manager.UnitActiveState".to_string(),
1845 value: string_value("active"),
1846 object: Some("healthy.service".to_string()),
1847 fields: None,
1848 };
1849 parse_one_metric(&mut stats, &metric3, &config, &HashSet::new(), false)
1850 .expect("metric3 should parse successfully");
1851
1852 let metric4 = ListOutput {
1854 name: "io.systemd.Manager.UnitLoadState".to_string(),
1855 value: string_value("loaded"),
1856 object: Some("healthy.service".to_string()),
1857 fields: None,
1858 };
1859 parse_one_metric(&mut stats, &metric4, &config, &HashSet::new(), false)
1860 .expect("metric4 should parse successfully");
1861
1862 assert!(
1864 !stats
1865 .unit_states
1866 .get("healthy.service")
1867 .expect("healthy.service should have a unit_states entry")
1868 .unhealthy
1869 );
1870 }
1871
1872 #[test]
1873 fn test_oneshot_inactive_service_is_candidate() {
1874 let mut stats = SystemdUnitStats::default();
1875 let config = default_units_config();
1876 let metrics = vec![
1877 ListOutput {
1878 name: "io.systemd.Manager.UnitActiveState".to_string(),
1879 value: string_value("inactive"),
1880 object: Some("done.service".to_string()),
1881 fields: None,
1882 },
1883 ListOutput {
1884 name: "io.systemd.Manager.UnitLoadState".to_string(),
1885 value: string_value("loaded"),
1886 object: Some("done.service".to_string()),
1887 fields: None,
1888 },
1889 ];
1890
1891 for metric in &metrics {
1892 parse_one_metric(&mut stats, metric, &config, &HashSet::new(), false)
1893 .expect("metric should parse successfully");
1894 }
1895
1896 assert_eq!(
1897 select_oneshot_candidates(&stats, &config),
1898 vec!["done.service".to_string()]
1899 );
1900 }
1901
1902 #[test]
1903 fn test_oneshot_candidate_selection_skips_non_candidates() {
1904 let mut stats = SystemdUnitStats::default();
1905 let config = default_units_config();
1906 stats.unit_states.insert(
1908 "running.service".to_string(),
1909 crate::units::UnitStates {
1910 active_state: SystemdUnitActiveState::active,
1911 load_state: SystemdUnitLoadState::loaded,
1912 unhealthy: false,
1913 time_in_state_usecs: None,
1914 },
1915 );
1916 stats.unit_states.insert(
1918 "waiting.timer".to_string(),
1919 crate::units::UnitStates {
1920 active_state: SystemdUnitActiveState::inactive,
1921 load_state: SystemdUnitLoadState::loaded,
1922 unhealthy: true,
1923 time_in_state_usecs: None,
1924 },
1925 );
1926 stats.unit_states.insert(
1928 "masked.service".to_string(),
1929 crate::units::UnitStates {
1930 active_state: SystemdUnitActiveState::inactive,
1931 load_state: SystemdUnitLoadState::masked,
1932 unhealthy: false,
1933 time_in_state_usecs: None,
1934 },
1935 );
1936 stats.unit_states.insert(
1938 "done.service".to_string(),
1939 crate::units::UnitStates {
1940 active_state: SystemdUnitActiveState::inactive,
1941 load_state: SystemdUnitLoadState::loaded,
1942 unhealthy: true,
1943 time_in_state_usecs: None,
1944 },
1945 );
1946
1947 assert_eq!(
1948 select_oneshot_candidates(&stats, &config),
1949 vec!["done.service".to_string()]
1950 );
1951 }
1952
1953 #[test]
1954 fn test_oneshot_override_marks_oneshot_healthy() {
1955 let mut stats = SystemdUnitStats::default();
1956 let config = default_units_config();
1957 stats.unit_states.insert(
1958 "done.service".to_string(),
1959 crate::units::UnitStates {
1960 active_state: SystemdUnitActiveState::inactive,
1961 load_state: SystemdUnitLoadState::loaded,
1962 unhealthy: true,
1963 time_in_state_usecs: None,
1964 },
1965 );
1966 stats.unit_states.insert(
1967 "simple.service".to_string(),
1968 crate::units::UnitStates {
1969 active_state: SystemdUnitActiveState::inactive,
1970 load_state: SystemdUnitLoadState::loaded,
1971 unhealthy: true,
1972 time_in_state_usecs: None,
1973 },
1974 );
1975 stats.unit_states.insert(
1977 "failed.service".to_string(),
1978 crate::units::UnitStates {
1979 active_state: SystemdUnitActiveState::inactive,
1980 load_state: SystemdUnitLoadState::loaded,
1981 unhealthy: true,
1982 time_in_state_usecs: None,
1983 },
1984 );
1985 let types = std::collections::HashMap::from([
1986 ("done.service".to_string(), true),
1987 ("simple.service".to_string(), false),
1988 ]);
1989
1990 apply_oneshot_types(&mut stats, &types, &config);
1991
1992 assert!(
1993 !stats
1994 .unit_states
1995 .get("done.service")
1996 .expect("done.service should have a unit_states entry")
1997 .unhealthy
1998 );
1999 assert!(
2000 stats
2001 .unit_states
2002 .get("simple.service")
2003 .expect("simple.service should have a unit_states entry")
2004 .unhealthy
2005 );
2006 assert!(
2007 stats
2008 .unit_states
2009 .get("failed.service")
2010 .expect("failed.service should have a unit_states entry")
2011 .unhealthy
2012 );
2013 }
2014
2015 #[test]
2016 fn test_oneshot_override_can_be_disabled() {
2017 let mut stats = SystemdUnitStats::default();
2018 let mut config = default_units_config();
2019 config.ignore_inactive_oneshot_services = false;
2020 stats.unit_states.insert(
2021 "done.service".to_string(),
2022 crate::units::UnitStates {
2023 active_state: SystemdUnitActiveState::inactive,
2024 load_state: SystemdUnitLoadState::loaded,
2025 unhealthy: true,
2026 time_in_state_usecs: None,
2027 },
2028 );
2029
2030 assert!(select_oneshot_candidates(&stats, &config).is_empty());
2031
2032 let types = std::collections::HashMap::from([("done.service".to_string(), true)]);
2033 apply_oneshot_types(&mut stats, &types, &config);
2034
2035 assert!(
2036 stats
2037 .unit_states
2038 .get("done.service")
2039 .expect("done.service should have a unit_states entry")
2040 .unhealthy
2041 );
2042 }
2043
2044 #[tokio::test]
2045 async fn test_allowlist_filtering() {
2046 let mut stats = SystemdUnitStats::default();
2047 let config = crate::config::UnitsConfig {
2048 enabled: true,
2049 state_stats: true,
2050 state_stats_allowlist: HashSet::from(["allowed.service".to_string()]),
2051 state_stats_blocklist: HashSet::new(),
2052 state_stats_time_in_state: false,
2053 ignore_inactive_oneshot_services: true,
2054 unit_files: true,
2055 ..Default::default()
2056 };
2057
2058 let metric1 = ListOutput {
2060 name: "io.systemd.Manager.UnitActiveState".to_string(),
2061 value: string_value("active"),
2062 object: Some("allowed.service".to_string()),
2063 fields: None,
2064 };
2065 parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
2066 .expect("metric1 should parse successfully");
2067 assert!(stats.unit_states.contains_key("allowed.service"));
2068
2069 let metric2 = ListOutput {
2071 name: "io.systemd.Manager.UnitActiveState".to_string(),
2072 value: string_value("active"),
2073 object: Some("not-allowed.service".to_string()),
2074 fields: None,
2075 };
2076 parse_one_metric(&mut stats, &metric2, &config, &HashSet::new(), false)
2077 .expect("metric2 should parse successfully");
2078 assert!(!stats.unit_states.contains_key("not-allowed.service"));
2079 }
2080
2081 #[tokio::test]
2082 async fn test_blocklist_filtering() {
2083 let mut stats = SystemdUnitStats::default();
2084 let config = crate::config::UnitsConfig {
2085 enabled: true,
2086 state_stats: true,
2087 state_stats_allowlist: HashSet::new(),
2088 state_stats_blocklist: HashSet::from(["blocked.service".to_string()]),
2089 state_stats_time_in_state: false,
2090 ignore_inactive_oneshot_services: true,
2091 unit_files: true,
2092 ..Default::default()
2093 };
2094
2095 let metric1 = ListOutput {
2097 name: "io.systemd.Manager.UnitActiveState".to_string(),
2098 value: string_value("active"),
2099 object: Some("blocked.service".to_string()),
2100 fields: None,
2101 };
2102 parse_one_metric(&mut stats, &metric1, &config, &HashSet::new(), false)
2103 .expect("metric1 should parse successfully");
2104 assert!(!stats.unit_states.contains_key("blocked.service"));
2105
2106 let metric2 = ListOutput {
2108 name: "io.systemd.Manager.UnitActiveState".to_string(),
2109 value: string_value("active"),
2110 object: Some("ok.service".to_string()),
2111 fields: None,
2112 };
2113 parse_one_metric(&mut stats, &metric2, &config, &HashSet::new(), false)
2114 .expect("metric2 should parse successfully");
2115 assert!(stats.unit_states.contains_key("ok.service"));
2116 }
2117
2118 #[tokio::test]
2119 async fn test_blocklist_overrides_allowlist() {
2120 let mut stats = SystemdUnitStats::default();
2121 let config = crate::config::UnitsConfig {
2122 enabled: true,
2123 state_stats: true,
2124 state_stats_allowlist: HashSet::from(["both.service".to_string()]),
2125 state_stats_blocklist: HashSet::from(["both.service".to_string()]),
2126 state_stats_time_in_state: false,
2127 ignore_inactive_oneshot_services: true,
2128 unit_files: true,
2129 ..Default::default()
2130 };
2131
2132 let metric = ListOutput {
2134 name: "io.systemd.Manager.UnitActiveState".to_string(),
2135 value: string_value("active"),
2136 object: Some("both.service".to_string()),
2137 fields: None,
2138 };
2139 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), false)
2140 .expect("metric should parse successfully");
2141 assert!(!stats.unit_states.contains_key("both.service"));
2142 }
2143
2144 #[test]
2145 fn test_load_state_counts_bypass_allowlist() {
2146 let config = crate::config::UnitsConfig {
2150 enabled: true,
2151 state_stats: true,
2152 state_stats_allowlist: HashSet::from(["allowed.service".to_string()]),
2154 state_stats_blocklist: HashSet::new(),
2155 state_stats_time_in_state: false,
2156 ignore_inactive_oneshot_services: true,
2157 unit_files: true,
2158 ..Default::default()
2159 };
2160 let mut stats = SystemdUnitStats::default();
2161
2162 let metrics = vec![
2163 ListOutput {
2165 name: "io.systemd.Manager.UnitLoadState".to_string(),
2166 value: string_value("loaded"),
2167 object: Some("allowed.service".to_string()),
2168 fields: None,
2169 },
2170 ListOutput {
2172 name: "io.systemd.Manager.UnitLoadState".to_string(),
2173 value: string_value("loaded"),
2174 object: Some("other.service".to_string()),
2175 fields: None,
2176 },
2177 ListOutput {
2178 name: "io.systemd.Manager.UnitLoadState".to_string(),
2179 value: string_value("not-found"), object: Some("missing.service".to_string()),
2181 fields: None,
2182 },
2183 ListOutput {
2184 name: "io.systemd.Manager.UnitLoadState".to_string(),
2185 value: string_value("masked"),
2186 object: Some("masked.service".to_string()),
2187 fields: None,
2188 },
2189 ];
2190 for m in metrics {
2191 parse_one_metric(&mut stats, &m, &config, &HashSet::new(), false)
2192 .expect("m should parse successfully");
2193 }
2194
2195 assert_eq!(stats.loaded_units, 2);
2197 assert_eq!(stats.not_found_units, 1);
2198 assert_eq!(stats.masked_units, 1);
2199 assert_eq!(stats.unit_states.len(), 1);
2201 assert!(stats.unit_states.contains_key("allowed.service"));
2202 }
2203
2204 #[test]
2205 fn test_load_state_counts_when_state_stats_disabled() {
2206 let config = crate::config::UnitsConfig {
2208 enabled: true,
2209 state_stats: false,
2210 state_stats_allowlist: HashSet::new(),
2211 state_stats_blocklist: HashSet::new(),
2212 state_stats_time_in_state: false,
2213 ignore_inactive_oneshot_services: true,
2214 unit_files: true,
2215 ..Default::default()
2216 };
2217 let mut stats = SystemdUnitStats::default();
2218
2219 let metrics = vec![
2220 ListOutput {
2221 name: "io.systemd.Manager.UnitLoadState".to_string(),
2222 value: string_value("loaded"),
2223 object: Some("svc1.service".to_string()),
2224 fields: None,
2225 },
2226 ListOutput {
2227 name: "io.systemd.Manager.UnitLoadState".to_string(),
2228 value: string_value("not-found"), object: Some("svc2.service".to_string()),
2230 fields: None,
2231 },
2232 ];
2233 for m in metrics {
2234 parse_one_metric(&mut stats, &m, &config, &HashSet::new(), false)
2235 .expect("m should parse successfully");
2236 }
2237
2238 assert_eq!(stats.loaded_units, 1);
2239 assert_eq!(stats.not_found_units, 1);
2240 assert_eq!(stats.unit_states.len(), 0);
2242 }
2243
2244 #[test]
2245 fn test_units_by_load_state_total() {
2246 let mut stats = SystemdUnitStats::default();
2247 let config = default_units_config();
2248
2249 let totals = [
2250 ("loaded", 191),
2251 ("not-found", 6),
2252 ("masked", 2),
2253 ("stub", 3),
2254 ];
2255 for (load_state, count) in totals {
2256 let metric = ListOutput {
2257 name: "io.systemd.Manager.UnitsByLoadStateTotal".to_string(),
2258 value: int_value(count),
2259 object: None,
2260 fields: Some(std::collections::HashMap::from([(
2261 "load_state".to_string(),
2262 serde_json::json!(load_state),
2263 )])),
2264 };
2265 parse_one_metric(&mut stats, &metric, &config, &HashSet::new(), true)
2266 .expect("load state total should parse successfully");
2267 }
2268
2269 assert_eq!(stats.loaded_units, 191);
2270 assert_eq!(stats.not_found_units, 6);
2271 assert_eq!(stats.masked_units, 2);
2272
2273 let per_unit = ListOutput {
2276 name: "io.systemd.Manager.UnitLoadState".to_string(),
2277 value: string_value("loaded"),
2278 object: Some("my-service.service".to_string()),
2279 fields: None,
2280 };
2281 parse_one_metric(&mut stats, &per_unit, &config, &HashSet::new(), true)
2282 .expect("per-unit load state should parse successfully");
2283
2284 assert_eq!(stats.loaded_units, 191);
2285 assert_eq!(
2286 stats
2287 .unit_states
2288 .get("my-service.service")
2289 .expect("my-service.service should have a unit_states entry")
2290 .load_state,
2291 SystemdUnitLoadState::loaded
2292 );
2293 }
2294}