Skip to main content

monitord/
boot.rs

1//! # boot module
2//!
3//! Collects boot blame metrics showing the slowest units at boot.
4//! Similar to `systemd-analyze blame` but stores N slowest units.
5
6use std::array::TryFromSliceError;
7use std::collections::HashMap;
8use std::io::ErrorKind;
9use std::num::TryFromIntError;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13use anyhow::{anyhow, Result};
14use tokio::sync::RwLock;
15use tracing::debug;
16use zbus::zvariant::ObjectPath;
17
18use crate::config::Config;
19use crate::dbus::zbus_systemd::ManagerProxy;
20use crate::dbus::zbus_unit::UnitProxy;
21use crate::MachineStats;
22
23/// Boot blame statistics: maps unit name to activation time in seconds
24pub type BootBlameStats = HashMap<String, f64>;
25
26const BOOT_ID_PATH: &str = "/proc/sys/kernel/random/boot_id";
27const BOOT_BLAME_CACHE_SUFFIX: &str = "boot_blame.bin";
28
29type BootCacheResult<T> = std::result::Result<T, BootCacheError>;
30
31#[derive(Debug, thiserror::Error)]
32enum BootCacheError {
33    #[error("boot cache I/O error: {0}")]
34    Io(#[from] std::io::Error),
35    #[error("boot id from {BOOT_ID_PATH} was empty")]
36    EmptyBootId,
37    #[error("boot cache payload decode error: {0}")]
38    InvalidPayload(&'static str),
39    #[error("boot cache UTF-8 decode error: {0}")]
40    Utf8(#[from] std::string::FromUtf8Error),
41    #[error("boot cache integer conversion error: {0}")]
42    IntConversion(#[from] TryFromIntError),
43    #[error("boot cache slice conversion error: {0}")]
44    SliceConversion(#[from] TryFromSliceError),
45}
46
47fn cache_file_path(cache_dir: &Path, boot_id: &str) -> PathBuf {
48    cache_dir.join(format!("{boot_id}.{BOOT_BLAME_CACHE_SUFFIX}"))
49}
50
51async fn get_boot_id() -> BootCacheResult<String> {
52    let boot_id = tokio::fs::read_to_string(BOOT_ID_PATH).await?;
53    let boot_id = boot_id.trim().to_string();
54    if boot_id.is_empty() {
55        return Err(BootCacheError::EmptyBootId);
56    }
57    Ok(boot_id)
58}
59
60fn encode_boot_blame_stats(stats: &BootBlameStats) -> BootCacheResult<Vec<u8>> {
61    let mut out = Vec::new();
62    let entry_count = u32::try_from(stats.len())?;
63    out.extend_from_slice(&entry_count.to_le_bytes());
64
65    for (unit_name, activation_time) in stats {
66        let unit_name_bytes = unit_name.as_bytes();
67        let unit_name_len = u32::try_from(unit_name_bytes.len())?;
68        out.extend_from_slice(&unit_name_len.to_le_bytes());
69        out.extend_from_slice(unit_name_bytes);
70        out.extend_from_slice(&activation_time.to_le_bytes());
71    }
72
73    Ok(out)
74}
75
76/// Cached boot blame payload plus the transport that produced it.
77///
78/// The transport byte is appended after the entries by current writers.
79/// `None` means a legacy file written before transport tracking existed —
80/// the caller treats that as a miss and re-collects, so a legacy hit can
81/// never emit a gauge claiming a transport that was never recorded.
82struct DecodedBootBlame {
83    stats: BootBlameStats,
84    transport: Option<crate::CollectorTransport>,
85}
86
87fn decode_boot_blame_stats(content: &[u8]) -> BootCacheResult<BootBlameStats> {
88    const U32_BYTES: usize = std::mem::size_of::<u32>();
89    const F64_BYTES: usize = std::mem::size_of::<f64>();
90    fn read_u32(bytes: &[u8], offset: &mut usize) -> BootCacheResult<u32> {
91        if *offset + std::mem::size_of::<u32>() > bytes.len() {
92            return Err(BootCacheError::InvalidPayload("unexpected end of payload"));
93        }
94        let value =
95            u32::from_le_bytes(bytes[*offset..*offset + std::mem::size_of::<u32>()].try_into()?);
96        *offset += std::mem::size_of::<u32>();
97        Ok(value)
98    }
99
100    if content.len() < U32_BYTES {
101        return Err(BootCacheError::InvalidPayload("payload too small"));
102    }
103
104    let mut offset = 0usize;
105    let entry_count = read_u32(content, &mut offset)? as usize;
106    let mut stats = BootBlameStats::with_capacity(entry_count);
107
108    for _ in 0..entry_count {
109        let name_len = read_u32(content, &mut offset)? as usize;
110        if offset + name_len + F64_BYTES > content.len() {
111            return Err(BootCacheError::InvalidPayload("invalid payload size"));
112        }
113        let unit_name = String::from_utf8(content[offset..offset + name_len].to_vec())?;
114        offset += name_len;
115        let activation_time = f64::from_le_bytes(content[offset..offset + F64_BYTES].try_into()?);
116        offset += F64_BYTES;
117        stats.insert(unit_name, activation_time);
118    }
119
120    if offset != content.len() {
121        return Err(BootCacheError::InvalidPayload("trailing bytes in payload"));
122    }
123
124    Ok(stats)
125}
126
127fn encode_cached_boot_blame(
128    stats: &BootBlameStats,
129    transport: crate::CollectorTransport,
130) -> BootCacheResult<Vec<u8>> {
131    let mut out = encode_boot_blame_stats(stats)?;
132    out.push(transport as u8);
133    Ok(out)
134}
135
136fn decode_cached_boot_blame(content: &[u8]) -> BootCacheResult<DecodedBootBlame> {
137    // Current files carry one trailing transport byte after the entries;
138    // legacy files end right after the last entry. A last byte of 0/1 whose
139    // removal leaves a well-formed entry payload is the new format —
140    // anything else decodes as legacy (transport unknown).
141    if let Some((payload, [marker])) = content.split_at_checked(content.len().saturating_sub(1)) {
142        if *marker <= 1 {
143            if let Ok(stats) = decode_boot_blame_stats(payload) {
144                let transport = if *marker == 1 {
145                    crate::CollectorTransport::Varlink
146                } else {
147                    crate::CollectorTransport::Dbus
148                };
149                return Ok(DecodedBootBlame {
150                    stats,
151                    transport: Some(transport),
152                });
153            }
154        }
155    }
156    Ok(DecodedBootBlame {
157        stats: decode_boot_blame_stats(content)?,
158        transport: None,
159    })
160}
161
162async fn read_cached_boot_blame_from_dir(
163    cache_dir: &Path,
164    boot_id: &str,
165) -> BootCacheResult<Option<DecodedBootBlame>> {
166    let cache_path = cache_file_path(cache_dir, boot_id);
167    let content = match tokio::fs::read(&cache_path).await {
168        Ok(content) => content,
169        Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
170        Err(err) => return Err(err.into()),
171    };
172    Ok(Some(decode_cached_boot_blame(&content)?))
173}
174
175async fn write_cached_boot_blame_to_dir(
176    cache_dir: &Path,
177    boot_id: &str,
178    stats: &BootBlameStats,
179    transport: crate::CollectorTransport,
180) -> BootCacheResult<()> {
181    tokio::fs::create_dir_all(cache_dir).await?;
182    let cache_path = cache_file_path(cache_dir, boot_id);
183    let encoded = encode_cached_boot_blame(stats, transport)?;
184    tokio::fs::write(cache_path, encoded).await?;
185    Ok(())
186}
187
188/// Calculate the activation time for a unit
189/// Returns the time in seconds from InactiveExitTimestamp to ActiveEnterTimestamp
190async fn get_unit_activation_time(
191    connection: &zbus::Connection,
192    unit_path: &ObjectPath<'_>,
193) -> Result<f64> {
194    let unit_proxy = UnitProxy::builder(connection)
195        .cache_properties(zbus::proxy::CacheProperties::No)
196        .path(unit_path)?
197        .build()
198        .await?;
199
200    let inactive_exit = unit_proxy.inactive_exit_timestamp().await?;
201    let active_enter = unit_proxy.active_enter_timestamp().await?;
202
203    // If either timestamp is 0, the unit hasn't been activated or the timing is invalid
204    if inactive_exit == 0 || active_enter == 0 {
205        return Ok(0.0);
206    }
207
208    // Calculate activation time in seconds (timestamps are in microseconds)
209    let activation_time_usec = active_enter.saturating_sub(inactive_exit);
210    let activation_time_sec = activation_time_usec as f64 / 1_000_000.0;
211
212    Ok(activation_time_sec)
213}
214
215/// Collect boot blame over D-Bus: one ListUnits plus two property reads
216/// per unit on the system.
217async fn collect_boot_blame_dbus(
218    config: &Config,
219    connection: &zbus::Connection,
220) -> Result<BootBlameStats> {
221    let systemd_proxy = ManagerProxy::builder(connection)
222        .cache_properties(zbus::proxy::CacheProperties::No)
223        .build()
224        .await?;
225    let units = systemd_proxy.list_units().await?;
226
227    let mut unit_times: Vec<(String, f64)> = Vec::new();
228
229    // Collect activation times for all units
230    for unit_info in units {
231        let unit_name = unit_info.0;
232        let unit_path = unit_info.6;
233
234        // Apply blocklist: skip units explicitly excluded
235        if config.boot_blame.blocklist.contains(&unit_name) {
236            debug!("Skipping boot blame for {} due to blocklist", &unit_name);
237            continue;
238        }
239        // Apply allowlist: if non-empty, only include listed units
240        if !config.boot_blame.allowlist.is_empty()
241            && !config.boot_blame.allowlist.contains(&unit_name)
242        {
243            continue;
244        }
245
246        match get_unit_activation_time(connection, &unit_path).await {
247            Ok(time) if time > 0.0 => {
248                unit_times.push((unit_name, time));
249            }
250            Ok(_) => {
251                // Unit has no activation time (0.0), skip it
252            }
253            Err(e) => {
254                debug!("Failed to get activation time for {}: {}", unit_name, e);
255            }
256        }
257    }
258
259    // Sort by activation time in descending order (slowest first)
260    unit_times.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
261
262    // Take only the N slowest units
263    let num_slowest = config.boot_blame.num_slowest_units as usize;
264    unit_times.truncate(num_slowest);
265
266    // Convert to HashMap
267    Ok(unit_times.into_iter().collect())
268}
269
270/// Update boot blame statistics with the N slowest units at boot.
271///
272/// Takes the shared D-Bus cell rather than a connection: cache hits and the
273/// varlink path return without ever connecting, so a bus-less host only
274/// pays for D-Bus when it actually collects over D-Bus.
275pub async fn update_boot_blame_stats(
276    config: Arc<Config>,
277    dbus: crate::DbusCell,
278    dbus_timeout: u64,
279    machine_stats: Arc<RwLock<MachineStats>>,
280    no_fallback: bool,
281) -> Result<()> {
282    debug!("Starting boot blame stats collection");
283
284    let mut maybe_boot_id = None;
285    if config.boot_blame.cache_enabled {
286        // In-memory hit: the collecting run already recorded which transport
287        // produced these stats, so the gauge is intact — keep it.
288        let cached_stats = machine_stats.read().await.boot_blame.clone();
289        if cached_stats.is_some() {
290            debug!("Using in-memory cached boot blame stats");
291            return Ok(());
292        }
293
294        let cache_dir = Path::new(&config.boot_blame.cache_dir);
295        match get_boot_id().await {
296            Ok(boot_id) => {
297                match read_cached_boot_blame_from_dir(cache_dir, &boot_id).await {
298                    Ok(Some(cached)) => {
299                        let cache_path = cache_file_path(cache_dir, &boot_id);
300                        debug!(
301                            "Using cached boot blame stats from {}",
302                            cache_path.display()
303                        );
304                        let mut stats = machine_stats.write().await;
305                        stats.boot_blame = Some(cached.stats);
306                        match cached.transport {
307                            Some(transport) => {
308                                // Replay the transport that produced the
309                                // cached entry so the gauge stays stable
310                                // across runs instead of going absent.
311                                stats.varlink_usage.boot_blame = Some(transport);
312                            }
313                            None => {
314                                // Legacy file from before transport tracking:
315                                // re-collect below so the gauge is honest
316                                // rather than absent or guessed.
317                                debug!(
318                                    "Boot blame cache predates transport tracking, re-collecting"
319                                );
320                                drop(stats);
321                                maybe_boot_id = Some(boot_id);
322                                return collect_and_cache(
323                                    &config,
324                                    &dbus,
325                                    dbus_timeout,
326                                    no_fallback,
327                                    machine_stats,
328                                    maybe_boot_id,
329                                )
330                                .await;
331                            }
332                        }
333                        return Ok(());
334                    }
335                    Ok(None) => {
336                        debug!("No cached boot blame stats found for boot id {}", boot_id);
337                    }
338                    Err(err) => {
339                        debug!(
340                            "Failed to load boot blame cache for boot id {}: {}",
341                            boot_id, err
342                        );
343                    }
344                }
345                maybe_boot_id = Some(boot_id);
346            }
347            Err(err) => {
348                debug!("Failed to retrieve boot id for boot blame cache: {}", err);
349            }
350        }
351    }
352
353    collect_and_cache(
354        &config,
355        &dbus,
356        dbus_timeout,
357        no_fallback,
358        machine_stats,
359        maybe_boot_id,
360    )
361    .await
362}
363
364/// Collect boot blame over whichever transport applies, record it, and write
365/// the disk cache (stamping which transport produced the entry so cache hits
366/// replay an honest gauge instead of going absent).
367async fn collect_and_cache(
368    config: &Arc<Config>,
369    dbus: &crate::DbusCell,
370    dbus_timeout: u64,
371    no_fallback: bool,
372    machine_stats: Arc<RwLock<MachineStats>>,
373    maybe_boot_id: Option<String>,
374) -> Result<()> {
375    let use_varlink = config.use_varlink(&[config.boot_blame.varlink]);
376    let (boot_blame_stats, transport) = if use_varlink {
377        match crate::varlink_boot::get_boot_blame_stats(
378            crate::varlink_boot::METRICS_SOCKET_PATH,
379            &config.boot_blame,
380        )
381        .await
382        {
383            Ok(stats) => (stats, crate::CollectorTransport::Varlink),
384            Err(err) => {
385                // Plain `?`: NoFallbackError already renders the full
386                // explanation via Display; `{:?}` would dump the struct.
387                crate::varlink_fallback::report_varlink_failure(
388                    no_fallback,
389                    "boot blame",
390                    "D-Bus",
391                    err,
392                )?;
393                let connection = crate::dbus_connection(dbus, dbus_timeout)
394                    .await
395                    .map_err(|e| anyhow!("D-Bus connection error: {:?}", e))?;
396                (
397                    collect_boot_blame_dbus(config, &connection).await?,
398                    crate::CollectorTransport::Dbus,
399                )
400            }
401        }
402    } else {
403        let connection = crate::dbus_connection(dbus, dbus_timeout)
404            .await
405            .map_err(|e| anyhow!("D-Bus connection error: {:?}", e))?;
406        (
407            collect_boot_blame_dbus(config, &connection).await?,
408            crate::CollectorTransport::Dbus,
409        )
410    };
411
412    debug!("Collected {} boot blame stats", boot_blame_stats.len());
413
414    // Update machine stats
415    let mut stats = machine_stats.write().await;
416    stats.boot_blame = Some(boot_blame_stats);
417    stats.varlink_usage.boot_blame = Some(transport);
418    if config.boot_blame.cache_enabled {
419        if let Some(boot_id) = maybe_boot_id {
420            if let Some(cached_stats) = stats.boot_blame.as_ref() {
421                let cache_dir = Path::new(&config.boot_blame.cache_dir);
422                if let Err(err) =
423                    write_cached_boot_blame_to_dir(cache_dir, &boot_id, cached_stats, transport)
424                        .await
425                {
426                    debug!(
427                        "Failed to write boot blame cache for boot id {} to {}: {}",
428                        boot_id, config.boot_blame.cache_dir, err
429                    );
430                } else {
431                    debug!("Updated boot blame cache for boot id {}", boot_id);
432                }
433            }
434        }
435    }
436
437    Ok(())
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn test_boot_blame_cache_encode_decode_roundtrip() {
446        let mut stats = BootBlameStats::new();
447        stats.insert("foo.service".to_string(), 12.3);
448        stats.insert("bar.service".to_string(), 45.6);
449
450        let encoded = encode_boot_blame_stats(&stats).expect("encode should succeed");
451        let decoded = decode_boot_blame_stats(&encoded).expect("decode should succeed");
452        assert_eq!(stats, decoded);
453    }
454
455    #[test]
456    fn test_boot_blame_cache_decode_invalid_payload() {
457        let invalid_payload = vec![0, 1, 2];
458        assert!(decode_boot_blame_stats(&invalid_payload).is_err());
459    }
460
461    #[test]
462    fn test_cached_boot_blame_roundtrips_transport() {
463        // The disk cache stamps which transport produced the entry so hits
464        // replay an honest gauge instead of going absent.
465        let mut stats = BootBlameStats::new();
466        stats.insert("foo.service".to_string(), 12.3);
467        for transport in [
468            crate::CollectorTransport::Varlink,
469            crate::CollectorTransport::Dbus,
470        ] {
471            let encoded = encode_cached_boot_blame(&stats, transport).expect("encode");
472            let decoded = decode_cached_boot_blame(&encoded).expect("decode");
473            assert_eq!(stats, decoded.stats);
474            assert_eq!(Some(transport), decoded.transport);
475        }
476    }
477
478    #[test]
479    fn test_legacy_boot_blame_cache_decodes_without_transport() {
480        // Pre-gauge files carry no transport byte: they decode with
481        // transport None, and the caller re-collects rather than guessing.
482        let mut stats = BootBlameStats::new();
483        stats.insert("foo.service".to_string(), 12.3);
484        stats.insert("bar.service".to_string(), 45.6);
485        let encoded = encode_boot_blame_stats(&stats).expect("encode should succeed");
486        let decoded = decode_cached_boot_blame(&encoded).expect("decode should succeed");
487        assert_eq!(stats, decoded.stats);
488        assert_eq!(None, decoded.transport);
489    }
490
491    #[tokio::test]
492    async fn test_boot_blame_cache_read_write_roundtrip() {
493        let temp_dir = tempfile::tempdir().expect("create temp dir");
494        let boot_id = "00000000-0000-0000-0000-000000000001";
495        let mut stats = BootBlameStats::new();
496        stats.insert("foo.service".to_string(), 1.25);
497
498        write_cached_boot_blame_to_dir(
499            temp_dir.path(),
500            boot_id,
501            &stats,
502            crate::CollectorTransport::Varlink,
503        )
504        .await
505        .expect("write cache");
506        let read = read_cached_boot_blame_from_dir(temp_dir.path(), boot_id)
507            .await
508            .expect("read cache")
509            .expect("cache hit");
510        assert_eq!(stats, read.stats);
511        assert_eq!(Some(crate::CollectorTransport::Varlink), read.transport);
512    }
513
514    #[tokio::test]
515    async fn test_boot_blame_cache_read_missing_file() {
516        let temp_dir = tempfile::tempdir().expect("create temp dir");
517        let missing = read_cached_boot_blame_from_dir(
518            temp_dir.path(),
519            "00000000-0000-0000-0000-000000000002",
520        )
521        .await
522        .expect("missing cache should not error");
523        assert!(missing.is_none());
524    }
525}