use gunnar_proto::{FilesystemUse, GlanceLine};
pub const OK: &str = "ok";
pub const WARN: &str = "warn";
pub const BAD: &str = "bad";
pub const UNKNOWN: &str = "unknown";
pub const WARN_USED_PERCENT: u64 = 80;
pub const BAD_USED_PERCENT: u64 = 90;
#[derive(Debug, Clone, Default)]
pub struct Facts<'a> {
pub record_present: bool,
pub data_label: &'a str,
pub data_shape: &'a str,
pub data_devices: &'a [String],
pub serialdat_label: &'a str,
pub serialdat_class: &'a str,
pub serialdat_device: &'a str,
pub off_stripe: &'a str,
pub off_stripe_health: &'a [String],
pub data_fs: Option<&'a FilesystemUse>,
pub disk: Option<&'a crate::disktest::Outcome>,
}
pub fn human(n: u64) -> String {
const K: f64 = 1024.0;
let v = n as f64;
if v >= K * K * K * K {
format!("{:.1} TiB", v / (K * K * K * K))
} else if v >= K * K * K {
format!("{:.1} GiB", v / (K * K * K))
} else if v >= K * K {
format!("{:.1} MiB", v / (K * K))
} else if v >= K {
format!("{:.1} KiB", v / K)
} else {
format!("{n} B")
}
}
fn line(key: &str, line: String, state: &str, detail: String) -> GlanceLine {
GlanceLine { key: key.to_owned(), line, state: state.to_owned(), detail }
}
pub fn data_line(f: &Facts) -> GlanceLine {
let label = f.data_label;
if !f.record_present {
return line(
"data",
format!("{label}: shape not recorded"),
UNKNOWN,
"This box has no layout config (or it did not verify), so the installer's shape of the \
data set is not known — and it is not guessed from what happens to be mounted."
.into(),
);
}
let devices = f.data_devices.join(", ");
match f.data_shape {
"raid0" if f.data_devices.len() >= 2 => line(
"data",
format!("{label} = raid0 over {devices}"),
OK,
"The repositories are on the data stripe, laid by the installer over the data disks."
.into(),
),
"plain" | "raid0" if !f.data_devices.is_empty() => line(
"data",
format!("{label} = {} on {devices}", f.data_shape),
OK,
"The repositories are on a data disk of their own (one disk, no stripe).".into(),
),
"p3" => line(
"data",
format!("{label} = p3 on the boot disk"),
WARN,
"No data disk was found at install time: the repositories live on the boot disk's \
third partition, beside the system."
.into(),
),
"" => line(
"data",
format!("{label}: the layout config names no shape"),
UNKNOWN,
"The config carries no `data.shape`.".into(),
),
other => line(
"data",
if devices.is_empty() { format!("{label} = {other}") } else { format!("{label} = {other} over {devices}") },
UNKNOWN,
format!("`data.shape = {other}` is a shape this build has no verdict for."),
),
}
}
pub fn serialdat_line(f: &Facts) -> GlanceLine {
let label = f.serialdat_label;
let dev = f.serialdat_device;
let text = match (f.serialdat_class, dev) {
("p3", "") => format!("{label} on the boot disk (P3)"),
("p3", dev) => format!("{label} on {dev} (boot disk P3)"),
("device", "") => format!("{label} on a disk of its own"),
("device", dev) => format!("{label} on {dev} (its own disk)"),
("fallback", _) => format!("{label} is a directory on the data set (no partition of its own)"),
_ if label.is_empty() => "/serialdat: nothing said where it lives".to_owned(),
_ => format!("{label}: nothing said what disk it is on"),
};
let state = match f.off_stripe {
"off-stripe" => OK,
"same-device" | "absent" => BAD,
_ => UNKNOWN,
};
let verdict = match f.off_stripe {
"off-stripe" => " — the off-stripe copies are on another failure domain",
"same-device" => " — the off-stripe copies share a device with the data (no second failure domain)",
"absent" => " — there is no off-stripe copy",
_ => "",
};
line("serialdat", format!("{text}{verdict}"), state, f.off_stripe_health.join(" "))
}
pub fn free_line(f: &Facts) -> GlanceLine {
let label = f.data_label;
match f.data_fs {
Some(fs) if fs.measured && fs.total_bytes > 0 => {
let used = fs.total_bytes.saturating_sub(fs.free_bytes);
let pct = used.saturating_mul(100) / fs.total_bytes;
let state = if pct >= BAD_USED_PERCENT {
BAD
} else if pct >= WARN_USED_PERCENT {
WARN
} else {
OK
};
line(
"free",
format!("{label}: {} free of {} ({pct} % used)", human(fs.free_bytes), human(fs.total_bytes)),
state,
format!(
"Amber from {WARN_USED_PERCENT} % used, red from {BAD_USED_PERCENT} % — the marks the \
estate's growth acts on."
),
)
}
Some(fs) => line(
"free",
format!("{label}: free space not measured"),
UNKNOWN,
if fs.error.is_empty() { "statvfs answered no size".to_owned() } else { fs.error.clone() },
),
None => line("free", format!("{label}: free space not measured"), UNKNOWN, String::new()),
}
}
pub fn speed_line(f: &Facts) -> GlanceLine {
use crate::disktest::Outcome;
match f.disk {
Some(Outcome::Done(r)) => line(
"speed",
format!("disk speed: {}", r.row()),
OK,
"Measured once at start-up on the data root: sequential write and read, and 4 KiB \
writes each followed by fsync."
.into(),
),
Some(Outcome::Skipped { free, need }) => line(
"speed",
format!("disk speed: not measured — {} free, the test needs {}", human(*free), human(*need)),
WARN,
"The start-up test never fills the disk it measures.".into(),
),
Some(Outcome::Failed(why)) => line("speed", format!("disk speed: the test failed — {why}"), BAD, String::new()),
None => line(
"speed",
"disk speed: not measured yet".to_owned(),
UNKNOWN,
"The start-up disk test has not finished a run in this process (it runs on the \
appliance, or with GUNNAR_DISKTEST set)."
.into(),
),
}
}
pub fn lines(f: &Facts) -> Vec<GlanceLine> {
vec![data_line(f), serialdat_line(f), free_line(f), speed_line(f)]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::disktest::{Outcome, ReadHow, Report};
use std::time::Duration;
fn devs(d: &[&str]) -> Vec<String> {
d.iter().map(|s| s.to_string()).collect()
}
fn estate<'a>(devices: &'a [String], fs: &'a FilesystemUse) -> Facts<'a> {
Facts {
record_present: true,
data_label: "/data",
data_shape: "raid0",
data_devices: devices,
serialdat_label: "/serialdat",
serialdat_class: "p3",
serialdat_device: "/dev/vda3",
off_stripe: "off-stripe",
off_stripe_health: &[],
data_fs: Some(fs),
disk: None,
}
}
fn fs(total: u64, free: u64) -> FilesystemUse {
FilesystemUse { what: "data root".into(), path: "/data".into(), measured: true, total_bytes: total, free_bytes: free, error: String::new() }
}
#[test]
fn data_names_its_own_devices_and_serialdat_names_the_boot_disk() {
let d = devs(&["/dev/vdb", "/dev/vdc"]);
let fs = fs(100 << 30, 60 << 30);
let f = estate(&d, &fs);
let data = data_line(&f);
assert_eq!((data.line.as_str(), data.state.as_str()), ("/data = raid0 over /dev/vdb, /dev/vdc", "ok"));
assert!(!data.line.contains("vda3"), "the data line must not name the boot disk: {}", data.line);
let sd = serialdat_line(&f);
assert!(sd.line.starts_with("/serialdat on /dev/vda3 (boot disk P3)"), "{}", sd.line);
assert_eq!(sd.state, "ok");
let keys: Vec<_> = lines(&f).into_iter().map(|l| l.key).collect();
assert_eq!(keys, ["data", "serialdat", "free", "speed"]);
}
#[test]
fn the_off_stripe_copies_are_green_only_when_off_stripe() {
let d = devs(&["/dev/vdb"]);
let fs = fs(1, 1);
for (word, state) in [("off-stripe", "ok"), ("same-device", "bad"), ("absent", "bad"), ("", "unknown"), ("weird", "unknown")] {
let mut f = estate(&d, &fs);
f.off_stripe = word;
assert_eq!(serialdat_line(&f).state, state, "{word}");
}
}
#[test]
fn the_data_shapes_each_have_their_words() {
let one = devs(&["/dev/vdb"]);
let none: Vec<String> = Vec::new();
let fs = fs(1, 1);
let mut f = estate(&one, &fs);
f.data_shape = "plain";
assert_eq!((data_line(&f).line.as_str(), data_line(&f).state.as_str()), ("/data = plain on /dev/vdb", "ok"));
let mut f = estate(&none, &fs);
f.data_shape = "p3";
assert_eq!(data_line(&f).state, "warn");
f.record_present = false;
assert_eq!(data_line(&f).state, "unknown", "no record: not guessed");
}
#[test]
fn free_space_turns_amber_then_red_and_unmeasured_is_unknown() {
let d = devs(&["/dev/vdb", "/dev/vdc"]);
for (free, state) in [(50u64, "ok"), (19, "warn"), (5, "bad")] {
let fs = fs(100, free);
assert_eq!(free_line(&estate(&d, &fs)).state, state, "{free} free of 100");
}
let broken = FilesystemUse { measured: false, error: "EIO".into(), ..fs(0, 0) };
let l = free_line(&estate(&d, &broken));
assert_eq!(l.state, "unknown");
assert!(!l.line.chars().any(|c| c.is_ascii_digit()), "no number that was not read: {}", l.line);
}
#[test]
fn the_disk_speed_is_green_only_when_measured() {
let d = devs(&["/dev/vdb", "/dev/vdc"]);
let fs = fs(1, 1);
let mut f = estate(&d, &fs);
assert_eq!(speed_line(&f).state, "unknown");
let done = Outcome::Done(Report {
seq_write_bps: 8.12e8,
seq_read_bps: 1.0e9,
read_how: ReadHow::Direct,
sync_ops_per_s: 3100.0,
elapsed: Duration::from_millis(1600),
file_bytes: 1 << 30,
write_elapsed: Duration::from_millis(800),
read_elapsed: Duration::from_millis(500),
sync_ops: 4960,
sync_elapsed: Duration::from_millis(300),
});
f.disk = Some(&done);
let l = speed_line(&f);
assert_eq!(l.state, "ok");
assert!(l.line.contains("812 MB/s"), "{}", l.line);
let failed = Outcome::Failed("EIO".into());
f.disk = Some(&failed);
assert_eq!(speed_line(&f).state, "bad");
}
}