Skip to main content

monitord/
varlink_unit.rs

1//! # varlink_unit module
2//!
3//! Per-unit detail via the `io.systemd.Unit` varlink API on PID 1's socket.
4//! `Unit.List` itself exists from systemd v258, but the per-type context
5//! sections read here (`varlink-service.c`, `varlink-timer.c`) first appear in
6//! **v261**, which is the real minimum. This is what the varlink path uses
7//! instead of the per-unit D-Bus property fetches in `units.rs`.
8//!
9//! `Unit.List` can either stream every unit or answer for one named unit.
10//! monitord asks per unit: measured on a test host with 305 units, streaming
11//! all of them costs ~92ms and 1.3MB, against ~0.47ms for a single filtered
12//! call on a warm connection. Break-even is around 198 units, and monitord
13//! wants a handful — the `[services]` list, the tracked timers, and whichever
14//! units need a oneshot type check. The collectors that do want every unit
15//! (`boot_blame`, `verify`) are served from the metrics stream instead.
16//!
17//! systemd omits fields that sit at their default rather than sending a zero,
18//! so every mapping below has to restate the default the D-Bus property would
19//! have returned. Those defaults are asserted in the tests.
20
21use std::collections::HashMap;
22use std::collections::HashSet;
23
24use tracing::debug;
25
26use crate::timer::TimerStats;
27use crate::units::ServiceStats;
28use crate::varlink::unit::{ListOutput, Unit};
29
30pub use crate::varlink::manager::MANAGER_SOCKET_PATH;
31
32/// systemd reports "unset" counters as `u64::MAX` over D-Bus (`[not set]` /
33/// `infinity` in `systemctl show`) and by omitting the field over varlink.
34const UNSET: u64 = u64::MAX;
35
36/// A connection to PID 1's unit API that remembers what it has already asked.
37///
38/// Built once per collection cycle. The cache matters because the same unit can
39/// be wanted by more than one caller in a cycle — a `.service` can be both in
40/// `[services]` and the target of a tracked timer — and a miss costs a round
41/// trip to PID 1, which serves varlink requests one at a time.
42pub struct UnitLookup {
43    connection: zlink::unix::Connection,
44    cache: HashMap<String, Option<ListOutput>>,
45}
46
47impl UnitLookup {
48    pub async fn connect(socket_path: &str) -> anyhow::Result<Self> {
49        Ok(Self {
50            connection: zlink::unix::connect(socket_path).await?,
51            cache: HashMap::new(),
52        })
53    }
54
55    /// Look a unit up, returning `Ok(None)` if systemd does not know it.
56    ///
57    /// A transport or protocol failure is an error rather than `None`, so the
58    /// caller can fall back to D-Bus for the whole phase. Collapsing the two
59    /// would leave service stats silently empty on a broken socket while
60    /// reporting success.
61    ///
62    /// A unit that is genuinely absent is cached as absent: the answer will not
63    /// change within a cycle, and re-asking would cost another round trip.
64    pub async fn get(&mut self, name: &str) -> anyhow::Result<Option<&ListOutput>> {
65        if !self.cache.contains_key(name) {
66            let fetched = match self.connection.list(Some(name)).await? {
67                Ok(output) => Some(output),
68                Err(err) => {
69                    debug!("No unit {} over varlink: {}", name, err);
70                    None
71                }
72            };
73            self.cache.insert(name.to_string(), fetched);
74        }
75        Ok(self.cache.get(name).and_then(|entry| entry.as_ref()))
76    }
77
78    /// Number of units fetched from PID 1 so far, for collection timings.
79    pub fn fetches(&self) -> u64 {
80        self.cache.len() as u64
81    }
82}
83
84/// Whether a unit is a oneshot service.
85///
86/// The varlink equivalent of `units::is_oneshot_service_by_name`, which the
87/// varlink path previously had to reach back to D-Bus for.
88pub fn is_oneshot(output: &ListOutput) -> bool {
89    output
90        .context
91        .as_ref()
92        .and_then(|context| context.service.as_ref())
93        .and_then(|service| service.r#type.as_deref())
94        == Some("oneshot")
95}
96
97/// Map a `Unit.List` reply onto `ServiceStats`.
98///
99/// `processes` comes from the unit's cgroup rather than the reply — systemd
100/// exposes no per-cgroup process count over varlink (tracked in #37).
101pub fn map_service_stats(output: &ListOutput, processes: u32) -> ServiceStats {
102    let runtime = output.runtime.as_ref();
103    let cgroup = runtime.and_then(|runtime| runtime.cgroup.as_ref());
104    let service_runtime = runtime.and_then(|runtime| runtime.service.as_ref());
105    let service_context = output
106        .context
107        .as_ref()
108        .and_then(|context| context.service.as_ref());
109
110    let realtime = |pick: fn(
111        &crate::varlink::unit::UnitRuntime,
112    ) -> Option<crate::varlink::unit::Timestamp>| {
113        runtime
114            .and_then(pick)
115            .and_then(|timestamp| timestamp.realtime)
116            .unwrap_or(0)
117    };
118
119    ServiceStats {
120        active_enter_timestamp: realtime(|runtime| runtime.active_enter_timestamp),
121        active_exit_timestamp: realtime(|runtime| runtime.active_exit_timestamp),
122        // Every cgroup counter defaults to UNSET, not 0: systemd omits these
123        // when the matching accounting option is off, and the D-Bus properties
124        // then read `[not set]` (u64::MAX). Answering 0 would report a service
125        // as using no memory rather than as unmeasured.
126        cpuusage_nsec: cgroup
127            .and_then(|cgroup| cgroup.cpu_usage_nsec)
128            .unwrap_or(UNSET),
129        inactive_exit_timestamp: realtime(|runtime| runtime.inactive_exit_timestamp),
130        ioread_bytes: cgroup
131            .and_then(|cgroup| cgroup.io_read_bytes)
132            .unwrap_or(UNSET),
133        ioread_operations: cgroup
134            .and_then(|cgroup| cgroup.io_read_operations)
135            .unwrap_or(UNSET),
136        memory_available: cgroup
137            .and_then(|cgroup| cgroup.memory_available)
138            .unwrap_or(UNSET),
139        memory_current: cgroup
140            .and_then(|cgroup| cgroup.memory_current)
141            .unwrap_or(UNSET),
142        nrestarts: service_runtime
143            .and_then(|service| service.n_restarts)
144            .unwrap_or(0),
145        processes,
146        restart_usec: service_context
147            .and_then(|service| service.restart_usec)
148            .unwrap_or(0),
149        state_change_timestamp: realtime(|runtime| runtime.state_change_timestamp),
150        status_errno: service_runtime
151            .and_then(|service| service.status_errno)
152            .unwrap_or(0),
153        tasks_current: cgroup
154            .and_then(|cgroup| cgroup.tasks_current)
155            .unwrap_or(UNSET),
156        // Under context.Exec, not context.Service as the D-Bus property name
157        // suggests. Defaults to infinity, which D-Bus reports as u64::MAX.
158        timeout_clean_usec: output
159            .context
160            .as_ref()
161            .and_then(|context| context.exec.as_ref())
162            .and_then(|exec| exec.timeout_clean_usec)
163            .unwrap_or(UNSET),
164        watchdog_usec: service_context
165            .and_then(|service| service.watchdog_usec)
166            .unwrap_or(0),
167    }
168}
169
170/// The unit a timer triggers, e.g. "logrotate.service" for "logrotate.timer".
171pub fn timer_triggered_unit(output: &ListOutput) -> Option<&str> {
172    output
173        .context
174        .as_ref()
175        .and_then(|context| context.timer.as_ref())
176        .and_then(|timer| timer.unit.as_deref())
177}
178
179/// Map a `Unit.List` reply onto `TimerStats`.
180///
181/// `triggered` is the reply for the unit this timer starts, looked up
182/// separately because its state change timestamps are properties of that unit
183/// rather than of the timer. `None` when it could not be resolved, which
184/// leaves those two fields at 0 — the same as the D-Bus path, which reports 0
185/// when the timer names no unit.
186pub fn map_timer_stats(output: &ListOutput, triggered: Option<&ListOutput>) -> TimerStats {
187    let context = output
188        .context
189        .as_ref()
190        .and_then(|context| context.timer.as_ref());
191    let runtime = output
192        .runtime
193        .as_ref()
194        .and_then(|runtime| runtime.timer.as_ref());
195    let last_trigger = runtime.and_then(|runtime| runtime.last_trigger_usec);
196    let service_state_change = triggered
197        .and_then(|triggered| triggered.runtime.as_ref())
198        .and_then(|runtime| runtime.state_change_timestamp);
199
200    TimerStats {
201        accuracy_usec: context.and_then(|timer| timer.accuracy_usec).unwrap_or(0),
202        fixed_random_delay: context
203            .and_then(|timer| timer.fixed_random_delay)
204            .unwrap_or(false),
205        last_trigger_usec: last_trigger
206            .and_then(|timestamp| timestamp.realtime)
207            .unwrap_or(0),
208        last_trigger_usec_monotonic: last_trigger
209            .and_then(|timestamp| timestamp.monotonic)
210            .unwrap_or(0),
211        next_elapse_usec_monotonic: runtime
212            .and_then(|timer| timer.next_elapse_usec_monotonic)
213            .unwrap_or(0),
214        next_elapse_usec_realtime: runtime
215            .and_then(|timer| timer.next_elapse_usec_realtime)
216            .unwrap_or(0),
217        persistent: context.and_then(|timer| timer.persistent).unwrap_or(false),
218        randomized_delay_usec: context
219            .and_then(|timer| timer.randomized_delay_usec)
220            .unwrap_or(0),
221        remain_after_elapse: context
222            .and_then(|timer| timer.remain_after_elapse)
223            .unwrap_or(false),
224        service_unit_last_state_change_usec: service_state_change
225            .and_then(|timestamp| timestamp.realtime)
226            .unwrap_or(0),
227        service_unit_last_state_change_usec_monotonic: service_state_change
228            .and_then(|timestamp| timestamp.monotonic)
229            .unwrap_or(0),
230    }
231}
232
233/// Count the processes in a unit's cgroup, including nested ones.
234///
235/// The D-Bus path counts what `GetProcesses` returns, and systemd walks the
236/// whole subtree there — a service that delegates its cgroup and puts workers
237/// in children would be undercounted by reading only its own `cgroup.procs`.
238/// The main PID is folded in the same way systemd does, since it can sit
239/// outside the cgroup. `fs_root` prefixes the cgroup mount for containers.
240pub async fn count_cgroup_processes(fs_root: &str, output: &ListOutput) -> u32 {
241    let runtime = output.runtime.as_ref();
242    let Some(cgroup_path) = runtime
243        .and_then(|runtime| runtime.cgroup.as_ref())
244        .and_then(|cgroup| cgroup.path.as_deref())
245    else {
246        return 0;
247    };
248
249    let mut pids: HashSet<u32> = HashSet::new();
250    let mut directories = vec![format!("{}/sys/fs/cgroup{}", fs_root, cgroup_path)];
251    while let Some(directory) = directories.pop() {
252        match tokio::fs::read_to_string(format!("{directory}/cgroup.procs")).await {
253            Ok(contents) => {
254                pids.extend(contents.lines().filter_map(|line| line.parse::<u32>().ok()))
255            }
256            Err(err) => debug!("Unable to read {}/cgroup.procs: {:?}", directory, err),
257        }
258        let Ok(mut entries) = tokio::fs::read_dir(&directory).await else {
259            continue;
260        };
261        while let Ok(Some(entry)) = entries.next_entry().await {
262            if entry.file_type().await.is_ok_and(|kind| kind.is_dir()) {
263                directories.push(entry.path().to_string_lossy().into_owned());
264            }
265        }
266    }
267
268    if let Some(main_pid) = runtime
269        .and_then(|runtime| runtime.service.as_ref())
270        .and_then(|service| service.main_pid.as_ref())
271        .and_then(|process| process.pid)
272    {
273        pids.insert(main_pid);
274    }
275
276    pids.len() as u32
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::varlink::unit::{
283        CGroupRuntime, ExecContext, ServiceContext, ServiceRuntime, TimerContext, TimerRuntime,
284        Timestamp, UnitContext, UnitRuntime,
285    };
286
287    fn output(context: Option<UnitContext>, runtime: Option<UnitRuntime>) -> ListOutput {
288        ListOutput { context, runtime }
289    }
290
291    fn service_context(r#type: &str) -> UnitContext {
292        UnitContext {
293            service: Some(ServiceContext {
294                r#type: Some(r#type.to_string()),
295                restart_usec: None,
296                watchdog_usec: None,
297            }),
298            exec: None,
299            timer: None,
300        }
301    }
302
303    #[test]
304    fn test_is_oneshot() {
305        assert!(is_oneshot(&output(Some(service_context("oneshot")), None)));
306        assert!(!is_oneshot(&output(
307            Some(service_context("notify-reload")),
308            None
309        )));
310        // A unit with no service context at all is not a service, let alone a
311        // oneshot one — the D-Bus path treats a failed lookup the same way.
312        assert!(!is_oneshot(&output(None, None)));
313    }
314
315    #[test]
316    fn test_map_service_stats_from_a_populated_reply() {
317        let stats = map_service_stats(
318            &output(
319                Some(UnitContext {
320                    service: Some(ServiceContext {
321                        r#type: Some("notify-reload".to_string()),
322                        restart_usec: Some(100_000),
323                        watchdog_usec: None,
324                    }),
325                    // TimeoutCleanUSec lives here, not under Service.
326                    exec: Some(ExecContext {
327                        timeout_clean_usec: Some(30_000_000),
328                    }),
329                    timer: None,
330                }),
331                Some(UnitRuntime {
332                    state_change_timestamp: Some(Timestamp {
333                        realtime: Some(1_789_701_442_296_989),
334                        monotonic: Some(3_774_344_633),
335                    }),
336                    active_enter_timestamp: Some(Timestamp {
337                        realtime: Some(1_789_701_442_296_989),
338                        monotonic: Some(3_774_344_633),
339                    }),
340                    inactive_exit_timestamp: Some(Timestamp {
341                        realtime: Some(1_789_701_442_287_084),
342                        monotonic: Some(3_774_334_729),
343                    }),
344                    active_exit_timestamp: Some(Timestamp {
345                        realtime: Some(1_789_701_442_280_000),
346                        monotonic: Some(3_774_327_645),
347                    }),
348                    cgroup: Some(CGroupRuntime {
349                        path: Some("/system.slice/dbus-broker.service".to_string()),
350                        cpu_usage_nsec: Some(86_690_000),
351                        memory_current: Some(3_457_024),
352                        memory_available: Some(7_619_899_392),
353                        tasks_current: Some(2),
354                        io_read_bytes: None,
355                        io_read_operations: None,
356                    }),
357                    service: Some(ServiceRuntime {
358                        main_pid: None,
359                        status_errno: Some(0),
360                        n_restarts: Some(0),
361                    }),
362                    timer: None,
363                }),
364            ),
365            2,
366        );
367
368        assert_eq!(stats.active_enter_timestamp, 1_789_701_442_296_989);
369        assert_eq!(stats.cpuusage_nsec, 86_690_000);
370        assert_eq!(stats.memory_current, 3_457_024);
371        assert_eq!(stats.tasks_current, 2);
372        assert_eq!(stats.processes, 2);
373        assert_eq!(stats.restart_usec, 100_000);
374        // Real, and emitted once a unit has actually left the active state.
375        assert_eq!(stats.active_exit_timestamp, 1_789_701_442_280_000);
376        // Read from context.Exec rather than context.Service.
377        assert_eq!(stats.timeout_clean_usec, 30_000_000);
378        // Omitted by systemd, so the D-Bus defaults have to be restated here:
379        // IO accounting off reads as u64::MAX, WatchdogUSec as 0.
380        assert_eq!(stats.ioread_bytes, u64::MAX);
381        assert_eq!(stats.ioread_operations, u64::MAX);
382        assert_eq!(stats.watchdog_usec, 0);
383    }
384
385    fn timer_output() -> ListOutput {
386        // Shaped from a real systemd-tmpfiles-clean.timer reply.
387        ListOutput {
388            context: Some(UnitContext {
389                service: None,
390                exec: None,
391                timer: Some(TimerContext {
392                    unit: Some("systemd-tmpfiles-clean.service".to_string()),
393                    accuracy_usec: Some(60_000_000),
394                    randomized_delay_usec: None,
395                    fixed_random_delay: Some(false),
396                    persistent: Some(false),
397                    remain_after_elapse: Some(true),
398                }),
399            }),
400            runtime: Some(UnitRuntime {
401                state_change_timestamp: None,
402                active_enter_timestamp: None,
403                inactive_exit_timestamp: None,
404                active_exit_timestamp: None,
405                cgroup: None,
406                service: None,
407                timer: Some(TimerRuntime {
408                    next_elapse_usec_realtime: Some(0),
409                    next_elapse_usec_monotonic: Some(91_091_400_515),
410                    last_trigger_usec: Some(Timestamp {
411                        realtime: Some(1_789_702_359_355_506),
412                        monotonic: Some(4_691_399_604),
413                    }),
414                }),
415            }),
416        }
417    }
418
419    #[test]
420    fn test_map_timer_stats() {
421        let triggered = output(
422            None,
423            Some(UnitRuntime {
424                state_change_timestamp: Some(Timestamp {
425                    realtime: Some(1_789_702_359_400_000),
426                    monotonic: Some(4_691_444_098),
427                }),
428                active_enter_timestamp: None,
429                inactive_exit_timestamp: None,
430                active_exit_timestamp: None,
431                cgroup: None,
432                service: None,
433                timer: None,
434            }),
435        );
436        let stats = map_timer_stats(&timer_output(), Some(&triggered));
437
438        assert_eq!(stats.accuracy_usec, 60_000_000);
439        assert_eq!(stats.last_trigger_usec, 1_789_702_359_355_506);
440        assert_eq!(stats.last_trigger_usec_monotonic, 4_691_399_604);
441        assert_eq!(stats.next_elapse_usec_monotonic, 91_091_400_515);
442        // A monotonic-only timer reports 0 here, as the D-Bus property does.
443        assert_eq!(stats.next_elapse_usec_realtime, 0);
444        assert!(stats.remain_after_elapse);
445        assert!(!stats.persistent);
446        // Omitted by systemd when unset, and 0 over D-Bus.
447        assert_eq!(stats.randomized_delay_usec, 0);
448        // Comes from the triggered unit, not the timer.
449        assert_eq!(
450            stats.service_unit_last_state_change_usec,
451            1_789_702_359_400_000
452        );
453        assert_eq!(
454            stats.service_unit_last_state_change_usec_monotonic,
455            4_691_444_098
456        );
457    }
458
459    #[test]
460    fn test_map_timer_stats_without_the_triggered_unit() {
461        // The D-Bus path reports 0 for both when the timer names no unit, so an
462        // unresolvable trigger target must not invent a timestamp.
463        let stats = map_timer_stats(&timer_output(), None);
464        assert_eq!(stats.service_unit_last_state_change_usec, 0);
465        assert_eq!(stats.service_unit_last_state_change_usec_monotonic, 0);
466        assert_eq!(stats.accuracy_usec, 60_000_000);
467    }
468
469    #[test]
470    fn test_timer_triggered_unit() {
471        assert_eq!(
472            timer_triggered_unit(&timer_output()),
473            Some("systemd-tmpfiles-clean.service")
474        );
475        assert_eq!(timer_triggered_unit(&output(None, None)), None);
476    }
477
478    #[test]
479    fn test_map_service_stats_from_an_empty_reply() {
480        // Everything absent must land on the D-Bus defaults rather than zeroing
481        // the unset sentinels. A service with accounting off reports [not set]
482        // over D-Bus, so reporting 0 bytes of memory would be a fabrication.
483        let stats = map_service_stats(&output(None, None), 0);
484        assert_eq!(stats.ioread_bytes, u64::MAX);
485        assert_eq!(stats.ioread_operations, u64::MAX);
486        assert_eq!(stats.timeout_clean_usec, u64::MAX);
487        assert_eq!(stats.cpuusage_nsec, u64::MAX);
488        assert_eq!(stats.memory_current, u64::MAX);
489        assert_eq!(stats.memory_available, u64::MAX);
490        assert_eq!(stats.tasks_current, u64::MAX);
491        // These two genuinely default to zero over D-Bus.
492        assert_eq!(stats.watchdog_usec, 0);
493        assert_eq!(stats.active_enter_timestamp, 0);
494    }
495}