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::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
271pub async fn update_boot_blame_stats(
272    config: Arc<Config>,
273    connection: zbus::Connection,
274    machine_stats: Arc<RwLock<MachineStats>>,
275) -> Result<()> {
276    debug!("Starting boot blame stats collection");
277
278    let mut maybe_boot_id = None;
279    if config.boot_blame.cache_enabled {
280        // In-memory hit: the collecting run already recorded which transport
281        // produced these stats, so the gauge is intact — keep it.
282        let cached_stats = machine_stats.read().await.boot_blame.clone();
283        if cached_stats.is_some() {
284            debug!("Using in-memory cached boot blame stats");
285            return Ok(());
286        }
287
288        let cache_dir = Path::new(&config.boot_blame.cache_dir);
289        match get_boot_id().await {
290            Ok(boot_id) => {
291                match read_cached_boot_blame_from_dir(cache_dir, &boot_id).await {
292                    Ok(Some(cached)) => {
293                        let cache_path = cache_file_path(cache_dir, &boot_id);
294                        debug!(
295                            "Using cached boot blame stats from {}",
296                            cache_path.display()
297                        );
298                        let mut stats = machine_stats.write().await;
299                        stats.boot_blame = Some(cached.stats);
300                        match cached.transport {
301                            Some(transport) => {
302                                // Replay the transport that produced the
303                                // cached entry so the gauge stays stable
304                                // across runs instead of going absent.
305                                stats.varlink_usage.boot_blame = Some(transport);
306                            }
307                            None => {
308                                // Legacy file from before transport tracking:
309                                // re-collect below so the gauge is honest
310                                // rather than absent or guessed.
311                                debug!(
312                                    "Boot blame cache predates transport tracking, re-collecting"
313                                );
314                                drop(stats);
315                                maybe_boot_id = Some(boot_id);
316                                return collect_and_cache(
317                                    &config,
318                                    &connection,
319                                    machine_stats,
320                                    maybe_boot_id,
321                                )
322                                .await;
323                            }
324                        }
325                        return Ok(());
326                    }
327                    Ok(None) => {
328                        debug!("No cached boot blame stats found for boot id {}", boot_id);
329                    }
330                    Err(err) => {
331                        debug!(
332                            "Failed to load boot blame cache for boot id {}: {}",
333                            boot_id, err
334                        );
335                    }
336                }
337                maybe_boot_id = Some(boot_id);
338            }
339            Err(err) => {
340                debug!("Failed to retrieve boot id for boot blame cache: {}", err);
341            }
342        }
343    }
344
345    collect_and_cache(&config, &connection, machine_stats, maybe_boot_id).await
346}
347
348/// Collect boot blame over whichever transport applies, record it, and write
349/// the disk cache (stamping which transport produced the entry so cache hits
350/// replay an honest gauge instead of going absent).
351async fn collect_and_cache(
352    config: &Arc<Config>,
353    connection: &zbus::Connection,
354    machine_stats: Arc<RwLock<MachineStats>>,
355    maybe_boot_id: Option<String>,
356) -> Result<()> {
357    let use_varlink = config.use_varlink(&[config.boot_blame.varlink]);
358    let (boot_blame_stats, transport) = if use_varlink {
359        match crate::varlink_boot::get_boot_blame_stats(
360            crate::varlink_boot::METRICS_SOCKET_PATH,
361            &config.boot_blame,
362        )
363        .await
364        {
365            Ok(stats) => (stats, crate::CollectorTransport::Varlink),
366            Err(err) => {
367                tracing::warn!(
368                    "Varlink boot blame failed, falling back to D-Bus: {:?}",
369                    err
370                );
371                (
372                    collect_boot_blame_dbus(config, connection).await?,
373                    crate::CollectorTransport::Dbus,
374                )
375            }
376        }
377    } else {
378        (
379            collect_boot_blame_dbus(config, connection).await?,
380            crate::CollectorTransport::Dbus,
381        )
382    };
383
384    debug!("Collected {} boot blame stats", boot_blame_stats.len());
385
386    // Update machine stats
387    let mut stats = machine_stats.write().await;
388    stats.boot_blame = Some(boot_blame_stats);
389    stats.varlink_usage.boot_blame = Some(transport);
390    if config.boot_blame.cache_enabled {
391        if let Some(boot_id) = maybe_boot_id {
392            if let Some(cached_stats) = stats.boot_blame.as_ref() {
393                let cache_dir = Path::new(&config.boot_blame.cache_dir);
394                if let Err(err) =
395                    write_cached_boot_blame_to_dir(cache_dir, &boot_id, cached_stats, transport)
396                        .await
397                {
398                    debug!(
399                        "Failed to write boot blame cache for boot id {} to {}: {}",
400                        boot_id, config.boot_blame.cache_dir, err
401                    );
402                } else {
403                    debug!("Updated boot blame cache for boot id {}", boot_id);
404                }
405            }
406        }
407    }
408
409    Ok(())
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn test_boot_blame_cache_encode_decode_roundtrip() {
418        let mut stats = BootBlameStats::new();
419        stats.insert("foo.service".to_string(), 12.3);
420        stats.insert("bar.service".to_string(), 45.6);
421
422        let encoded = encode_boot_blame_stats(&stats).expect("encode should succeed");
423        let decoded = decode_boot_blame_stats(&encoded).expect("decode should succeed");
424        assert_eq!(stats, decoded);
425    }
426
427    #[test]
428    fn test_boot_blame_cache_decode_invalid_payload() {
429        let invalid_payload = vec![0, 1, 2];
430        assert!(decode_boot_blame_stats(&invalid_payload).is_err());
431    }
432
433    #[test]
434    fn test_cached_boot_blame_roundtrips_transport() {
435        // The disk cache stamps which transport produced the entry so hits
436        // replay an honest gauge instead of going absent.
437        let mut stats = BootBlameStats::new();
438        stats.insert("foo.service".to_string(), 12.3);
439        for transport in [
440            crate::CollectorTransport::Varlink,
441            crate::CollectorTransport::Dbus,
442        ] {
443            let encoded = encode_cached_boot_blame(&stats, transport).expect("encode");
444            let decoded = decode_cached_boot_blame(&encoded).expect("decode");
445            assert_eq!(stats, decoded.stats);
446            assert_eq!(Some(transport), decoded.transport);
447        }
448    }
449
450    #[test]
451    fn test_legacy_boot_blame_cache_decodes_without_transport() {
452        // Pre-gauge files carry no transport byte: they decode with
453        // transport None, and the caller re-collects rather than guessing.
454        let mut stats = BootBlameStats::new();
455        stats.insert("foo.service".to_string(), 12.3);
456        stats.insert("bar.service".to_string(), 45.6);
457        let encoded = encode_boot_blame_stats(&stats).expect("encode should succeed");
458        let decoded = decode_cached_boot_blame(&encoded).expect("decode should succeed");
459        assert_eq!(stats, decoded.stats);
460        assert_eq!(None, decoded.transport);
461    }
462
463    #[tokio::test]
464    async fn test_boot_blame_cache_read_write_roundtrip() {
465        let temp_dir = tempfile::tempdir().expect("create temp dir");
466        let boot_id = "00000000-0000-0000-0000-000000000001";
467        let mut stats = BootBlameStats::new();
468        stats.insert("foo.service".to_string(), 1.25);
469
470        write_cached_boot_blame_to_dir(
471            temp_dir.path(),
472            boot_id,
473            &stats,
474            crate::CollectorTransport::Varlink,
475        )
476        .await
477        .expect("write cache");
478        let read = read_cached_boot_blame_from_dir(temp_dir.path(), boot_id)
479            .await
480            .expect("read cache")
481            .expect("cache hit");
482        assert_eq!(stats, read.stats);
483        assert_eq!(Some(crate::CollectorTransport::Varlink), read.transport);
484    }
485
486    #[tokio::test]
487    async fn test_boot_blame_cache_read_missing_file() {
488        let temp_dir = tempfile::tempdir().expect("create temp dir");
489        let missing = read_cached_boot_blame_from_dir(
490            temp_dir.path(),
491            "00000000-0000-0000-0000-000000000002",
492        )
493        .await
494        .expect("missing cache should not error");
495        assert!(missing.is_none());
496    }
497}