1use gunnar_proto::{FilesystemUse, GlanceLine};
20
21pub const OK: &str = "ok";
22pub const WARN: &str = "warn";
23pub const BAD: &str = "bad";
24pub const UNKNOWN: &str = "unknown";
25
26pub const WARN_USED_PERCENT: u64 = 80;
29pub const BAD_USED_PERCENT: u64 = 90;
30
31#[derive(Debug, Clone, Default)]
34pub struct Facts<'a> {
35 pub record_present: bool,
37 pub data_label: &'a str,
39 pub data_shape: &'a str,
41 pub data_devices: &'a [String],
43 pub serialdat_label: &'a str,
45 pub serialdat_class: &'a str,
47 pub serialdat_device: &'a str,
49 pub off_stripe: &'a str,
51 pub off_stripe_health: &'a [String],
52 pub data_fs: Option<&'a FilesystemUse>,
54 pub disk: Option<&'a crate::disktest::Outcome>,
56}
57
58pub fn human(n: u64) -> String {
60 const K: f64 = 1024.0;
61 let v = n as f64;
62 if v >= K * K * K * K {
63 format!("{:.1} TiB", v / (K * K * K * K))
64 } else if v >= K * K * K {
65 format!("{:.1} GiB", v / (K * K * K))
66 } else if v >= K * K {
67 format!("{:.1} MiB", v / (K * K))
68 } else if v >= K {
69 format!("{:.1} KiB", v / K)
70 } else {
71 format!("{n} B")
72 }
73}
74
75fn line(key: &str, line: String, state: &str, detail: String) -> GlanceLine {
76 GlanceLine { key: key.to_owned(), line, state: state.to_owned(), detail }
77}
78
79pub fn data_line(f: &Facts) -> GlanceLine {
81 let label = f.data_label;
82 if !f.record_present {
83 return line(
84 "data",
85 format!("{label}: shape not recorded"),
86 UNKNOWN,
87 "This box has no layout config (or it did not verify), so the installer's shape of the \
88 data set is not known — and it is not guessed from what happens to be mounted."
89 .into(),
90 );
91 }
92 let devices = f.data_devices.join(", ");
93 match f.data_shape {
94 "raid0" if f.data_devices.len() >= 2 => line(
95 "data",
96 format!("{label} = raid0 over {devices}"),
97 OK,
98 "The repositories are on the data stripe, laid by the installer over the data disks."
99 .into(),
100 ),
101 "plain" | "raid0" if !f.data_devices.is_empty() => line(
102 "data",
103 format!("{label} = {} on {devices}", f.data_shape),
104 OK,
105 "The repositories are on a data disk of their own (one disk, no stripe).".into(),
106 ),
107 "p3" => line(
108 "data",
109 format!("{label} = p3 on the boot disk"),
110 WARN,
111 "No data disk was found at install time: the repositories live on the boot disk's \
112 third partition, beside the system."
113 .into(),
114 ),
115 "" => line(
116 "data",
117 format!("{label}: the layout config names no shape"),
118 UNKNOWN,
119 "The config carries no `data.shape`.".into(),
120 ),
121 other => line(
122 "data",
123 if devices.is_empty() { format!("{label} = {other}") } else { format!("{label} = {other} over {devices}") },
124 UNKNOWN,
125 format!("`data.shape = {other}` is a shape this build has no verdict for."),
126 ),
127 }
128}
129
130pub fn serialdat_line(f: &Facts) -> GlanceLine {
132 let label = f.serialdat_label;
133 let dev = f.serialdat_device;
134 let text = match (f.serialdat_class, dev) {
135 ("p3", "") => format!("{label} on the boot disk (P3)"),
136 ("p3", dev) => format!("{label} on {dev} (boot disk P3)"),
137 ("device", "") => format!("{label} on a disk of its own"),
138 ("device", dev) => format!("{label} on {dev} (its own disk)"),
139 ("fallback", _) => format!("{label} is a directory on the data set (no partition of its own)"),
140 _ if label.is_empty() => "/serialdat: nothing said where it lives".to_owned(),
141 _ => format!("{label}: nothing said what disk it is on"),
142 };
143 let state = match f.off_stripe {
144 "off-stripe" => OK,
145 "same-device" | "absent" => BAD,
146 _ => UNKNOWN,
147 };
148 let verdict = match f.off_stripe {
149 "off-stripe" => " — the off-stripe copies are on another failure domain",
150 "same-device" => " — the off-stripe copies share a device with the data (no second failure domain)",
151 "absent" => " — there is no off-stripe copy",
152 _ => "",
153 };
154 line("serialdat", format!("{text}{verdict}"), state, f.off_stripe_health.join(" "))
155}
156
157pub fn free_line(f: &Facts) -> GlanceLine {
159 let label = f.data_label;
160 match f.data_fs {
161 Some(fs) if fs.measured && fs.total_bytes > 0 => {
162 let used = fs.total_bytes.saturating_sub(fs.free_bytes);
163 let pct = used.saturating_mul(100) / fs.total_bytes;
164 let state = if pct >= BAD_USED_PERCENT {
165 BAD
166 } else if pct >= WARN_USED_PERCENT {
167 WARN
168 } else {
169 OK
170 };
171 line(
172 "free",
173 format!("{label}: {} free of {} ({pct} % used)", human(fs.free_bytes), human(fs.total_bytes)),
174 state,
175 format!(
176 "Amber from {WARN_USED_PERCENT} % used, red from {BAD_USED_PERCENT} % — the marks the \
177 estate's growth acts on."
178 ),
179 )
180 }
181 Some(fs) => line(
182 "free",
183 format!("{label}: free space not measured"),
184 UNKNOWN,
185 if fs.error.is_empty() { "statvfs answered no size".to_owned() } else { fs.error.clone() },
186 ),
187 None => line("free", format!("{label}: free space not measured"), UNKNOWN, String::new()),
188 }
189}
190
191pub fn speed_line(f: &Facts) -> GlanceLine {
193 use crate::disktest::Outcome;
194 match f.disk {
195 Some(Outcome::Done(r)) => line(
196 "speed",
197 format!("disk speed: {}", r.row()),
198 OK,
199 "Measured once at start-up on the data root: sequential write and read, and 4 KiB \
200 writes each followed by fsync."
201 .into(),
202 ),
203 Some(Outcome::Skipped { free, need }) => line(
204 "speed",
205 format!("disk speed: not measured — {} free, the test needs {}", human(*free), human(*need)),
206 WARN,
207 "The start-up test never fills the disk it measures.".into(),
208 ),
209 Some(Outcome::Failed(why)) => line("speed", format!("disk speed: the test failed — {why}"), BAD, String::new()),
210 None => line(
211 "speed",
212 "disk speed: not measured yet".to_owned(),
213 UNKNOWN,
214 "The start-up disk test has not finished a run in this process (it runs on the \
215 appliance, or with GUNNAR_DISKTEST set)."
216 .into(),
217 ),
218 }
219}
220
221pub fn lines(f: &Facts) -> Vec<GlanceLine> {
223 vec![data_line(f), serialdat_line(f), free_line(f), speed_line(f)]
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::disktest::{Outcome, ReadHow, Report};
230 use std::time::Duration;
231
232 fn devs(d: &[&str]) -> Vec<String> {
233 d.iter().map(|s| s.to_string()).collect()
234 }
235
236 fn estate<'a>(devices: &'a [String], fs: &'a FilesystemUse) -> Facts<'a> {
237 Facts {
238 record_present: true,
239 data_label: "/data",
240 data_shape: "raid0",
241 data_devices: devices,
242 serialdat_label: "/serialdat",
243 serialdat_class: "p3",
244 serialdat_device: "/dev/vda3",
245 off_stripe: "off-stripe",
246 off_stripe_health: &[],
247 data_fs: Some(fs),
248 disk: None,
249 }
250 }
251
252 fn fs(total: u64, free: u64) -> FilesystemUse {
253 FilesystemUse { what: "data root".into(), path: "/data".into(), measured: true, total_bytes: total, free_bytes: free, error: String::new() }
254 }
255
256 #[test]
259 fn data_names_its_own_devices_and_serialdat_names_the_boot_disk() {
260 let d = devs(&["/dev/vdb", "/dev/vdc"]);
261 let fs = fs(100 << 30, 60 << 30);
262 let f = estate(&d, &fs);
263 let data = data_line(&f);
264 assert_eq!((data.line.as_str(), data.state.as_str()), ("/data = raid0 over /dev/vdb, /dev/vdc", "ok"));
265 assert!(!data.line.contains("vda3"), "the data line must not name the boot disk: {}", data.line);
266 let sd = serialdat_line(&f);
267 assert!(sd.line.starts_with("/serialdat on /dev/vda3 (boot disk P3)"), "{}", sd.line);
268 assert_eq!(sd.state, "ok");
269 let keys: Vec<_> = lines(&f).into_iter().map(|l| l.key).collect();
270 assert_eq!(keys, ["data", "serialdat", "free", "speed"]);
271 }
272
273 #[test]
276 fn the_off_stripe_copies_are_green_only_when_off_stripe() {
277 let d = devs(&["/dev/vdb"]);
278 let fs = fs(1, 1);
279 for (word, state) in [("off-stripe", "ok"), ("same-device", "bad"), ("absent", "bad"), ("", "unknown"), ("weird", "unknown")] {
280 let mut f = estate(&d, &fs);
281 f.off_stripe = word;
282 assert_eq!(serialdat_line(&f).state, state, "{word}");
283 }
284 }
285
286 #[test]
287 fn the_data_shapes_each_have_their_words() {
288 let one = devs(&["/dev/vdb"]);
289 let none: Vec<String> = Vec::new();
290 let fs = fs(1, 1);
291 let mut f = estate(&one, &fs);
292 f.data_shape = "plain";
293 assert_eq!((data_line(&f).line.as_str(), data_line(&f).state.as_str()), ("/data = plain on /dev/vdb", "ok"));
294 let mut f = estate(&none, &fs);
295 f.data_shape = "p3";
296 assert_eq!(data_line(&f).state, "warn");
297 f.record_present = false;
298 assert_eq!(data_line(&f).state, "unknown", "no record: not guessed");
299 }
300
301 #[test]
304 fn free_space_turns_amber_then_red_and_unmeasured_is_unknown() {
305 let d = devs(&["/dev/vdb", "/dev/vdc"]);
306 for (free, state) in [(50u64, "ok"), (19, "warn"), (5, "bad")] {
307 let fs = fs(100, free);
308 assert_eq!(free_line(&estate(&d, &fs)).state, state, "{free} free of 100");
309 }
310 let broken = FilesystemUse { measured: false, error: "EIO".into(), ..fs(0, 0) };
311 let l = free_line(&estate(&d, &broken));
312 assert_eq!(l.state, "unknown");
313 assert!(!l.line.chars().any(|c| c.is_ascii_digit()), "no number that was not read: {}", l.line);
314 }
315
316 #[test]
317 fn the_disk_speed_is_green_only_when_measured() {
318 let d = devs(&["/dev/vdb", "/dev/vdc"]);
319 let fs = fs(1, 1);
320 let mut f = estate(&d, &fs);
321 assert_eq!(speed_line(&f).state, "unknown");
322 let done = Outcome::Done(Report {
323 seq_write_bps: 8.12e8,
324 seq_read_bps: 1.0e9,
325 read_how: ReadHow::Direct,
326 sync_ops_per_s: 3100.0,
327 elapsed: Duration::from_millis(1600),
328 file_bytes: 1 << 30,
329 write_elapsed: Duration::from_millis(800),
330 read_elapsed: Duration::from_millis(500),
331 sync_ops: 4960,
332 sync_elapsed: Duration::from_millis(300),
333 });
334 f.disk = Some(&done);
335 let l = speed_line(&f);
336 assert_eq!(l.state, "ok");
337 assert!(l.line.contains("812 MB/s"), "{}", l.line);
338 let failed = Outcome::Failed("EIO".into());
339 f.disk = Some(&failed);
340 assert_eq!(speed_line(&f).state, "bad");
341 }
342}