//! Boot-event log — "who installed what, when, from where". //! //! Each `/boot/.ipxe` fetch that actually goes on to serve a boot //! script lands an entry here. The log is bounded in memory (newest-first, //! ring-buffered at [`BootLog::CAP`]) and is mirrored append-only to //! `/boot_log.jsonl`. Mirrors `HostBindings`'s "in-memory is //! authoritative, disk is a cache" policy — a corrupt log file should //! never block PXE for the network. //! //! We deliberately don't push these onto the `LogBus` (the operator //! terminal stream). The terminal already shows the http traces; the //! Host log is a curated, persistent, easy-to-scan view of "what got //! imaged on what hardware" and conflating the two would be noisy. use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use std::collections::VecDeque; use std::io::Write; use std::net::IpAddr; use std::path::PathBuf; use std::sync::Arc; use time::OffsetDateTime; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BootEvent { #[serde(with = "time::serde::rfc3339")] pub timestamp: OffsetDateTime, /// Lowercase, colon-separated. `None` when iPXE didn't supply /// `?mac=${mac}` in the chain URL (older bookmarks, custom scripts). pub mac: Option, /// Connecting peer's IP — taken from the TCP socket when available /// (PXE clients connect direct, no reverse proxy), and falls back to /// `X-Forwarded-For` for the rare case where one is present. pub ip: Option, /// `BootEntry::id` — the same id used in `/boot/.ipxe`. pub target_id: String, /// Human-friendly label: the ISO's filename / volume label / entry /// title. Pre-resolved at log time so the UI can render without /// joining against the ISO store (and so "what image was installed?" /// survives the operator deleting the ISO later). pub target_title: String, } /// In-memory ring + disk-backed append log of boot events. Cheap to /// clone; the inner state is `Arc>`. #[derive(Debug, Clone)] pub struct BootLog { path: Arc, inner: Arc>>, } impl BootLog { /// Newest entries we retain in memory. Past this, the oldest gets /// evicted — the on-disk JSONL keeps the full history for offline /// inspection. 500 covers a typical install-day's worth without /// turning the Hosts tab into a wall of text. pub const CAP: usize = 500; /// Load up to `CAP` newest events from `/boot_log.jsonl`, /// or start empty if the file is missing / unreadable. #[must_use] pub fn load_or_default(work_dir: &std::path::Path) -> Self { let path = work_dir.join("boot_log.jsonl"); let mut events = VecDeque::with_capacity(Self::CAP); if let Ok(text) = std::fs::read_to_string(&path) { for line in text.lines() { if line.trim().is_empty() { continue; } match serde_json::from_str::(line) { Ok(ev) => { if events.len() == Self::CAP { events.pop_front(); } events.push_back(ev); } Err(e) => { tracing::warn!( target: "openpxe::boot_log", "skipping unparseable boot_log line: {e}" ); } } } } Self { path: Arc::new(path), inner: Arc::new(RwLock::new(events)), } } /// Append an event. Persistence is best-effort and never blocks the /// caller on a failed write (the in-memory copy is the source of /// truth for the live UI; the JSONL is just for crash survival). pub fn record(&self, ev: &BootEvent) { // Push into the ring first so a slow / failing disk doesn't lose // events for the live UI. { let mut g = self.inner.write(); if g.len() == Self::CAP { g.pop_front(); } g.push_back(ev.clone()); } tracing::info!( target: "openpxe::boot_log", mac = ev.mac.as_deref().unwrap_or("?"), ip = ev.ip.map(|i| i.to_string()).as_deref().unwrap_or("?"), target = %ev.target_id, "boot event" ); // Append to disk. We tolerate write failures — they'd show up as // missing entries on the next restart only. let mut line = match serde_json::to_string(ev) { Ok(s) => s, Err(e) => { tracing::warn!(target: "openpxe::boot_log", "serialize boot event: {e}"); return; } }; line.push('\n'); if let Some(parent) = self.path.parent() { let _ = std::fs::create_dir_all(parent); } match std::fs::OpenOptions::new() .create(true) .append(true) .open(self.path.as_path()) { Ok(mut f) => { if let Err(e) = f.write_all(line.as_bytes()) { tracing::warn!(target: "openpxe::boot_log", "append boot_log.jsonl: {e}"); } } Err(e) => { tracing::warn!(target: "openpxe::boot_log", "open boot_log.jsonl: {e}"); } } } /// Newest-first snapshot, up to `CAP` entries. #[must_use] pub fn list(&self) -> Vec { let g = self.inner.read(); // VecDeque preserves insertion order; reverse so newest is first. g.iter().rev().cloned().collect() } #[must_use] pub fn len(&self) -> usize { self.inner.read().len() } #[must_use] pub fn is_empty(&self) -> bool { self.len() == 0 } /// Wipe in-memory + the on-disk file. Used by the `terminal clear` /// equivalent or future operator action; not currently wired to a UI /// button but exposed for completeness. pub fn clear(&self) { self.inner.write().clear(); let _ = std::fs::remove_file(self.path.as_path()); } } #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; fn ev(target: &str, mac: Option<&str>) -> BootEvent { BootEvent { timestamp: OffsetDateTime::now_utc(), mac: mac.map(str::to_string), ip: Some("10.0.0.42".parse().unwrap()), target_id: target.into(), target_title: format!("{target}.iso"), } } #[test] fn record_then_list_is_newest_first() { let dir = tempdir().unwrap(); let log = BootLog::load_or_default(dir.path()); assert!(log.is_empty()); log.record(&ev("alpha", Some("aa:bb:cc:00:00:01"))); log.record(&ev("beta", Some("aa:bb:cc:00:00:02"))); let list = log.list(); assert_eq!(list.len(), 2); assert_eq!(list[0].target_id, "beta"); assert_eq!(list[1].target_id, "alpha"); } #[test] fn round_trip_through_disk() { let dir = tempdir().unwrap(); let log = BootLog::load_or_default(dir.path()); log.record(&ev("alpha", Some("aa:bb:cc:00:00:01"))); log.record(&ev("beta", None)); drop(log); let log2 = BootLog::load_or_default(dir.path()); assert_eq!(log2.len(), 2); let list = log2.list(); assert_eq!(list[0].target_id, "beta"); assert_eq!(list[1].target_id, "alpha"); assert!(list[0].mac.is_none()); assert_eq!(list[1].mac.as_deref(), Some("aa:bb:cc:00:00:01")); } #[test] fn ring_evicts_oldest_past_cap() { let dir = tempdir().unwrap(); let log = BootLog::load_or_default(dir.path()); for i in 0..(BootLog::CAP + 5) { log.record(&ev(&format!("e{i}"), None)); } assert_eq!(log.len(), BootLog::CAP); let list = log.list(); // Newest first; the most recent push is the last index inserted. assert_eq!(list[0].target_id, format!("e{}", BootLog::CAP + 4)); // Oldest in-memory should be the 6th push (0..5 were evicted). assert_eq!(list[BootLog::CAP - 1].target_id, "e5"); } #[test] fn clear_wipes_memory_and_disk() { let dir = tempdir().unwrap(); let log = BootLog::load_or_default(dir.path()); log.record(&ev("alpha", None)); log.clear(); assert!(log.is_empty()); let log2 = BootLog::load_or_default(dir.path()); assert!(log2.is_empty()); } #[test] fn corrupt_disk_lines_are_skipped_not_fatal() { // Write a file with one valid + one garbage line; loader should // surface the valid one and skip the garbage. let dir = tempdir().unwrap(); let path = dir.path().join("boot_log.jsonl"); let valid = serde_json::to_string(&ev("ok", Some("aa:bb:cc:00:00:09"))).unwrap(); std::fs::write(&path, format!("{valid}\nNOT_JSON\n{valid}\n")).unwrap(); let log = BootLog::load_or_default(dir.path()); assert_eq!(log.len(), 2); } }