FDS/OS 1.0
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
use clap::{CommandFactory, Parser, Subcommand};
|
||||
use fds_common::{
|
||||
Error, Result, read_text,
|
||||
trace::{self, Point, Report},
|
||||
};
|
||||
use std::{
|
||||
fs,
|
||||
os::unix::fs::PermissionsExt,
|
||||
path::{Path, PathBuf},
|
||||
process::ExitCode,
|
||||
};
|
||||
|
||||
fn root() -> Result<()> {
|
||||
if unsafe { libc::geteuid() } != 0 {
|
||||
return Err(Error("This boot event requires root".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn clock_floor() -> Result<()> {
|
||||
root()?;
|
||||
let epoch: i64 = read_text(Path::new("/usr/share/fds/build-epoch"), 32)?
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| Error("Invalid image clock floor".into()))?;
|
||||
if !(1..=4_102_444_800).contains(&epoch) {
|
||||
return Err(Error(
|
||||
"Image clock floor is outside the supported range".into(),
|
||||
));
|
||||
}
|
||||
let mut before: libc::timespec = unsafe { std::mem::zeroed() };
|
||||
if unsafe { libc::clock_gettime(libc::CLOCK_REALTIME, &mut before) } < 0 {
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
let advance = before.tv_sec < epoch;
|
||||
if advance {
|
||||
let floor = libc::timespec {
|
||||
tv_sec: epoch,
|
||||
tv_nsec: 0,
|
||||
};
|
||||
if unsafe { libc::clock_settime(libc::CLOCK_REALTIME, &floor) } < 0 {
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
}
|
||||
fs::create_dir_all("/run/fds")?;
|
||||
let status = serde_json::json!({"format":1, "source":if advance {"image_floor"} else {"retained_kernel_clock"},
|
||||
"image_epoch":epoch, "previous_unix_seconds":before.tv_sec, "minimum_unix_seconds":before.tv_sec.max(epoch)});
|
||||
fs::write(
|
||||
"/run/fds/clock.json",
|
||||
serde_json::to_vec_pretty(&status).map_err(|e| Error(e.to_string()))?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
fn adopt() -> Result<()> {
|
||||
root()?;
|
||||
let instant = trace::now()?;
|
||||
let directory = Path::new(trace::RUNTIME);
|
||||
fs::create_dir_all(directory.join("console"))?;
|
||||
fs::set_permissions(directory, fs::Permissions::from_mode(0o755))?;
|
||||
fs::set_permissions(directory.join("console"), fs::Permissions::from_mode(0o755))?;
|
||||
let path = std::ffi::CString::new(directory.join("console").to_str().unwrap()).unwrap();
|
||||
if unsafe { libc::chown(path.as_ptr(), 1000, 1000) } < 0 {
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
for point in Point::ALL {
|
||||
let source = Path::new(trace::EARLY).join(format!("{}.json", point.name()));
|
||||
if source.exists() {
|
||||
fs::rename(&source, directory.join(source.file_name().unwrap())).or_else(|error| {
|
||||
if error.raw_os_error() != Some(libc::EXDEV) {
|
||||
return Err(error);
|
||||
}
|
||||
fs::copy(&source, directory.join(source.file_name().unwrap()))?;
|
||||
fs::remove_file(source)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
trace::save(directory, Point::S6Start, instant)?;
|
||||
// Preserve a valid kernel/RTC clock. A machine without an RTC must still
|
||||
// create files newer than immutable image inputs; no time server is awaited.
|
||||
clock_floor()
|
||||
}
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
version,
|
||||
about = "Record and compare measured boot events",
|
||||
after_help = "Measurements use Linux CLOCK_BOOTTIME, excluding firmware and power-on."
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Action>,
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum Action {
|
||||
/// Import stage0 events and record native s6 startup (root only).
|
||||
Adopt,
|
||||
/// Advance an older clock to the image timestamp (root only).
|
||||
ClockFloor,
|
||||
/// Record a named boot event.
|
||||
Mark {
|
||||
#[arg(value_parser = Point::parse)]
|
||||
event: Point,
|
||||
},
|
||||
/// Show this boot's events and durations.
|
||||
Report {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Compare reports; a regression over 100 ms requires an explanation.
|
||||
Compare {
|
||||
baseline: PathBuf,
|
||||
current: PathBuf,
|
||||
#[arg(long, value_parser = explanation)]
|
||||
explain: Option<String>,
|
||||
},
|
||||
}
|
||||
fn explanation(value: &str) -> std::result::Result<String, String> {
|
||||
if value.trim().is_empty() {
|
||||
Err("An explanation must not be blank".into())
|
||||
} else {
|
||||
Ok(value.into())
|
||||
}
|
||||
}
|
||||
fn run() -> Result<()> {
|
||||
match Cli::parse().command {
|
||||
None => {
|
||||
Cli::command().print_help()?;
|
||||
println!();
|
||||
}
|
||||
Some(Action::Adopt) => adopt()?,
|
||||
Some(Action::ClockFloor) => clock_floor()?,
|
||||
Some(Action::Mark { event: point }) => {
|
||||
let directory = if point == Point::ConsoleReady {
|
||||
let uid = unsafe { libc::geteuid() };
|
||||
if uid != 0 && uid != 1000 {
|
||||
return Err(Error("Console readiness requires the FDS user".into()));
|
||||
}
|
||||
Path::new(trace::RUNTIME).join("console")
|
||||
} else {
|
||||
root()?;
|
||||
Path::new(trace::RUNTIME).to_owned()
|
||||
};
|
||||
trace::save(&directory, point, trace::now()?)?;
|
||||
}
|
||||
Some(Action::Report { json }) => trace::load(Path::new(trace::RUNTIME))?.print(json)?,
|
||||
Some(Action::Compare {
|
||||
baseline,
|
||||
current,
|
||||
explain,
|
||||
}) => compare(&baseline, ¤t, explain.as_deref())?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn compare(baseline: &Path, current: &Path, explanation: Option<&str>) -> Result<()> {
|
||||
let load = |path| -> Result<Report> {
|
||||
serde_json::from_str(&read_text(path, 65536)?).map_err(|e| Error(e.to_string()))
|
||||
};
|
||||
let (before, after) = (load(baseline)?, load(current)?);
|
||||
if before.format != 1
|
||||
|| after.format != 1
|
||||
|| before.clock != after.clock
|
||||
|| before.platform != after.platform
|
||||
{
|
||||
return Err(Error(
|
||||
"Compare reports from the same platform and clock".into(),
|
||||
));
|
||||
}
|
||||
let elapsed = |report: &Report| {
|
||||
report
|
||||
.durations_ns
|
||||
.get("kernel-to-console")
|
||||
.copied()
|
||||
.ok_or_else(|| Error("Console readiness was not recorded".into()))
|
||||
};
|
||||
let delta = i128::from(elapsed(&after)?) - i128::from(elapsed(&before)?);
|
||||
println!(
|
||||
"KERNEL TO CONSOLE CHANGE: {:+.3} ms",
|
||||
delta as f64 / 1_000_000.0
|
||||
);
|
||||
if let Some(reason) = explanation {
|
||||
println!("EXPLANATION: {reason}");
|
||||
}
|
||||
if delta > 100_000_000 && explanation.is_none() {
|
||||
return Err(Error(
|
||||
"Regression exceeds 100 ms; fix it or record --explain REASON".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
eprintln!("fds-boottrace: {error}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cli_tests {
|
||||
use super::*;
|
||||
use clap::{CommandFactory, Parser};
|
||||
#[test]
|
||||
fn typed_command_contract() {
|
||||
Cli::command().debug_assert();
|
||||
assert!(Cli::try_parse_from(["fds-boottrace", "mark", "console-ready"]).is_ok());
|
||||
assert!(Cli::try_parse_from(["fds-boottrace", "mark", "unknown"]).is_err());
|
||||
assert!(Cli::try_parse_from(["fds-boottrace", "report", "--json"]).is_ok());
|
||||
assert!(
|
||||
Cli::try_parse_from([
|
||||
"fds-boottrace",
|
||||
"compare",
|
||||
"before",
|
||||
"after",
|
||||
"--explain",
|
||||
"measured change"
|
||||
])
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
Cli::try_parse_from([
|
||||
"fds-boottrace",
|
||||
"compare",
|
||||
"before",
|
||||
"after",
|
||||
"--explain",
|
||||
" "
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
assert!(Cli::try_parse_from(["fds-boottrace", "clock-floor", "--json"]).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user