Skip to main content

monitord/
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::HashMap;
7use std::str::FromStr;
8use std::sync::Arc;
9use std::time::Instant;
10use std::time::SystemTime;
11use std::time::UNIX_EPOCH;
12
13use struct_field_names_as_array::FieldNamesAsArray;
14use thiserror::Error;
15use tokio::sync::RwLock;
16use tracing::debug;
17use tracing::error;
18use tracing::warn;
19use zbus::zvariant::ObjectPath;
20use zbus::zvariant::OwnedObjectPath;
21
22#[derive(Error, Debug)]
23pub enum MonitordUnitsError {
24    #[error("Units D-Bus error: {0}")]
25    ZbusError(#[from] zbus::Error),
26    #[error("Integer conversion error: {0}")]
27    IntConversion(#[from] std::num::TryFromIntError),
28    #[error("System time error: {0}")]
29    SystemTimeError(#[from] std::time::SystemTimeError),
30}
31
32use crate::timer::TimerStats;
33use crate::MachineStats;
34
35// Re-export the enums and function from unit_constants for backwards compatibility
36pub use crate::unit_constants::is_unit_unhealthy;
37pub use crate::unit_constants::SystemdUnitActiveState;
38pub use crate::unit_constants::SystemdUnitLoadState;
39
40/// Inner timing breakdown for the units collector D-Bus phases.
41///
42/// Helps locate which step of unit collection dominates wall time when the
43/// `units` collector is the slowest one in `MonitordStats::collector_timings`.
44#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, PartialEq)]
45pub struct UnitsCollectionTimings {
46    /// Time for the systemd ListUnits D-Bus call (one batched call returning all units).
47    pub list_units_ms: f64,
48    /// Time for filesystem unit file stats collection (runs concurrently with list_units).
49    pub unit_files_ms: f64,
50    /// Time spent in the per-unit parse loop, including any per-unit D-Bus calls
51    /// (timer property fetches, state stats, service stats).
52    pub per_unit_loop_ms: f64,
53    /// Number of timer units whose properties were fetched via D-Bus this run.
54    pub timer_dbus_fetches: u64,
55    /// Number of unit state D-Bus fetches this run (when state_stats_time_in_state is enabled).
56    pub state_dbus_fetches: u64,
57    /// Number of per-service D-Bus property fetches this run.
58    pub service_dbus_fetches: u64,
59}
60
61/// Unit file counts for a scope (root or user), broken down by unit type.
62#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, PartialEq)]
63pub struct UnitFilesScope {
64    /// Generated unit files by type (e.g. "service" => 2, "mount" => 5)
65    pub generated: HashMap<String, u64>,
66    /// Transient unit files by type (e.g. "service" => 10, "scope" => 6)
67    pub transient: HashMap<String, u64>,
68}
69
70/// Unit file statistics collected from the filesystem for root and user scopes.
71#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, Default, PartialEq)]
72pub struct UnitFilesStats {
73    pub root: UnitFilesScope,
74    pub user: UnitFilesScope,
75}
76
77#[derive(
78    serde::Serialize, serde::Deserialize, Clone, Debug, Default, FieldNamesAsArray, PartialEq,
79)]
80
81/// Aggregated systemd unit statistics: counts by type, load state, active state,
82/// plus optional per-service and per-timer detailed metrics
83pub struct SystemdUnitStats {
84    /// Number of units in the "activating" state (in the process of being started)
85    pub activating_units: u64,
86    /// Number of units in the "active" state (currently started and running)
87    pub active_units: u64,
88    /// Number of automount units (on-demand filesystem mount points)
89    pub automount_units: u64,
90    /// Number of device units (kernel devices exposed to systemd by udev)
91    pub device_units: u64,
92    /// Number of units in the "failed" state (exited with error, crashed, or timed out)
93    pub failed_units: u64,
94    /// Number of units in the "inactive" state (not currently running)
95    pub inactive_units: u64,
96    /// Number of pending jobs queued in the systemd job scheduler
97    pub jobs_queued: u64,
98    /// Number of units whose unit file has been successfully loaded into memory
99    pub loaded_units: u64,
100    /// Number of units whose unit file is masked (symlinked to /dev/null, cannot be started)
101    pub masked_units: u64,
102    /// Number of mount units (filesystem mount points managed by systemd)
103    pub mount_units: u64,
104    /// Number of units whose unit file could not be found on disk
105    pub not_found_units: u64,
106    /// Number of path units (file/directory watch triggers)
107    pub path_units: u64,
108    /// Number of scope units (externally created process groups, e.g. user sessions)
109    pub scope_units: u64,
110    /// Number of service units (daemon/process lifecycle management)
111    pub service_units: u64,
112    /// Number of slice units (resource management groups in the cgroup hierarchy)
113    pub slice_units: u64,
114    /// Number of socket units (IPC/network socket activation endpoints)
115    pub socket_units: u64,
116    /// Number of target units (synchronization points for grouping units)
117    pub target_units: u64,
118    /// Number of timer units (calendar/monotonic scheduled triggers)
119    pub timer_units: u64,
120    /// Number of timer units with Persistent=yes (triggers missed runs after downtime)
121    pub timer_persistent_units: u64,
122    /// Number of timer units with RemainAfterElapse=yes (stays loaded after firing)
123    pub timer_remain_after_elapse: u64,
124    /// Total number of units known to systemd (all types, all states)
125    pub total_units: u64,
126    /// Unit file statistics from the filesystem (e.g. generator output counts)
127    pub unit_files: UnitFilesStats,
128    /// Per-service detailed metrics keyed by unit name (e.g. "sshd.service")
129    pub service_stats: HashMap<String, ServiceStats>,
130    /// Per-timer detailed metrics keyed by unit name (e.g. "logrotate.timer")
131    pub timer_stats: HashMap<String, TimerStats>,
132    /// Per-unit active/load state tracking keyed by unit name
133    pub unit_states: HashMap<String, UnitStates>,
134    /// Inner timing breakdown for this collector. Zero-valued before the first
135    /// run completes or when the varlink path is taken.
136    pub collection_timings: UnitsCollectionTimings,
137}
138
139/// Per-service metrics from the org.freedesktop.systemd1.Service and Unit D-Bus interfaces.
140/// Ref: <https://www.freedesktop.org/software/systemd/man/org.freedesktop.systemd1.html>
141#[derive(
142    serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, FieldNamesAsArray, PartialEq,
143)]
144pub struct ServiceStats {
145    /// Realtime timestamp (usec since epoch) when the unit most recently entered the active state
146    pub active_enter_timestamp: u64,
147    /// Realtime timestamp (usec since epoch) when the unit most recently left the active state
148    pub active_exit_timestamp: u64,
149    /// Total CPU time consumed by this service's cgroup in nanoseconds
150    pub cpuusage_nsec: u64,
151    /// Realtime timestamp (usec since epoch) when the unit most recently left the inactive state
152    pub inactive_exit_timestamp: u64,
153    /// Total bytes read from block I/O by this service's cgroup
154    pub ioread_bytes: u64,
155    /// Total number of block I/O read operations by this service's cgroup
156    pub ioread_operations: u64,
157    /// Memory available to the service (MemoryAvailable from cgroup), in bytes
158    pub memory_available: u64,
159    /// Current memory usage of the service's cgroup in bytes
160    pub memory_current: u64,
161    /// Number of times systemd has restarted this service (automatic restarts)
162    pub nrestarts: u32,
163    /// Current number of processes in this service's cgroup
164    pub processes: u32,
165    /// Configured restart delay for this service in microseconds (RestartUSec)
166    pub restart_usec: u64,
167    /// Realtime timestamp (usec since epoch) of the most recent state change of any kind
168    pub state_change_timestamp: u64,
169    /// errno-style exit status code from the main process (0 = success)
170    pub status_errno: i32,
171    /// Current number of tasks (threads) in this service's cgroup
172    pub tasks_current: u64,
173    /// Timeout in microseconds for the cleanup of resources after the service exits
174    pub timeout_clean_usec: u64,
175    /// Watchdog timeout in microseconds; the service must ping within this interval or be killed
176    pub watchdog_usec: u64,
177}
178
179/// Per-unit state tracking combining active state, load state, and computed health.
180/// Ref: <https://www.freedesktop.org/software/systemd/man/org.freedesktop.systemd1.html>
181#[derive(
182    serde::Serialize, serde::Deserialize, Clone, Debug, Default, Eq, FieldNamesAsArray, PartialEq,
183)]
184pub struct UnitStates {
185    /// Current active state of the unit (active, inactive, failed, activating, deactivating, reloading)
186    pub active_state: SystemdUnitActiveState,
187    /// Current load state of the unit (loaded, error, masked, not_found)
188    pub load_state: SystemdUnitLoadState,
189    /// Computed health flag: true when a loaded unit is not active, or when load state is error/not_found.
190    /// Masked units are never marked unhealthy since masking is an intentional admin action.
191    pub unhealthy: bool,
192    /// Microseconds elapsed since the unit's most recent state change.
193    /// None when time-in-state tracking is disabled in config (expensive D-Bus lookup per unit).
194    pub time_in_state_usecs: Option<u64>,
195}
196
197// Declare state types
198// Reference: https://www.freedesktop.org/software/systemd/man/org.freedesktop.systemd1.html
199// SubState can be unit-type-specific so can't enum
200
201#[derive(Debug)]
202pub struct ListedUnit {
203    pub name: String,                      // The primary unit name
204    pub description: String,               // The human readable description
205    pub load_state: String, // The load state (i.e. whether the unit file has been loaded successfully)
206    pub active_state: String, // The active state (i.e. whether the unit is currently started or not)
207    pub sub_state: String,    // The sub state (i.e. unit type more specific state)
208    pub follow_unit: String, // A unit that is being followed in its state by this unit, if there is any, otherwise the empty string
209    pub unit_object_path: OwnedObjectPath, // The unit object path
210    pub job_id: u32, // If there is a job queued for the job unit, the numeric job id, 0 otherwise
211    pub job_type: String, // The job type as string
212    pub job_object_path: OwnedObjectPath, // The job object path
213}
214impl
215    From<(
216        String,
217        String,
218        String,
219        String,
220        String,
221        String,
222        OwnedObjectPath,
223        u32,
224        String,
225        OwnedObjectPath,
226    )> for ListedUnit
227{
228    fn from(
229        tuple: (
230            String,
231            String,
232            String,
233            String,
234            String,
235            String,
236            OwnedObjectPath,
237            u32,
238            String,
239            OwnedObjectPath,
240        ),
241    ) -> Self {
242        ListedUnit {
243            name: tuple.0,
244            description: tuple.1,
245            load_state: tuple.2,
246            active_state: tuple.3,
247            sub_state: tuple.4,
248            follow_unit: tuple.5,
249            unit_object_path: tuple.6,
250            job_id: tuple.7,
251            job_type: tuple.8,
252            job_object_path: tuple.9,
253        }
254    }
255}
256
257pub const SERVICE_FIELD_NAMES: &[&str] = &ServiceStats::FIELD_NAMES_AS_ARRAY;
258pub const UNIT_FIELD_NAMES: &[&str] = &SystemdUnitStats::FIELD_NAMES_AS_ARRAY;
259pub const UNIT_STATES_FIELD_NAMES: &[&str] = &UnitStates::FIELD_NAMES_AS_ARRAY;
260
261/// Pull out selected systemd service statistics
262async fn parse_service(
263    connection: &zbus::Connection,
264    name: &str,
265    object_path: &OwnedObjectPath,
266) -> Result<ServiceStats, MonitordUnitsError> {
267    debug!("Parsing service {} stats", name);
268
269    let sp = crate::dbus::zbus_service::ServiceProxy::builder(connection)
270        .cache_properties(zbus::proxy::CacheProperties::No)
271        .path(object_path.clone())?
272        .build()
273        .await?;
274    let up = crate::dbus::zbus_unit::UnitProxy::builder(connection)
275        .cache_properties(zbus::proxy::CacheProperties::No)
276        .path(object_path.clone())?
277        .build()
278        .await?;
279
280    // Use tokio::join! without tokio::spawn to avoid per-task allocation overhead.
281    // These all share the same D-Bus connection so spawn adds no parallelism benefit.
282    let (
283        active_enter_timestamp,
284        active_exit_timestamp,
285        cpuusage_nsec,
286        inactive_exit_timestamp,
287        ioread_bytes,
288        ioread_operations,
289        memory_current,
290        memory_available,
291        nrestarts,
292        processes,
293        restart_usec,
294        state_change_timestamp,
295        status_errno,
296        tasks_current,
297        timeout_clean_usec,
298        watchdog_usec,
299    ) = tokio::join!(
300        up.active_enter_timestamp(),
301        up.active_exit_timestamp(),
302        sp.cpuusage_nsec(),
303        up.inactive_exit_timestamp(),
304        sp.ioread_bytes(),
305        sp.ioread_operations(),
306        sp.memory_current(),
307        sp.memory_available(),
308        sp.nrestarts(),
309        sp.get_processes(),
310        sp.restart_usec(),
311        up.state_change_timestamp(),
312        sp.status_errno(),
313        sp.tasks_current(),
314        sp.timeout_clean_usec(),
315        sp.watchdog_usec(),
316    );
317
318    Ok(ServiceStats {
319        active_enter_timestamp: active_enter_timestamp?,
320        active_exit_timestamp: active_exit_timestamp?,
321        cpuusage_nsec: cpuusage_nsec?,
322        inactive_exit_timestamp: inactive_exit_timestamp?,
323        ioread_bytes: ioread_bytes?,
324        ioread_operations: ioread_operations?,
325        memory_current: memory_current?,
326        memory_available: memory_available?,
327        nrestarts: nrestarts?,
328        processes: processes?.len().try_into()?,
329        restart_usec: restart_usec?,
330        state_change_timestamp: state_change_timestamp?,
331        status_errno: status_errno?,
332        tasks_current: tasks_current?,
333        timeout_clean_usec: timeout_clean_usec?,
334        watchdog_usec: watchdog_usec?,
335    })
336}
337
338async fn get_time_in_state(
339    connection: Option<&zbus::Connection>,
340    unit: &ListedUnit,
341) -> Result<Option<u64>, MonitordUnitsError> {
342    match connection {
343        Some(c) => {
344            let up = crate::dbus::zbus_unit::UnitProxy::builder(c)
345                .cache_properties(zbus::proxy::CacheProperties::No)
346                .path(ObjectPath::from(unit.unit_object_path.clone()))?
347                .build()
348                .await?;
349            let now: u64 = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() * 1_000_000;
350            let state_change_timestamp = match up.state_change_timestamp().await {
351                Ok(sct) => sct,
352                Err(err) => {
353                    error!(
354                        "Unable to get state_change_timestamp for {} - Setting to 0: {:?}",
355                        &unit.name, err,
356                    );
357                    0
358                }
359            };
360            Ok(Some(now - state_change_timestamp))
361        }
362        None => {
363            error!("No zbus connection passed, but time_in_state_usecs enabled");
364            Ok(None)
365        }
366    }
367}
368
369/// Parse state of a unit into our unit_states hash.
370///
371/// Returns true when an actual time-in-state D-Bus fetch was performed,
372/// so callers can keep `state_dbus_fetches` honest. Allowlist/blocklist
373/// short-circuits and `state_stats_time_in_state = false` both return false.
374pub async fn parse_state(
375    stats: &mut SystemdUnitStats,
376    unit: &ListedUnit,
377    config: &crate::config::UnitsConfig,
378    connection: Option<&zbus::Connection>,
379) -> Result<bool, MonitordUnitsError> {
380    if config.state_stats_blocklist.contains(&unit.name) {
381        debug!("Skipping state stats for {} due to blocklist", &unit.name);
382        return Ok(false);
383    }
384    if !config.state_stats_allowlist.is_empty()
385        && !config.state_stats_allowlist.contains(&unit.name)
386    {
387        return Ok(false);
388    }
389    let active_state = SystemdUnitActiveState::from_str(&unit.active_state)
390        .unwrap_or(SystemdUnitActiveState::unknown);
391    let load_state = SystemdUnitLoadState::from_str(&unit.load_state.replace('-', "_"))
392        .unwrap_or(SystemdUnitLoadState::unknown);
393
394    // Get the state_change_timestamp to determine time in usecs we've been in current state
395    let mut time_in_state_usecs: Option<u64> = None;
396    let mut did_dbus_fetch = false;
397    if config.state_stats_time_in_state {
398        time_in_state_usecs = get_time_in_state(connection, unit).await?;
399        // get_time_in_state only issues a D-Bus call when connection is Some;
400        // the None path logs an error and returns Ok(None) without calling out.
401        did_dbus_fetch = connection.is_some();
402    }
403
404    stats.unit_states.insert(
405        unit.name.clone(),
406        UnitStates {
407            active_state,
408            load_state,
409            unhealthy: is_unit_unhealthy(active_state, load_state),
410            time_in_state_usecs,
411        },
412    );
413    Ok(did_dbus_fetch)
414}
415
416/// Parse a unit and add to overall counts of state, type etc.
417fn parse_unit(stats: &mut SystemdUnitStats, unit: &ListedUnit) {
418    // Count unit type
419    match unit.name.rsplit('.').next() {
420        Some("automount") => stats.automount_units += 1,
421        Some("device") => stats.device_units += 1,
422        Some("mount") => stats.mount_units += 1,
423        Some("path") => stats.path_units += 1,
424        Some("scope") => stats.scope_units += 1,
425        Some("service") => stats.service_units += 1,
426        Some("slice") => stats.slice_units += 1,
427        Some("socket") => stats.socket_units += 1,
428        Some("target") => stats.target_units += 1,
429        Some("timer") => stats.timer_units += 1,
430        unknown => debug!("Found unhandled '{:?}' unit type", unknown),
431    };
432    // Count load state
433    match unit.load_state.as_str() {
434        "loaded" => stats.loaded_units += 1,
435        "masked" => stats.masked_units += 1,
436        "not-found" => stats.not_found_units += 1,
437        _ => debug!("{} is not loaded. It's {}", unit.name, unit.load_state),
438    };
439    // Count unit status
440    match unit.active_state.as_str() {
441        "activating" => stats.activating_units += 1,
442        "active" => stats.active_units += 1,
443        "failed" => stats.failed_units += 1,
444        "inactive" => stats.inactive_units += 1,
445        unknown => debug!("Found unhandled '{}' unit state", unknown),
446    };
447    // Count jobs queued
448    if unit.job_id != 0 {
449        stats.jobs_queued += 1;
450    }
451}
452
453const TRANSIENT_DIR: &str = "/run/systemd/transient";
454
455async fn count_unit_files_by_type(path: &str) -> HashMap<String, u64> {
456    let mut dir = match tokio::fs::read_dir(path).await {
457        Ok(d) => d,
458        Err(err) => {
459            debug!("Unable to read {}: {:?}", path, err);
460            return HashMap::new();
461        }
462    };
463    let mut counts = HashMap::new();
464    loop {
465        match dir.next_entry().await {
466            Ok(Some(entry)) => {
467                let file_type = match entry.file_type().await {
468                    Ok(ft) => ft,
469                    Err(_) => continue,
470                };
471                if !file_type.is_file() {
472                    continue;
473                }
474                let name = entry.file_name();
475                let unit_type = name
476                    .to_str()
477                    .and_then(|n| n.rsplit('.').next())
478                    .unwrap_or("unknown");
479                *counts.entry(unit_type.to_string()).or_insert(0) += 1;
480            }
481            Ok(None) => break,
482            Err(err) => {
483                warn!("Error reading entry in {}: {:?}", path, err);
484                continue;
485            }
486        }
487    }
488    counts
489}
490
491fn merge_counts(target: &mut HashMap<String, u64>, source: HashMap<String, u64>) {
492    for (unit_type, count) in source {
493        *target.entry(unit_type).or_insert(0) += count;
494    }
495}
496
497/// Enumerate the per-user systemd transient directories under `{fs_root}/run/user`.
498async fn enumerate_user_transient_dirs(fs_root: &str) -> Vec<String> {
499    let user_dir = format!("{fs_root}/run/user");
500    match tokio::fs::read_dir(&user_dir).await {
501        Ok(mut entries) => {
502            let mut dirs = Vec::new();
503            loop {
504                match entries.next_entry().await {
505                    Ok(Some(entry)) => {
506                        dirs.push(format!("{}/systemd/transient", entry.path().display()));
507                    }
508                    Ok(None) => break,
509                    Err(err) => {
510                        warn!("Error reading entry in {}: {:?}", user_dir, err);
511                        continue;
512                    }
513                }
514            }
515            dirs
516        }
517        Err(err) => {
518            debug!("Unable to read {}: {:?}", user_dir, err);
519            Vec::new()
520        }
521    }
522}
523
524/// Collect unit file statistics from the filesystem.
525/// `fs_root` is prepended to all paths — empty string for the host,
526/// `/proc/<pid>/root` for containers.
527///
528/// All directory reads are issued in parallel: the three generator directories,
529/// the root transient directory, and user-dir enumeration run concurrently in a
530/// first batch; per-user transient reads run concurrently in a second batch.
531pub async fn collect_unit_files_stats(fs_root: &str) -> UnitFilesStats {
532    // Pre-bind formatted paths to extend their lifetime across the join.
533    let gen_path = format!("{fs_root}/run/systemd/generator");
534    let gen_early_path = format!("{fs_root}/run/systemd/generator.early");
535    let gen_late_path = format!("{fs_root}/run/systemd/generator.late");
536    let transient_path = format!("{fs_root}{TRANSIENT_DIR}");
537
538    // First batch: fixed paths + user dir enumeration all in parallel.
539    let (gen, gen_early, gen_late, root_transient, user_dirs) = tokio::join!(
540        count_unit_files_by_type(&gen_path),
541        count_unit_files_by_type(&gen_early_path),
542        count_unit_files_by_type(&gen_late_path),
543        count_unit_files_by_type(&transient_path),
544        enumerate_user_transient_dirs(fs_root),
545    );
546
547    let mut root_generated = HashMap::new();
548    merge_counts(&mut root_generated, gen);
549    merge_counts(&mut root_generated, gen_early);
550    merge_counts(&mut root_generated, gen_late);
551
552    // Second batch: read every user transient directory in parallel.
553    let user_transient_counts =
554        futures_util::future::join_all(user_dirs.iter().map(|d| count_unit_files_by_type(d))).await;
555
556    let mut user_transient = HashMap::new();
557    for counts in user_transient_counts {
558        merge_counts(&mut user_transient, counts);
559    }
560
561    UnitFilesStats {
562        root: UnitFilesScope {
563            generated: root_generated,
564            transient: root_transient,
565        },
566        user: UnitFilesScope {
567            generated: HashMap::new(),
568            transient: user_transient,
569        },
570    }
571}
572
573/// Pull all units from dbus and count how system is setup and behaving
574pub async fn parse_unit_state(
575    config: &crate::config::Config,
576    connection: &zbus::Connection,
577    fs_root: &str,
578) -> Result<SystemdUnitStats, MonitordUnitsError> {
579    if !config.units.state_stats_allowlist.is_empty() {
580        debug!(
581            "Using unit state allowlist: {:?}",
582            config.units.state_stats_allowlist
583        );
584    }
585
586    if !config.units.state_stats_blocklist.is_empty() {
587        debug!(
588            "Using unit state blocklist: {:?}",
589            config.units.state_stats_blocklist,
590        );
591    }
592
593    let mut stats = SystemdUnitStats::default();
594
595    let p = crate::dbus::zbus_systemd::ManagerProxy::builder(connection)
596        .cache_properties(zbus::proxy::CacheProperties::No)
597        .build()
598        .await?;
599
600    // Run filesystem collection and D-Bus list_units in parallel, timing each independently.
601    let (unit_files_result, units_result) = tokio::join!(
602        async {
603            let start = Instant::now();
604            let files = if config.units.unit_files {
605                collect_unit_files_stats(fs_root).await
606            } else {
607                UnitFilesStats::default()
608            };
609            (files, start.elapsed().as_secs_f64() * 1000.0)
610        },
611        async {
612            let start = Instant::now();
613            let units = p.list_units().await;
614            (units, start.elapsed().as_secs_f64() * 1000.0)
615        },
616    );
617    let (unit_files, unit_files_ms) = unit_files_result;
618    let (units_result, list_units_ms) = units_result;
619    stats.collection_timings.unit_files_ms = unit_files_ms;
620    stats.collection_timings.list_units_ms = list_units_ms;
621    stats.unit_files = unit_files;
622
623    let units = units_result?;
624    stats.total_units = units.len() as u64;
625
626    let per_unit_loop_start = Instant::now();
627    let mut state_dbus_fetches: u64 = 0;
628    let mut service_dbus_fetches: u64 = 0;
629    let mut timer_dbus_fetches: u64 = 0;
630
631    for unit_raw in units {
632        let unit: ListedUnit = unit_raw.into();
633        // Collect unit types + states counts
634        parse_unit(&mut stats, &unit);
635
636        // Collect per unit state stats - ActiveState + LoadState
637        // Not collecting SubState (yet)
638        if config.units.state_stats {
639            let did_dbus_fetch =
640                parse_state(&mut stats, &unit, &config.units, Some(connection)).await?;
641            if did_dbus_fetch {
642                state_dbus_fetches += 1;
643            }
644        }
645
646        // Collect service stats
647        if config.services.contains(&unit.name) {
648            debug!("Collecting service stats for {:?}", &unit);
649            match parse_service(connection, &unit.name, &unit.unit_object_path).await {
650                Ok(service_stats) => {
651                    stats.service_stats.insert(unit.name.clone(), service_stats);
652                    service_dbus_fetches += 1;
653                }
654                Err(err) => error!(
655                    "Unable to get service stats for {} {}: {:#?}",
656                    &unit.name, &unit.unit_object_path, err
657                ),
658            }
659        }
660
661        // Collect timer stats
662        if config.timers.enabled && unit.name.contains(".timer") {
663            if config.timers.blocklist.contains(&unit.name) {
664                debug!("Skipping timer stats for {} due to blocklist", &unit.name);
665                continue;
666            }
667            if !config.timers.allowlist.is_empty() && !config.timers.allowlist.contains(&unit.name)
668            {
669                continue;
670            }
671            let timer_stats: Option<TimerStats> =
672                match crate::timer::collect_timer_stats(connection, &mut stats, &unit).await {
673                    Ok(ts) => {
674                        timer_dbus_fetches += 1;
675                        Some(ts)
676                    }
677                    Err(err) => {
678                        error!("Failed to get {} stats: {:#?}", &unit.name, err);
679                        None
680                    }
681                };
682            if let Some(ts) = timer_stats {
683                stats.timer_stats.insert(unit.name.clone(), ts);
684            }
685        }
686    }
687    let per_unit_loop_elapsed = per_unit_loop_start.elapsed();
688    stats.collection_timings.per_unit_loop_ms = per_unit_loop_elapsed.as_secs_f64() * 1000.0;
689    stats.collection_timings.state_dbus_fetches = state_dbus_fetches;
690    stats.collection_timings.service_dbus_fetches = service_dbus_fetches;
691    stats.collection_timings.timer_dbus_fetches = timer_dbus_fetches;
692
693    debug!("unit stats: {:?}", stats);
694    Ok(stats)
695}
696
697/// Async wrapper that can update unit stats when passed a locked struct.
698/// `fs_root` is prepended to filesystem paths for unit file stats —
699/// empty string for the host, `/proc/<pid>/root` for containers.
700pub async fn update_unit_stats(
701    config: Arc<crate::config::Config>,
702    connection: zbus::Connection,
703    locked_machine_stats: Arc<RwLock<MachineStats>>,
704    fs_root: String,
705) -> anyhow::Result<()> {
706    let mut machine_stats = locked_machine_stats.write().await;
707    match parse_unit_state(&config, &connection, &fs_root).await {
708        Ok(units_stats) => machine_stats.units = units_stats,
709        Err(err) => error!("units stats failed: {:?}", err),
710    }
711    Ok(())
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717    use std::collections::HashSet;
718    use strum::IntoEnumIterator;
719
720    fn get_unit_file() -> ListedUnit {
721        ListedUnit {
722            name: String::from("apport-autoreport.timer"),
723            description: String::from(
724                "Process error reports when automatic reporting is enabled (timer based)",
725            ),
726            load_state: String::from("loaded"),
727            active_state: String::from("inactive"),
728            sub_state: String::from("dead"),
729            follow_unit: String::from(""),
730            unit_object_path: ObjectPath::try_from(
731                "/org/freedesktop/systemd1/unit/apport_2dautoreport_2etimer",
732            )
733            .expect("Unable to make an object path")
734            .into(),
735            job_id: 0,
736            job_type: String::from(""),
737            job_object_path: ObjectPath::try_from("/").unwrap().into(),
738        }
739    }
740
741    #[tokio::test]
742    async fn test_state_parse() -> Result<(), MonitordUnitsError> {
743        let test_unit_name = String::from("apport-autoreport.timer");
744        let expected_stats = SystemdUnitStats {
745            activating_units: 0,
746            active_units: 0,
747            automount_units: 0,
748            device_units: 0,
749            failed_units: 0,
750            inactive_units: 0,
751            jobs_queued: 0,
752            loaded_units: 0,
753            masked_units: 0,
754            mount_units: 0,
755            not_found_units: 0,
756            path_units: 0,
757            scope_units: 0,
758            service_units: 0,
759            slice_units: 0,
760            socket_units: 0,
761            target_units: 0,
762            timer_units: 0,
763            timer_persistent_units: 0,
764            timer_remain_after_elapse: 0,
765            total_units: 0,
766            unit_files: UnitFilesStats::default(),
767            service_stats: HashMap::new(),
768            timer_stats: HashMap::new(),
769            unit_states: HashMap::from([(
770                test_unit_name.clone(),
771                UnitStates {
772                    active_state: SystemdUnitActiveState::inactive,
773                    load_state: SystemdUnitLoadState::loaded,
774                    unhealthy: true,
775                    time_in_state_usecs: None,
776                },
777            )]),
778            collection_timings: UnitsCollectionTimings::default(),
779        };
780        let mut stats = SystemdUnitStats::default();
781        let systemd_unit = get_unit_file();
782        let mut config = crate::config::UnitsConfig::default();
783
784        // Test no allow list or blocklist; with connection: None, parse_state
785        // takes the no-op path inside get_time_in_state and returns false.
786        let did_fetch = parse_state(&mut stats, &systemd_unit, &config, None).await?;
787        assert_eq!(expected_stats, stats);
788        assert!(!did_fetch);
789
790        // Create an allow list
791        config.state_stats_allowlist = HashSet::from([test_unit_name.clone()]);
792
793        // test no blocklist and only allow list - Should equal the same as no lists above
794        let mut allowlist_stats = SystemdUnitStats::default();
795        let did_fetch = parse_state(&mut allowlist_stats, &systemd_unit, &config, None).await?;
796        assert_eq!(expected_stats, allowlist_stats);
797        assert!(!did_fetch);
798
799        // Now add a blocklist
800        config.state_stats_blocklist = HashSet::from([test_unit_name]);
801
802        // test blocklist with allow list (show it's preferred)
803        let mut blocklist_stats = SystemdUnitStats::default();
804        let expected_blocklist_stats = SystemdUnitStats::default();
805        let did_fetch = parse_state(&mut blocklist_stats, &systemd_unit, &config, None).await?;
806        assert_eq!(expected_blocklist_stats, blocklist_stats);
807        // Blocklist short-circuit must NOT count as a D-Bus fetch.
808        assert!(!did_fetch);
809        Ok(())
810    }
811
812    #[test]
813    fn test_unit_parse() {
814        let expected_stats = SystemdUnitStats {
815            activating_units: 0,
816            active_units: 0,
817            automount_units: 0,
818            device_units: 0,
819            failed_units: 0,
820            inactive_units: 1,
821            jobs_queued: 0,
822            loaded_units: 1,
823            masked_units: 0,
824            mount_units: 0,
825            not_found_units: 0,
826            path_units: 0,
827            scope_units: 0,
828            service_units: 0,
829            slice_units: 0,
830            socket_units: 0,
831            target_units: 0,
832            timer_units: 1,
833            timer_persistent_units: 0,
834            timer_remain_after_elapse: 0,
835            total_units: 0,
836            unit_files: UnitFilesStats::default(),
837            service_stats: HashMap::new(),
838            timer_stats: HashMap::new(),
839            unit_states: HashMap::new(),
840            collection_timings: UnitsCollectionTimings::default(),
841        };
842        let mut stats = SystemdUnitStats::default();
843        let systemd_unit = get_unit_file();
844        parse_unit(&mut stats, &systemd_unit);
845        assert_eq!(expected_stats, stats);
846    }
847
848    #[test]
849    fn test_unit_parse_activating() {
850        let mut activating_unit = get_unit_file();
851        activating_unit.active_state = String::from("activating");
852        let mut stats = SystemdUnitStats::default();
853        parse_unit(&mut stats, &activating_unit);
854        assert_eq!(stats.activating_units, 1);
855        assert_eq!(stats.active_units, 0);
856        assert_eq!(stats.inactive_units, 0);
857    }
858
859    #[test]
860    fn test_iterators() {
861        assert!(SystemdUnitActiveState::iter().collect::<Vec<_>>().len() > 0);
862        assert!(SystemdUnitLoadState::iter().collect::<Vec<_>>().len() > 0);
863    }
864
865    #[tokio::test]
866    async fn test_count_unit_files_by_type() {
867        let dir = tempfile::tempdir().expect("Unable to create temp dir");
868        let path = dir.path();
869
870        std::fs::write(path.join("sshd.service"), "").unwrap();
871        std::fs::write(path.join("nginx.service"), "").unwrap();
872        std::fs::write(path.join("boot.mount"), "").unwrap();
873        std::fs::write(path.join("swap.swap"), "").unwrap();
874        std::fs::create_dir(path.join("multi-user.target.wants")).unwrap();
875
876        let counts = count_unit_files_by_type(path.to_str().unwrap()).await;
877        assert_eq!(counts.get("service"), Some(&2));
878        assert_eq!(counts.get("mount"), Some(&1));
879        assert_eq!(counts.get("swap"), Some(&1));
880        assert_eq!(counts.get("wants"), None);
881        assert_eq!(counts.len(), 3);
882    }
883
884    #[tokio::test]
885    async fn test_count_unit_files_by_type_nonexistent_dir() {
886        let counts = count_unit_files_by_type("/nonexistent/path").await;
887        assert!(counts.is_empty());
888    }
889
890    #[tokio::test]
891    async fn test_collect_unit_files_stats_with_fs_root() {
892        let root = tempfile::tempdir().expect("Unable to create temp dir");
893        let root_path = root.path();
894
895        let gen_dir = root_path.join("run/systemd/generator");
896        std::fs::create_dir_all(&gen_dir).unwrap();
897        std::fs::write(gen_dir.join("boot.mount"), "").unwrap();
898        std::fs::write(gen_dir.join("swap.swap"), "").unwrap();
899
900        let transient_dir = root_path.join("run/systemd/transient");
901        std::fs::create_dir_all(&transient_dir).unwrap();
902        std::fs::write(transient_dir.join("run-thing.service"), "").unwrap();
903
904        let user_transient = root_path.join("run/user/1000/systemd/transient");
905        std::fs::create_dir_all(&user_transient).unwrap();
906        std::fs::write(user_transient.join("app-code.scope"), "").unwrap();
907        std::fs::write(user_transient.join("app-term.scope"), "").unwrap();
908
909        let stats = collect_unit_files_stats(root_path.to_str().unwrap()).await;
910        assert_eq!(stats.root.generated.get("mount"), Some(&1));
911        assert_eq!(stats.root.generated.get("swap"), Some(&1));
912        assert_eq!(stats.root.transient.get("service"), Some(&1));
913        assert_eq!(stats.user.transient.get("scope"), Some(&2));
914        assert!(stats.user.generated.is_empty());
915    }
916}