- Upload reliability + diagnostics:
- api_upload_iso now distinguishes clean EOF from mid-stream errors;
a truncated multipart body (proxy buffer cap, network drop) returns
400 with the cause and a "try the LAN IP" hint instead of silently
finalising a partial file.
- Per-stage tracing (begin/MB-watermark/finish/abort) so a stuck
upload is debuggable from the Terminal tab.
- Web upload UI surfaces bytes/total, percent, throughput, ETA, and
maps 413/502/504/network-drop to actionable hints.
- New BootLog feature under Hosts:
- openpxe-core::BootLog — bounded in-memory ring (500) + append-only
JSONL on disk, recording (timestamp, mac, ip, target_id,
target_title) every time a boot entry script is served.
- iPXE per-entry chain URLs grow ?mac=${mac}; password prompt
submission carries it through; host-binding short-circuit uses the
bound MAC. ConnectInfo<SocketAddr> wired for peer IP capture (with
optional fallback so tower::oneshot in tests still works).
- GET /api/boot-log endpoint + Host log table under the Hosts tab.
- UI changes:
- Queue card header "Forge" → "Status".
- Removed Tinkerbell attribution sentence from Hosts tab.
- Topbar readiness chip moved into the sidebar footer as
"Service status: Ready / Advertised to clients / <url>", grouping
advertised PXE URL with operator-relevant status.
- Jet-black dark palette (#000 / #0a0a0a / #141414 / #1c1c1c)
replacing the blue-tinted ramp; terminal toolbar/input recoloured
to match.
- 89 tests passing (was 85 in v0.3.2); cargo clippy --workspace
--all-targets clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
250 lines
9.0 KiB
Rust
250 lines
9.0 KiB
Rust
//! Boot-event log — "who installed what, when, from where".
|
|
//!
|
|
//! Each `/boot/<entry>.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
|
|
//! `<work_dir>/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<String>,
|
|
/// 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<IpAddr>,
|
|
/// `BootEntry::id` — the same id used in `/boot/<id>.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<RwLock<_>>`.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BootLog {
|
|
path: Arc<PathBuf>,
|
|
inner: Arc<RwLock<VecDeque<BootEvent>>>,
|
|
}
|
|
|
|
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 `<work_dir>/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::<BootEvent>(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<BootEvent> {
|
|
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);
|
|
}
|
|
}
|