Skip to main content

monitord/
varlink_units.rs

1//! # units module
2//!
3//! All main systemd unit statistics. Counts of types of units, unit states and
4//! queued jobs. We also house service specific statistics and system unit states.
5
6use std::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
36/// One `io.systemd.Metrics.List` stream, awaited by every collector that reads
37/// it in a cycle.
38///
39/// PID 1 serves varlink requests one at a time and the stream is its most
40/// expensive call after a full `Unit.List`, so a second stream per cycle would
41/// cost PID 1 the whole walk again and make both collectors wait on each other.
42/// `Shared` needs a cloneable output, hence the `Arc`s.
43pub type SharedMetrics =
44    Shared<BoxFuture<'static, Result<Arc<Vec<ListOutput>>, Arc<anyhow::Error>>>>;
45
46/// Parse a string value from a metric into an enum type, warning on failure
47fn 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    // Normalize hyphens to underscores to match enum variant names (e.g. "not-found" -> "not_found"),
58    // mirroring the same replacement done in the D-Bus path (units.rs::parse_state).
59    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
73/// Check if a unit name should be skipped based on allowlist/blocklist
74fn 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
87/// Parse state of a unit into our unit_states hash
88///
89/// `services` is the `[services]` config list: per-service stats are tracked
90/// only for those units, mirroring the D-Bus path.
91///
92/// `has_load_state_totals` says whether this run's metrics include the exact
93/// `UnitsByLoadStateTotal` family. When they do, the per-unit `UnitLoadState`
94/// metrics must not also be counted into the load-state totals, or every unit
95/// would be counted twice; the metric stream has no guaranteed ordering, so
96/// the caller determines this up front rather than relying on which arrives
97/// first.
98pub 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            // Count aggregate load state totals, matching D-Bus parse_unit() behaviour
131            // which counts every unit regardless of the state_stats allowlist. Skipped
132            // when the exact UnitsByLoadStateTotal metric is available instead.
133            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            // Per-unit state tracking is gated by config.
142            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            // Service stats follow the [services] list (like the D-Bus path),
155            // independent of the state_stats gating used for unit_states.
156            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            // Per-service like NRestarts above: tracked for the [services] list only.
187            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            // systemd rejects a negative ERRNO= in sd_notify ("Numerical result
200            // out of range") and emits this metric unsigned, so the value is
201            // non-negative. It is still stored as i32 to match the D-Bus
202            // StatusErrno property that ServiceStats is typed from, which is
203            // what makes the conversion below worth checking.
204            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                    // The remaining load states (stub, merged, error, bad-setting)
277                    // have no counter, matching the D-Bus path.
278                    _ => 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                    // Other states (reloading, deactivating, maintenance,
306                    // refreshing) have no counter, matching the D-Bus path
307                    // which also only counts the four states above.
308                    _ => 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            // A zero/unknown timestamp leaves the entry untouched: the shared
380            // helper returns None instead of a bogus huge elapsed time.
381            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
397/// Collect all metrics from the varlink socket.
398/// Runs on a blocking thread with a dedicated runtime because the zlink
399/// stream is !Send and cannot be held across await points in a Send future.
400pub(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/// What the units pass learned from the metrics stream that the detail pass
435/// needs next.
436#[derive(Clone, Debug, Default, PartialEq)]
437pub struct UnitDiscovery {
438    /// Every timer unit, sorted.
439    pub timer_names: Vec<String>,
440    /// `io.systemd.Manager.ReloadCount`, which invalidates cached oneshot types.
441    pub reload_count: Option<u64>,
442}
443
444/// More endpoints than this and [`OneshotTypeCache`] starts over, so machines
445/// coming and going cannot grow it forever.
446const MAX_CACHED_ENDPOINTS: usize = 64;
447
448/// Oneshot verdicts from the previous cycle, per varlink endpoint.
449///
450/// The oneshot override wants `Service.Type` for every inactive service, and
451/// `Unit.List` has no way to ask for less than the whole ~7KB reply: on a host
452/// with 103 such services that is ~140ms of PID 1 per cycle, most of a run, for
453/// an answer that only changes when unit files are reloaded.
454///
455/// Entries live as long as `ReloadCount` holds still. Compared with `!=` since a
456/// `daemon-reexec` resets it to 0 rather than bumping it. Only units that were
457/// candidates last cycle are kept, so a service that ran or went away since is
458/// asked about again.
459///
460/// A process static because the exporter calls `stat_collector` once per scrape,
461/// so nothing owned by that call would outlive a cycle.
462#[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
469/// A poisoned lock only means a panic elsewhere mid-update. The worst that
470/// leaves behind is a missing entry, which costs a lookup, so it must not take
471/// the units collector down with it.
472fn 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    /// Hand over what is still valid for `endpoint`, leaving nothing behind:
480    /// `store` puts back the set this cycle actually used.
481    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    /// Without a `ReloadCount` nothing could ever invalidate the entry, so it
491    /// is not kept.
492    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
504/// Prepare the shared metrics stream for one collection cycle.
505///
506/// Nothing is sent until a collector awaits it, so building this for a cycle
507/// where every reader uses D-Bus costs nothing.
508pub 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
519/// Await the shared stream, turning its shared error back into an owned one.
520pub(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    // Parity with the D-Bus path's UnitsCollectionTimings: list_units_ms is the
534    // bulk fetch (varlink List on io.systemd.Manager), per_unit_loop_ms is the
535    // local parse loop plus the io.systemd.Unit.List detail pass that follows
536    // it. All three *_dbus_fetches counters stay 0 on the host varlink path,
537    // which no longer touches D-Bus at all; they only move for containers,
538    // which cannot reach their own varlink sockets (#211), or when the unit
539    // socket is unusable and the whole collection is redone over D-Bus.
540    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    // Timer units are enumerated from the metrics we already have rather than
553    // from a second bulk call: every unit appears here as a metric object, so
554    // this costs a scan of a vector we fetched anyway. Mirrors how the D-Bus
555    // path picks timers out of its ListUnits reply.
556    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
577/// Select unit names whose health needs a service-type check.
578///
579/// Mirrors the condition in `units::parse_state`: only inactive, loaded
580/// `.service` units can be rescued by the oneshot override, so only those
581/// need the `Service.Type` lookup. Pure function over already collected
582/// stats, so it runs under a read lock without any I/O.
583pub 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
602/// Recompute `unhealthy` for tracked units given resolved service types.
603///
604/// Units missing from `oneshot_types` (lookup failed or never a candidate)
605/// are treated as "not oneshot", matching the D-Bus path's assumption.
606pub 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
625/// Refuse a `Unit.List` reply that carries no per-type context section.
626///
627/// systemd v258 through v260 answer `Unit.List` successfully but without the
628/// per-type context sections: `src/core/varlink-service.c` and
629/// `src/core/varlink-timer.c` both land in v261. Without this check every field
630/// would map to its default on those versions, the call would look like a
631/// success, and the D-Bus fallback would never run — silently wrong numbers
632/// rather than a visible failure.
633///
634/// Only the unit types monitord maps are checked, so an unexpected type listed
635/// in `[services]` is treated as a config mistake rather than an old systemd.
636fn 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
667/// Pick the timers to collect, applying the same `[timers]` gating as the
668/// D-Bus path in `units::parse_unit_state`.
669fn 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
686/// Collect per-unit detail over varlink: `ServiceStats` for the `[services]`
687/// list, the tracked timers, and the service types the oneshot health override
688/// needs.
689///
690/// Both come from `io.systemd.Unit.List`, through one connection and one cache,
691/// so a unit wanted by both is fetched once. This replaces the per-service and
692/// oneshot-type D-Bus fetches the varlink path used to fall back to.
693///
694/// Selection runs under a read lock and the varlink round trips hold no lock at
695/// all, so they never block other collectors on the shared `MachineStats` lock.
696pub 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    // Phase 1: resolve every wanted service over varlink (sequential — PID 1
716    // serves these one at a time) and remember the replies with live cgroups.
717    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        // Cloned so the cache entry stays available to the oneshot pass below.
723        let output = output.clone();
724        require_unit_context(name, &output)?;
725        service_outputs.push((name.clone(), output));
726    }
727
728    // Phase 2: read every service's cgroup files concurrently. No IPC here,
729    // just local filesystem reads, so all units go out in one batch.
730    // `host_memory` is read once here and shared by every service rather
731    // than once per service inside the reader.
732    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            // Main + control PIDs, folded into the process count the way
742            // `GetProcesses` does (both can sit outside the cgroup).
743            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            // An empty cgroup path (inactive unit) short-circuits inside
753            // `read_service_cgroup`, with every field falling back to the
754            // reply values inside `map_service_stats`.
755            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    // Timers need a second unit each — the one they trigger — for its state
792    // change timestamps. That unit is often already in [services], which is
793    // where the lookup cache earns its place.
794    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        // If the triggered unit cannot be resolved the timer is still reported,
802        // with its own fields intact and the two trigger timestamps at 0. The
803        // D-Bus path instead drops the timer entirely, because its GetUnit call
804        // errors and the whole per-timer fetch is abandoned. Keeping it is the
805        // more useful of the two: a timer whose target is unloaded still has a
806        // real accuracy, next elapse and last trigger worth reporting.
807        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    // The phase still folds into per_unit_loop_ms like its D-Bus predecessor,
842    // but nothing here touches D-Bus, so the *_dbus_fetches counters stay put.
843    machine_stats.units.collection_timings.per_unit_loop_ms += fetch_elapsed_ms;
844    Ok(())
845}
846
847/// Sum per-type counts as a fallback total for systemd versions whose metrics
848/// lack `UnitsTotal`. Mirrors what the D-Bus path computes as `units.len()`,
849/// except unmapped types (e.g. swap) are missed.
850fn 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    // Always collect metrics to get aggregate counts (UnitsByTypeTotal, UnitsByStateTotal)
884    // as well as per-unit state data when config.units.state_stats is enabled.
885    let discovery = parse_metrics(&mut stats, metrics, &config.units, &config.services).await?;
886
887    // Prefer the UnitsTotal metric when present: it is exact, including unit
888    // types we do not map (e.g. swap). Fall back to summing per-type counts
889    // on systemd versions whose metrics lack it.
890    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
898/// Async wrapper that can update unit stats when passed a locked struct.
899pub 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        // PID 1 walks every unit per stream, so two collectors in one cycle
919        // must land on one connection, not two.
920        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            // Stays bound and is never accepted again: a second connection
934            // would never get a reply and trip the timeout below.
935            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        // A daemon-reload bumps the count, a daemon-reexec resets it to 0:
968        // going backwards has to invalidate just the same.
969        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        // Nothing could invalidate the entry, so it must never be served.
979        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        // Each reader falls back on its own, so an error has to reach them all.
1006        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        // systemd sends "not-found" with a hyphen over both D-Bus and varlink;
1068        // parse_metric_enum must normalize it to "not_found" before enum parsing.
1069        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        // NRestarts is tracked per the [services] list, mirroring the D-Bus path.
1094        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        // Units outside [services] get no service_stats entry even with data present.
1116        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        // A service reporting EACCES via sd_notify's ERRNO=13, as seen live.
1134        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        // Out of i32 range: warn and leave the previous value alone rather than
1152        // wrapping, since the metric is serialized unsigned.
1153        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        // Units outside [services] get no service_stats entry even with data present.
1171        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        // Test UnitsByTypeTotal
1188        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        // Test UnitsByStateTotal
1202        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        // Test UnitsByStateTotal with activating state
1216        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        // Test JobsQueued
1236        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        // Test UnitsTotal
1247        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        // systemd v258-v260 answer Unit.List successfully but omit the
1265        // per-type context sections, so a reply that parses fine still carries
1266        // nothing we can map. CI only ever runs Rawhide, so this shape cannot
1267        // be reached there and has to be pinned here instead.
1268        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        // Timers need the same guard: without it a v260 host with timers
1278        // enabled and no [services] would report every timer as all zeroes
1279        // and never fall back.
1280        assert!(require_unit_context("foo.timer", &bare).is_err());
1281        // Nothing at all is equally unusable.
1282        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        // v261+ replies pass.
1290        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        // A type monitord does not map is a config mistake, not an old
1321        // systemd, so it must not trigger a version fallback.
1322        assert!(require_unit_context("foo.socket", &bare).is_ok());
1323    }
1324
1325    #[test]
1326    fn test_sum_units_by_type_fallback() {
1327        // The fallback sums mapped per-type counts; unmapped types like swap
1328        // are missed, which is why the UnitsTotal metric is preferred.
1329        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        // 0 means no state change has occurred: no entry is created for the
1374        // timestamp alone, so no bogus huge elapsed time is ever reported.
1375        // (In practice the Active/LoadState arms create the entry with
1376        // time_in_state_usecs left as None.)
1377        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        // Disabled via state_stats_time_in_state.
1403        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        // Disabled via state_stats.
1411        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        // Excluded via blocklist.
1419        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        // Negative and non-integer values are skipped, not stored. Tracking
1427        // must be enabled here so these calls reach validation instead of
1428        // returning at the gating checks above.
1429        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        // Unknown active state is skipped (not silently defaulted)
1515        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        // Missing nrestarts value (null) is skipped. The service must be in
1529        // [services] so the null-value path (not the gating) is what skips it.
1530        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        // Unknown unit type is ignored gracefully
1551        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        // Metric with no fields is handled gracefully
1565        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        // Non-string field value is ignored
1575        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        // Unhandled metric name is ignored
1588        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        // When state_stats=false, UnitActiveState / UnitLoadState are skipped so
1601        // unit_states remains empty. NRestarts follows the [services] list
1602        // instead (like the D-Bus path) and is unaffected by state_stats.
1603        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        // Per-unit state data must be absent when state_stats=false, but the
1644        // [services]-listed unit still gets its restart count.
1645        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        // But aggregate type/state counts must still be processed (they are not gated on state_stats)
1656        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        // Invalid value returns None
1695        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        // Null value returns None
1707        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        // Test all active states
1722        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        // Test all load states
1745        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        // Parse initial state
1772        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        // Update to active state
1790        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        // Set active state to failed
1814        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        // Set load state to loaded
1824        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        // Should be unhealthy: loaded + failed
1834        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        // Set active state to active
1843        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        // Set load state to loaded
1853        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        // Should be healthy: loaded + active
1863        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        // Active service: healthy already, no type lookup needed.
1907        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        // Non-service unit: service type does not apply.
1917        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        // Masked service: never unhealthy, no type lookup needed.
1927        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        // The only real candidate.
1937        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        // failed.service has no type entry (lookup failed): stays unhealthy.
1976        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        // Allowed unit should be tracked
2059        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        // Non-allowed unit should be skipped
2070        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        // Blocked unit should be skipped
2096        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        // Non-blocked unit should be tracked
2107        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        // Unit in both lists should be blocked (blocklist takes priority)
2133        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        // Counting path used when UnitsByLoadStateTotal is absent (systemd v260):
2147        // loaded_units/masked_units/not_found_units must be counted for every unit,
2148        // regardless of the state_stats allowlist (matching D-Bus parse_unit() behaviour).
2149        let config = crate::config::UnitsConfig {
2150            enabled: true,
2151            state_stats: true,
2152            // Only "allowed.service" is in the allowlist
2153            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            // allowed unit → counts AND stored in unit_states
2164            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            // non-allowed unit → only counted, NOT stored in unit_states
2171            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"), // systemd sends hyphenated form over the wire
2180                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        // Aggregate counts include ALL units regardless of allowlist
2196        assert_eq!(stats.loaded_units, 2);
2197        assert_eq!(stats.not_found_units, 1);
2198        assert_eq!(stats.masked_units, 1);
2199        // per-unit state only tracked for the allowed unit
2200        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        // Even when state_stats=false, aggregate load state counts must be populated.
2207        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"), // systemd sends hyphenated form over the wire
2229                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        // No per-unit state tracking when state_stats=false
2241        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        // Per-unit metrics must not add to the totals once the aggregate is in
2274        // play, but per-unit state tracking carries on as before.
2275        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}