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 {
77 key: key.to_owned(),
78 line,
79 state: state.to_owned(),
80 detail,
81 }
82}
83
84pub fn data_line(f: &Facts) -> GlanceLine {
86 let label = f.data_label;
87 if !f.record_present {
88 return line(
89 "data",
90 format!("{label}: shape not recorded"),
91 UNKNOWN,
92 "This box has no layout config (or it did not verify), so the installer's shape of the \
93 data set is not known — and it is not guessed from what happens to be mounted."
94 .into(),
95 );
96 }
97 let devices = f.data_devices.join(", ");
98 match f.data_shape {
99 "raid0" if f.data_devices.len() >= 2 => line(
100 "data",
101 format!("{label} = raid0 over {devices}"),
102 OK,
103 "The repositories are on the data stripe, laid by the installer over the data disks."
104 .into(),
105 ),
106 "plain" | "raid0" if !f.data_devices.is_empty() => line(
107 "data",
108 format!("{label} = {} on {devices}", f.data_shape),
109 OK,
110 "The repositories are on a data disk of their own (one disk, no stripe).".into(),
111 ),
112 "p3" => line(
113 "data",
114 format!("{label} = p3 on the boot disk"),
115 WARN,
116 "No data disk was found at install time: the repositories live on the boot disk's \
117 third partition, beside the system."
118 .into(),
119 ),
120 "" => line(
121 "data",
122 format!("{label}: the layout config names no shape"),
123 UNKNOWN,
124 "The config carries no `data.shape`.".into(),
125 ),
126 other => line(
127 "data",
128 if devices.is_empty() {
129 format!("{label} = {other}")
130 } else {
131 format!("{label} = {other} over {devices}")
132 },
133 UNKNOWN,
134 format!("`data.shape = {other}` is a shape this build has no verdict for."),
135 ),
136 }
137}
138
139pub fn serialdat_line(f: &Facts) -> GlanceLine {
141 let label = f.serialdat_label;
142 let dev = f.serialdat_device;
143 let text = match (f.serialdat_class, dev) {
144 ("p3", "") => format!("{label} on the boot disk (P3)"),
145 ("p3", dev) => format!("{label} on {dev} (boot disk P3)"),
146 ("device", "") => format!("{label} on a disk of its own"),
147 ("device", dev) => format!("{label} on {dev} (its own disk)"),
148 ("fallback", _) => {
149 format!("{label} is a directory on the data set (no partition of its own)")
150 }
151 _ if label.is_empty() => "/serialdat: nothing said where it lives".to_owned(),
152 _ => format!("{label}: nothing said what disk it is on"),
153 };
154 let state = match f.off_stripe {
155 "off-stripe" => OK,
156 "same-device" | "absent" => BAD,
157 _ => UNKNOWN,
158 };
159 let verdict = match f.off_stripe {
160 "off-stripe" => " — the off-stripe copies are on another failure domain",
161 "same-device" => {
162 " — the off-stripe copies share a device with the data (no second failure domain)"
163 }
164 "absent" => " — there is no off-stripe copy",
165 _ => "",
166 };
167 line(
168 "serialdat",
169 format!("{text}{verdict}"),
170 state,
171 f.off_stripe_health.join(" "),
172 )
173}
174
175pub fn free_line(f: &Facts) -> GlanceLine {
177 let label = f.data_label;
178 match f.data_fs {
179 Some(fs) if fs.measured && fs.total_bytes > 0 => {
180 let used = fs.total_bytes.saturating_sub(fs.free_bytes);
181 let pct = used.saturating_mul(100) / fs.total_bytes;
182 let state = if pct >= BAD_USED_PERCENT {
183 BAD
184 } else if pct >= WARN_USED_PERCENT {
185 WARN
186 } else {
187 OK
188 };
189 line(
190 "free",
191 format!("{label}: {} free of {} ({pct} % used)", human(fs.free_bytes), human(fs.total_bytes)),
192 state,
193 format!(
194 "Amber from {WARN_USED_PERCENT} % used, red from {BAD_USED_PERCENT} % — the marks the \
195 estate's growth acts on."
196 ),
197 )
198 }
199 Some(fs) => line(
200 "free",
201 format!("{label}: free space not measured"),
202 UNKNOWN,
203 if fs.error.is_empty() {
204 "statvfs answered no size".to_owned()
205 } else {
206 fs.error.clone()
207 },
208 ),
209 None => line(
210 "free",
211 format!("{label}: free space not measured"),
212 UNKNOWN,
213 String::new(),
214 ),
215 }
216}
217
218pub fn speed_line(f: &Facts) -> GlanceLine {
220 use crate::disktest::Outcome;
221 match f.disk {
222 Some(Outcome::Done(r)) => line(
223 "speed",
224 format!("disk speed: {}", r.row()),
225 OK,
226 "Measured once at start-up on the data root: sequential write and read, and 4 KiB \
227 writes each followed by fsync."
228 .into(),
229 ),
230 Some(Outcome::Skipped { free, need }) => line(
231 "speed",
232 format!(
233 "disk speed: not measured — {} free, the test needs {}",
234 human(*free),
235 human(*need)
236 ),
237 WARN,
238 "The start-up test never fills the disk it measures.".into(),
239 ),
240 Some(Outcome::Failed(why)) => line(
241 "speed",
242 format!("disk speed: the test failed — {why}"),
243 BAD,
244 String::new(),
245 ),
246 None => line(
247 "speed",
248 "disk speed: not measured yet".to_owned(),
249 UNKNOWN,
250 "The start-up disk test has not finished a run in this process (it runs on the \
251 appliance, or with GUNNAR_DISKTEST set)."
252 .into(),
253 ),
254 }
255}
256
257pub fn lines(f: &Facts) -> Vec<GlanceLine> {
259 vec![data_line(f), serialdat_line(f), free_line(f), speed_line(f)]
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use crate::disktest::{Outcome, ReadHow, Report};
266 use std::time::Duration;
267
268 fn devs(d: &[&str]) -> Vec<String> {
269 d.iter().map(|s| s.to_string()).collect()
270 }
271
272 fn estate<'a>(devices: &'a [String], fs: &'a FilesystemUse) -> Facts<'a> {
273 Facts {
274 record_present: true,
275 data_label: "/data",
276 data_shape: "raid0",
277 data_devices: devices,
278 serialdat_label: "/serialdat",
279 serialdat_class: "p3",
280 serialdat_device: "/dev/vda3",
281 off_stripe: "off-stripe",
282 off_stripe_health: &[],
283 data_fs: Some(fs),
284 disk: None,
285 }
286 }
287
288 fn fs(total: u64, free: u64) -> FilesystemUse {
289 FilesystemUse {
290 what: "data root".into(),
291 path: "/data".into(),
292 measured: true,
293 total_bytes: total,
294 free_bytes: free,
295 error: String::new(),
296 }
297 }
298
299 #[test]
302 fn data_names_its_own_devices_and_serialdat_names_the_boot_disk() {
303 let d = devs(&["/dev/vdb", "/dev/vdc"]);
304 let fs = fs(100 << 30, 60 << 30);
305 let f = estate(&d, &fs);
306 let data = data_line(&f);
307 assert_eq!(
308 (data.line.as_str(), data.state.as_str()),
309 ("/data = raid0 over /dev/vdb, /dev/vdc", "ok")
310 );
311 assert!(
312 !data.line.contains("vda3"),
313 "the data line must not name the boot disk: {}",
314 data.line
315 );
316 let sd = serialdat_line(&f);
317 assert!(
318 sd.line
319 .starts_with("/serialdat on /dev/vda3 (boot disk P3)"),
320 "{}",
321 sd.line
322 );
323 assert_eq!(sd.state, "ok");
324 let keys: Vec<_> = lines(&f).into_iter().map(|l| l.key).collect();
325 assert_eq!(keys, ["data", "serialdat", "free", "speed"]);
326 }
327
328 #[test]
331 fn the_off_stripe_copies_are_green_only_when_off_stripe() {
332 let d = devs(&["/dev/vdb"]);
333 let fs = fs(1, 1);
334 for (word, state) in [
335 ("off-stripe", "ok"),
336 ("same-device", "bad"),
337 ("absent", "bad"),
338 ("", "unknown"),
339 ("weird", "unknown"),
340 ] {
341 let mut f = estate(&d, &fs);
342 f.off_stripe = word;
343 assert_eq!(serialdat_line(&f).state, state, "{word}");
344 }
345 }
346
347 #[test]
348 fn the_data_shapes_each_have_their_words() {
349 let one = devs(&["/dev/vdb"]);
350 let none: Vec<String> = Vec::new();
351 let fs = fs(1, 1);
352 let mut f = estate(&one, &fs);
353 f.data_shape = "plain";
354 assert_eq!(
355 (data_line(&f).line.as_str(), data_line(&f).state.as_str()),
356 ("/data = plain on /dev/vdb", "ok")
357 );
358 let mut f = estate(&none, &fs);
359 f.data_shape = "p3";
360 assert_eq!(data_line(&f).state, "warn");
361 f.record_present = false;
362 assert_eq!(data_line(&f).state, "unknown", "no record: not guessed");
363 }
364
365 #[test]
368 fn free_space_turns_amber_then_red_and_unmeasured_is_unknown() {
369 let d = devs(&["/dev/vdb", "/dev/vdc"]);
370 for (free, state) in [(50u64, "ok"), (19, "warn"), (5, "bad")] {
371 let fs = fs(100, free);
372 assert_eq!(
373 free_line(&estate(&d, &fs)).state,
374 state,
375 "{free} free of 100"
376 );
377 }
378 let broken = FilesystemUse {
379 measured: false,
380 error: "EIO".into(),
381 ..fs(0, 0)
382 };
383 let l = free_line(&estate(&d, &broken));
384 assert_eq!(l.state, "unknown");
385 assert!(
386 !l.line.chars().any(|c| c.is_ascii_digit()),
387 "no number that was not read: {}",
388 l.line
389 );
390 }
391
392 #[test]
393 fn the_disk_speed_is_green_only_when_measured() {
394 let d = devs(&["/dev/vdb", "/dev/vdc"]);
395 let fs = fs(1, 1);
396 let mut f = estate(&d, &fs);
397 assert_eq!(speed_line(&f).state, "unknown");
398 let done = Outcome::Done(Report {
399 seq_write_bps: 8.12e8,
400 seq_read_bps: 1.0e9,
401 read_how: ReadHow::Direct,
402 sync_ops_per_s: 3100.0,
403 elapsed: Duration::from_millis(1600),
404 file_bytes: 1 << 30,
405 write_elapsed: Duration::from_millis(800),
406 read_elapsed: Duration::from_millis(500),
407 sync_ops: 4960,
408 sync_elapsed: Duration::from_millis(300),
409 });
410 f.disk = Some(&done);
411 let l = speed_line(&f);
412 assert_eq!(l.state, "ok");
413 assert!(l.line.contains("812 MB/s"), "{}", l.line);
414 let failed = Outcome::Failed("EIO".into());
415 f.disk = Some(&failed);
416 assert_eq!(speed_line(&f).state, "bad");
417 }
418}