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