1use std::path::{Path, PathBuf};
80
81use gunnar_proto::{CapacityResponse, CapacityVerdict, SetGrowth};
82use gunnar_ssh::{headroom::human, Headroom};
83
84use crate::dashboard::probe::{self, DiskKind};
85
86#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct GrowthFacts {
90 pub kind: Option<DiskKind>,
95 pub is_pid1: bool,
100 pub grow: Option<gunnar_grpc::grow_journal::Summary>,
106 pub runtime_engine: bool,
110 pub hotplug: bool,
115}
116
117impl GrowthFacts {
118 pub fn measure(root: &Path) -> GrowthFacts {
120 GrowthFacts {
121 kind: crate::dashboard::probe::disk_kind(root),
122 is_pid1: std::process::id() == 1,
123 grow: gunnar_grpc::grow_journal::Summary::measure(),
124 runtime_engine: cfg!(feature = "grow-runtime"),
125 hotplug: crate::dashboard::probe::pci_hotplug().present,
126 }
127 }
128}
129
130pub fn decide_growth(facts: &GrowthFacts, root: &Path) -> (SetGrowth, String) {
132 let root = root.display();
133 match (&facts.kind, facts.is_pid1) {
134 (None, _) => (
135 SetGrowth::GrowthUnknown,
136 format!(
137 "gunnar cannot tell what {root} sits on — it is not a mount point of its own, \
138 so whether this set can grow is UNKNOWN, which is not a yes"
139 ),
140 ),
141 (Some(kind), true) if facts.grow.is_some() && facts.runtime_engine && facts.hotplug => {
147 let journal = facts.grow.as_ref().expect("guarded");
148 let n = kind.set.as_ref().map(|s| s.members).unwrap_or(1);
149 (
150 SetGrowth::GrowthAtRuntime,
151 format!(
152 "AND THE SET GROWS WITHOUT A RESTART: {root} is a {kind} set, this gunnar-server \
153 links the grow engine, the kernel has PCI hot-plug and the journal is on this box — `Admin.GrowStart` drains \
154 the box, unmounts, waits for ALL {n} members to come back larger at one equal \
155 size, grows in place and serves again in minutes; a crash mid-way is finished \
156 at the next boot. Grow journal: {}",
157 journal.detail()
158 ),
159 )
160 }
161 (Some(kind), true) if facts.grow.is_some() => {
166 let journal = facts.grow.as_ref().expect("guarded");
167 let n = kind.set.as_ref().map(|s| s.members).unwrap_or(1);
168 (
169 SetGrowth::GrowthAtRestart,
170 format!(
171 "AND THE SET GROWS AT RESTART: {root} is a {kind} set and this box's boot stack \
172 (korp-start) grows it into enlarged member volumes at the next boot — enlarge \
173 ALL {n} members to one equal size, restart, and the next measurement reads the \
174 larger set. It does NOT grow while gunnar runs{}, and a disk ATTACHED to this \
175 machine is still invisible to it. Grow journal: {}",
176 if facts.runtime_engine && !facts.hotplug {
177 " (the grow engine is linked, but the running kernel has NO PCI hot-plug: a member detached and re-attached while gunnar runs is invisible, so a runtime growth is refused by name)"
178 } else {
179 ""
180 },
181 journal.detail()
182 ),
183 )
184 }
185 (Some(kind), true) => {
186 let laid = if kind.set.is_some() {
192 " laid once at install (korp-installer `lay_set`, InstallMode::Fresh only)"
193 } else {
194 ""
195 };
196 let engine = if facts.runtime_engine {
211 " (this server links the runtime grow engine, but there is no grow journal on this box for it to continue, so it refuses by name)"
212 } else {
213 ""
214 };
215 (
216 SetGrowth::GrowthSealed,
217 format!(
218 "AND THE SET CANNOT GROW WHILE GUNNAR RUNS: {root} is a {kind} set{laid}, \
219 this box's boot stack keeps no grow journal{engine}, and gunnar is PID 1 \
220 with no shell — so a disk attached to this machine would be UNUSED AND \
221 INVISIBLE. Serving more needs a fresh install onto a larger set, not \
222 another disk"
223 ),
224 )
225 }
226 (Some(kind), false) => (
227 SetGrowth::GrowthOperatorOnly,
228 format!(
229 "gunnar will not grow the set itself here — the grow engine runs only where \
230 gunnar is PID 1 over a set its own boot stack laid, and this process is not \
231 PID 1 — but that is also why an operator with a shell can extend {root} \
232 ({kind}) out of band, and the next measurement will see it"
233 ),
234 ),
235 }
236}
237
238#[derive(Debug, Clone, PartialEq)]
242pub struct MachineFacts {
243 pub cpus: Result<usize, String>,
249 pub memory: Result<(u64, u64), String>,
253 pub cgroup_memory_max: Option<u64>,
257 pub load1: Result<f64, String>,
259 pub disk: Option<crate::disktest::Outcome>,
266}
267
268impl MachineFacts {
269 pub fn measure() -> MachineFacts {
273 let cpus = std::thread::available_parallelism()
274 .map(|n| n.get())
275 .map_err(|err| format!("the affinity mask could not be read ({err})"));
276 let mut memory = probe::meminfo();
277 let mut cgroup_memory_max = None;
278 if let (Ok((total, available)), Some((cap, used))) =
283 (&memory, crate::budget::cgroup_memory())
284 {
285 if cap < *total {
286 cgroup_memory_max = Some(cap);
287 memory = Ok((cap, (*available).min(cap.saturating_sub(used))));
288 }
289 }
290 MachineFacts {
291 cpus,
292 memory,
293 cgroup_memory_max,
294 load1: probe::load1_value(),
295 disk: crate::disktest::last(),
296 }
297 }
298}
299
300pub fn disk_fields(disk: &Option<crate::disktest::Outcome>) -> CapacityResponse {
314 use crate::disktest::{Outcome, CACHED_BPS};
315 use gunnar_proto::DiskTest;
316
317 let bps = |v: f64| -> Option<u64> {
318 (v.is_finite() && v > 0.0).then(|| v.round() as u64)
320 };
321 match disk {
322 None => CapacityResponse {
323 disk_test: DiskTest::DisktestNotRun as i32,
324 disk_reason: "The start-up disk test has not finished a run in this process: its \
325 gate is closed (not the appliance's PID 1 and GUNNAR_DISKTEST unset) \
326 or it is still running. Every disk_* speed field is absent — this is \
327 NOT a report of a slow disk."
328 .to_owned(),
329 ..Default::default()
330 },
331 Some(Outcome::Skipped { free, need }) => CapacityResponse {
332 disk_test: DiskTest::DisktestSkipped as i32,
333 disk_reason: format!(
334 "The start-up disk test SKIPPED: {} free on the data root, below the {} it \
335 needs, and the test must never be the thing that fills the disk. Every disk_* \
336 speed field is absent — nothing was measured.",
337 human(*free),
338 human(*need)
339 ),
340 ..Default::default()
341 },
342 Some(Outcome::Failed(why)) => CapacityResponse {
343 disk_test: DiskTest::DisktestFailed as i32,
344 disk_reason: format!(
345 "The start-up disk test FAILED ({why}). Every disk_* speed field is absent — \
346 the run did not produce numbers."
347 ),
348 ..Default::default()
349 },
350 Some(Outcome::Done(r)) => {
351 let cached = r.seq_read_bps > CACHED_BPS;
352 let mut clauses = Vec::with_capacity(3);
353 let disk_seq_write_bps = bps(r.seq_write_bps);
354 match disk_seq_write_bps {
355 Some(_) => clauses.push(format!(
356 "sequential write {}",
357 crate::disktest::fmt_rate(r.seq_write_bps)
358 )),
359 None => clauses
360 .push("sequential write UNMEASURED (the leg reported no rate)".to_owned()),
361 }
362 let disk_seq_read_bps = if cached {
363 clauses.push(format!(
364 "sequential read CACHED and therefore ABSENT: the leg came back at {} \
365 ({}), over the {} bar, so the page cache answered and the device was never \
366 touched — this is not a fast disk, it is no reading of the disk at all",
367 crate::disktest::fmt_rate(r.seq_read_bps),
368 r.read_how.as_str(),
369 crate::disktest::fmt_rate(CACHED_BPS)
370 ));
371 None
372 } else {
373 let v = bps(r.seq_read_bps);
374 match v {
375 Some(_) => clauses.push(format!(
376 "sequential read {} ({})",
377 crate::disktest::fmt_rate(r.seq_read_bps),
378 r.read_how.as_str()
379 )),
380 None => clauses
381 .push("sequential read UNMEASURED (the leg reported no rate)".to_owned()),
382 }
383 v
384 };
385 let disk_sync_milliops_per_s = (r.sync_ops_per_s.is_finite() && r.sync_ops_per_s > 0.0)
386 .then(|| (r.sync_ops_per_s * 1000.0).round() as u64);
387 match disk_sync_milliops_per_s {
388 Some(_) => clauses.push(format!(
389 "4 KiB overwrite + fsync {:.0} ops/s",
390 r.sync_ops_per_s
391 )),
392 None => {
393 clauses.push("4 KiB fsync UNMEASURED (the leg reported no rate)".to_owned())
394 }
395 }
396 let us =
397 |d: std::time::Duration| Some(u64::try_from(d.as_micros()).unwrap_or(u64::MAX));
398 CapacityResponse {
399 disk_test: DiskTest::DisktestMeasured as i32,
400 disk_seq_write_bps,
401 disk_seq_read_bps,
402 disk_sync_milliops_per_s,
403 disk_read_how: r.read_how.as_str().to_owned(),
404 disk_test_bytes: Some(r.file_bytes),
409 disk_write_us: us(r.write_elapsed),
410 disk_read_us: us(r.read_elapsed),
411 disk_sync_ops: Some(r.sync_ops),
412 disk_sync_us: us(r.sync_elapsed),
413 disk_test_us: us(r.elapsed),
414 disk_reason: format!(
415 "This disk, at start-up, on an idle box: {}. Taken in {:.1} s.",
416 clauses.join("; "),
417 r.elapsed.as_secs_f64()
418 ),
419 ..Default::default()
420 }
421 }
422 }
423}
424
425pub fn machine_fields(m: &MachineFacts) -> CapacityResponse {
433 let mut clauses = Vec::with_capacity(3);
434 let cpu_millicores_total = match &m.cpus {
435 Ok(n) => {
436 clauses.push(format!(
437 "{n} CPUs = {} millicores gunnar may run on (affinity mask, cgroup cpu.max limit \
438 applied)",
439 n * 1000
440 ));
441 Some((*n as u64) * 1000)
442 }
443 Err(why) => {
444 clauses.push(format!(
445 "cores UNMEASURED ({why}), so cpu_millicores_total is absent"
446 ));
447 None
448 }
449 };
450 let (ram_bytes_total, ram_bytes_available) = match &m.memory {
451 Ok((total, available)) => {
452 let clamp = match m.cgroup_memory_max {
453 Some(cap) => format!(
454 " — clamped to the cgroup memory.max of {}, which is what this process \
455 may use; /proc/meminfo reports the host",
456 human(cap)
457 ),
458 None => String::new(),
459 };
460 clauses.push(format!(
461 "RAM {} total, {} available (/proc/meminfo MemTotal/MemAvailable{clamp})",
462 human(*total),
463 human(*available)
464 ));
465 (Some(*total), Some(*available))
466 }
467 Err(why) => {
468 clauses.push(format!(
469 "RAM UNMEASURED ({why}), so ram_bytes_total and ram_bytes_available are absent"
470 ));
471 (None, None)
472 }
473 };
474 let cpu_millicores_busy = match &m.load1 {
475 Ok(load) => {
476 let busy = (load * 1000.0).round().max(0.0) as u64;
477 clauses.push(format!(
478 "1-min load {load:.2} ≈ {busy} millicores busy (/proc/loadavg × 1000: an \
479 APPROXIMATION of the whole box's runnable tasks, not gunnar's own CPU time)"
480 ));
481 Some(busy)
482 }
483 Err(why) => {
484 clauses.push(format!(
485 "load UNMEASURED ({why}), so cpu_millicores_busy is absent"
486 ));
487 None
488 }
489 };
490 let d = disk_fields(&m.disk);
495 CapacityResponse {
496 cpu_millicores_total,
497 ram_bytes_total,
498 ram_bytes_available,
499 cpu_millicores_busy,
500 machine_reason: format!("This box: {}.", clauses.join("; ")),
501 ..d
502 }
503}
504
505pub fn measure_disks(
515 data_root: &Path,
516 slow_root: Option<&Path>,
517 floor: gunnar_ssh::DiskFloor,
518) -> gunnar_proto::DiskSnapshot {
519 let mut volumes = vec![
520 disk_snapshot::Volume::new("system", "/"),
521 disk_snapshot::Volume::new("data", data_root),
522 ];
523 if let Some(slow) = slow_root {
524 volumes.push(disk_snapshot::Volume::new("serialdat", slow));
525 }
526 let snap = disk_snapshot::take(
527 &volumes,
528 disk_snapshot::Floor {
529 percent: floor.percent,
530 min_bytes: floor.bytes,
531 },
532 );
533 disks_to_wire(snap, gunnar_core::now_ms())
534}
535
536pub fn serialdat_breakdown(
546 known: &[(&str, PathBuf)],
547 used: Option<u64>,
548) -> Vec<gunnar_proto::PathUse> {
549 let mut rows: Vec<gunnar_proto::PathUse> = known
550 .iter()
551 .map(|(label, path)| {
552 let mut row = gunnar_proto::PathUse {
553 label: (*label).to_owned(),
554 path: path.display().to_string(),
555 ..Default::default()
556 };
557 match allocated_bytes(path) {
558 Ok(bytes) => {
559 row.measured = true;
560 row.bytes = bytes;
561 }
562 Err(why) => row.error = why,
563 }
564 row
565 })
566 .collect();
567 let inner: Vec<(PathBuf, u64)> = known
570 .iter()
571 .zip(rows.iter())
572 .filter(|(_, r)| r.measured)
573 .map(|((_, p), r)| (p.clone(), r.bytes))
574 .collect();
575 for ((_, outer), row) in known.iter().zip(rows.iter_mut()) {
576 if !row.measured {
577 continue;
578 }
579 let nested: u64 = inner
580 .iter()
581 .filter(|(p, _)| p != outer && p.starts_with(outer))
582 .map(|(_, b)| *b)
583 .sum();
584 row.bytes = row.bytes.saturating_sub(nested);
585 }
586 if let Some(used) = used {
587 let known_sum: u64 = rows.iter().filter(|r| r.measured).map(|r| r.bytes).sum();
588 rows.push(gunnar_proto::PathUse {
589 label: "other".to_owned(),
590 path: String::new(),
591 measured: true,
592 bytes: used.saturating_sub(known_sum),
593 error: String::new(),
594 });
595 }
596 rows
597}
598
599fn allocated_bytes(path: &Path) -> Result<u64, String> {
602 use std::os::unix::fs::MetadataExt as _;
603 let meta = match std::fs::symlink_metadata(path) {
604 Ok(m) => m,
605 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
606 Err(e) => return Err(format!("{}: {e}", path.display())),
607 };
608 let mut total = meta.blocks() * 512;
609 if meta.is_dir() {
610 let entries = std::fs::read_dir(path).map_err(|e| format!("{}: {e}", path.display()))?;
611 for entry in entries {
612 let entry = entry.map_err(|e| format!("{}: {e}", path.display()))?;
613 total += allocated_bytes(&entry.path())?;
614 }
615 }
616 Ok(total)
617}
618
619pub fn disks_to_wire(snap: disk_snapshot::Snapshot, at_unix_ms: i64) -> gunnar_proto::DiskSnapshot {
623 let mounts = snap
624 .disks
625 .into_iter()
626 .map(|d| {
627 let mut row = gunnar_proto::DiskMount {
628 source: d.mount.source,
629 mount_point: d.mount.mount_point.display().to_string(),
630 fs_type: d.mount.fstype,
631 read_only: d.mount.read_only,
632 labels: d.labels,
633 floor_bytes: d.floor_bytes,
634 servable_bytes: d.servable_bytes,
635 ..Default::default()
636 };
637 match d.figures {
638 Ok(f) => {
639 row.measured = true;
640 row.total_bytes = f.total_bytes;
641 row.free_bytes = f.free_bytes;
642 row.available_bytes = f.available_bytes;
643 row.used_bytes = f.used_bytes;
644 row.reserved_bytes = f.reserved_bytes;
645 row.inodes_total = f.inodes_total;
646 row.inodes_free = f.inodes_free;
647 }
648 Err(why) => row.error = why,
649 }
650 row
651 })
652 .collect();
653 let devices = snap
654 .block_devices
655 .into_iter()
656 .map(|b| gunnar_proto::BlockDevice {
657 name: b.name,
658 size_bytes: b.size_bytes,
659 rotational: b.rotational,
660 removable: b.removable,
661 read_only: b.read_only,
662 partitions: b
663 .partitions
664 .into_iter()
665 .map(|(name, size_bytes)| gunnar_proto::BlockPartition { name, size_bytes })
666 .collect(),
667 holders: b.holders,
668 members: b.members,
669 raid_level: b.raid_level,
670 })
671 .collect();
672 let unresolved = snap
673 .unresolved
674 .into_iter()
675 .map(|u| gunnar_proto::UnresolvedDisk {
676 label: u.label,
677 path: u.path.display().to_string(),
678 why: u.why,
679 })
680 .collect();
681 gunnar_proto::DiskSnapshot {
682 measured_at_unix_ms: at_unix_ms,
683 mounts,
684 devices,
685 unresolved,
686 problems: snap.problems,
687 serialdat: Vec::new(),
689 }
690}
691
692#[derive(Debug, Clone)]
696pub struct Question {
697 pub root: PathBuf,
699 pub room: Result<Headroom, String>,
702 pub floor_spec: String,
704 pub requested_bytes: u64,
706 pub held_bytes: u64,
708 pub namespace: String,
710 pub growth: GrowthFacts,
711 pub machine: MachineFacts,
714}
715
716pub fn answer(q: &Question) -> CapacityResponse {
718 let (growth, growth_clause) = decide_growth(&q.growth, &q.root);
719 let mut machine = machine_fields(&q.machine);
722 machine.grow_state = q.growth.grow.as_ref().map(|s| s.to_wire());
725 machine.hotplug = q.growth.hotplug;
726 let data_set = q
727 .growth
728 .kind
729 .as_ref()
730 .map(|k| k.to_string())
731 .unwrap_or_default();
732 let root = q.root.display();
733
734 let additional = q.requested_bytes.saturating_sub(q.held_bytes);
738 let asked = if q.namespace.is_empty() {
739 format!("a cap of {}", human(q.requested_bytes))
740 } else {
741 format!(
742 "raising {} to {} (it holds {})",
743 q.namespace,
744 human(q.requested_bytes),
745 human(q.held_bytes)
746 )
747 };
748
749 let room = match &q.room {
750 Err(err) => {
751 return CapacityResponse {
752 verdict: CapacityVerdict::CapacityUnmeasured as i32,
753 growth: growth as i32,
754 root: q.root.display().to_string(),
755 requested_bytes: q.requested_bytes,
756 held_bytes: q.held_bytes,
757 additional_bytes: additional,
758 data_set,
759 reason: format!(
760 "{root} could not be measured ({err}), so gunnar will not answer that {asked} \
761 can be served — an unmeasured box has not said yes. {growth_clause}"
762 ),
763 total_bytes: 0,
766 free_bytes: 0,
767 floor_bytes: 0,
768 servable_bytes: 0,
769 shortfall_bytes: 0,
770 ..machine
771 };
772 }
773 Ok(room) => *room,
774 };
775
776 let servable = room.free.saturating_sub(room.floor);
781 let measured = format!(
782 "{root} has {} free of {}, {} of it reserved by the disk floor (--disk-floor {}), so {} \
783 may be served",
784 human(room.free),
785 human(room.total),
786 human(room.floor),
787 q.floor_spec,
788 human(servable),
789 );
790
791 let (verdict, shortfall, outcome) = if additional <= servable {
795 (
796 CapacityVerdict::CapacityAvailable,
797 0,
798 format!("needs {} more: YES, it can be served.", human(additional)),
799 )
800 } else {
801 let shortfall = additional - servable;
802 (
803 CapacityVerdict::CapacityExhausted,
804 shortfall,
805 format!(
806 "needs {} more, which is {} beyond that: NO.",
807 human(additional),
808 human(shortfall)
809 ),
810 )
811 };
812 CapacityResponse {
813 verdict: verdict as i32,
814 growth: growth as i32,
815 root: q.root.display().to_string(),
816 total_bytes: room.total,
817 free_bytes: room.free,
818 floor_bytes: room.floor,
819 servable_bytes: servable,
820 requested_bytes: q.requested_bytes,
821 held_bytes: q.held_bytes,
822 additional_bytes: additional,
823 shortfall_bytes: shortfall,
824 data_set,
825 reason: format!("{measured}. {asked} {outcome} {growth_clause}"),
826 ..machine
827 }
828}
829
830#[cfg(test)]
831mod tests {
832 use super::*;
833 use crate::dashboard::probe::SetFacts;
834
835 const GIB: u64 = 1024 * 1024 * 1024;
836
837 fn appliance() -> GrowthFacts {
840 GrowthFacts {
841 kind: Some(DiskKind {
842 fs: "xfs".into(),
843 set: Some(SetFacts {
844 level: "raid0".into(),
845 members: 4,
846 member_bytes: 75 * GIB,
847 chunk_bytes: 512 * 1024,
848 }),
849 }),
850 is_pid1: true,
851 grow: None,
852 runtime_engine: false,
853 hotplug: false,
854 }
855 }
856
857 fn developer_box() -> GrowthFacts {
859 GrowthFacts {
860 is_pid1: false,
861 ..appliance()
862 }
863 }
864
865 fn growing_appliance(text: &str) -> GrowthFacts {
868 GrowthFacts {
869 grow: Some(gunnar_grpc::grow_journal::Summary::of(
870 text,
871 "/serialdat/grow-journal",
872 )),
873 ..appliance()
874 }
875 }
876
877 #[test]
883 fn the_engine_in_the_server_with_the_journal_on_the_box_grows_at_runtime() {
884 let both = GrowthFacts {
885 runtime_engine: true,
886 hotplug: true,
887 ..growing_appliance("")
888 };
889 let (v, line) = decide_growth(&both, Path::new("/data"));
890 assert_eq!(v, SetGrowth::GrowthAtRuntime);
891 assert!(
892 line.contains("WITHOUT A RESTART")
893 && line.contains("Admin.GrowStart")
894 && line.contains("ALL 4 members"),
895 "{line}"
896 );
897 let engine_only = GrowthFacts {
898 runtime_engine: true,
899 hotplug: true,
900 ..appliance()
901 };
902 assert_eq!(
903 decide_growth(&engine_only, Path::new("/data")).0,
904 SetGrowth::GrowthSealed,
905 "no journal: the engine alone proves nothing"
906 );
907 assert_eq!(
908 decide_growth(&growing_appliance(""), Path::new("/data")).0,
909 SetGrowth::GrowthAtRestart,
910 "journal alone: the restart arm"
911 );
912 }
913
914 #[test]
919 fn the_engine_without_a_hotplug_kernel_grows_only_at_restart_and_says_why() {
920 let no_hp = GrowthFacts {
921 runtime_engine: true,
922 hotplug: false,
923 ..growing_appliance("")
924 };
925 let (v, line) = decide_growth(&no_hp, Path::new("/data"));
926 assert_eq!(v, SetGrowth::GrowthAtRestart);
927 assert!(
928 line.contains("NO PCI hot-plug") && line.contains("refused by name"),
929 "{line}"
930 );
931 let mut q = question(gunnar_rs_today(), no_hp.clone());
932 q.requested_bytes = 1;
933 assert!(!answer(&q).hotplug);
934 q.growth.hotplug = true;
935 let a = answer(&q);
936 assert!(
937 a.hotplug && a.growth == SetGrowth::GrowthAtRuntime as i32,
938 "{a:?}"
939 );
940 q.room = Err("EIO".into());
941 assert!(answer(&q).hotplug, "the unmeasured arm carries it too");
942 }
943
944 #[test]
952 fn a_boot_stack_with_the_grow_engine_grows_at_restart_and_reports_the_journal() {
953 let (v, line) = decide_growth(&growing_appliance(""), Path::new("/data"));
954 assert_eq!(v, SetGrowth::GrowthAtRestart);
955 assert!(
956 line.contains("GROWS AT RESTART")
957 && line.contains("ALL 4 members")
958 && line.contains("invisible to it"),
959 "{line}"
960 );
961 assert!(line.contains("no grow has run"), "{line}");
962 assert_eq!(
963 decide_growth(&appliance(), Path::new("/data")).0,
964 SetGrowth::GrowthSealed,
965 "no journal: sealed as before"
966 );
967
968 let partial = "epoch=0 target_sectors=134215680 member=0 step=PARTIAL at=1\nepoch=0 target_sectors=134215680 member=1 step=PARTIAL at=1\n";
970 let (v, line) = decide_growth(&growing_appliance(partial), Path::new("/data"));
971 assert_eq!(v, SetGrowth::GrowthAtRestart);
972 assert!(
973 line.contains("PARTIAL RESIZE: 2 member(s) [0, 1]"),
974 "{line}"
975 );
976
977 let done = "epoch=1 target_sectors=134215680 member=- step=G1 at=1\nepoch=1 target_sectors=134215680 member=- step=G6 at=2\n";
979 let mut q = question(gunnar_rs_today(), growing_appliance(done));
980 q.requested_bytes = 1;
981 let a = answer(&q);
982 assert_eq!(a.growth, SetGrowth::GrowthAtRestart as i32);
983 let g = a.grow_state.expect("the journal is on the wire");
984 assert!(
985 g.engine_present
986 && g.phase == "done"
987 && g.epoch == 1
988 && g.target_sectors == 134_215_680
989 && g.journal_path == "/serialdat/grow-journal",
990 "{g:?}"
991 );
992 assert!(a.reason.contains("epoch 1 done"), "{}", a.reason);
993 q.requested_bytes = 1000 * GIB;
994 assert!(answer(&q).grow_state.is_some(), "the no arm carries it too");
995 q.room = Err("EIO".into());
996 assert!(answer(&q).grow_state.is_some(), "and the unmeasured arm");
997 let q = question(gunnar_rs_today(), appliance());
998 assert!(
999 answer(&q).grow_state.is_none(),
1000 "no journal, no grow_state — an absence, never an empty one"
1001 );
1002 }
1003
1004 fn appliance_box() -> MachineFacts {
1007 MachineFacts {
1008 cpus: Ok(4),
1009 memory: Ok((16 * GIB, 12 * GIB)),
1010 cgroup_memory_max: None,
1011 load1: Ok(0.42),
1012 disk: Some(crate::disktest::Outcome::Done(crate::disktest::Report {
1013 seq_write_bps: 812e6,
1014 seq_read_bps: 1.9e9,
1015 read_how: crate::disktest::ReadHow::Direct,
1016 sync_ops_per_s: 3100.0,
1017 elapsed: std::time::Duration::from_millis(1600),
1018 file_bytes: 256 << 20,
1019 write_elapsed: std::time::Duration::from_millis(330),
1020 read_elapsed: std::time::Duration::from_millis(141),
1021 sync_ops: 1550,
1022 sync_elapsed: std::time::Duration::from_millis(500),
1023 })),
1024 }
1025 }
1026
1027 fn question(room: Headroom, growth: GrowthFacts) -> Question {
1028 Question {
1029 root: PathBuf::from("/data"),
1030 room: Ok(room),
1031 floor_spec: "5%,2.0 GiB".into(),
1032 requested_bytes: 0,
1033 held_bytes: 0,
1034 namespace: String::new(),
1035 growth,
1036 machine: appliance_box(),
1037 }
1038 }
1039
1040 fn gunnar_rs_today() -> Headroom {
1042 let total = 300 * GIB;
1043 Headroom {
1044 total,
1045 free: 70 * GIB,
1046 floor: (total / 100) * 5,
1047 }
1048 }
1049
1050 #[test]
1051 fn the_growth_verdict_separates_a_sealed_appliance_from_a_box_with_a_shell() {
1052 let (sealed, sealed_line) = decide_growth(&appliance(), Path::new("/data"));
1053 let (operator, operator_line) = decide_growth(&developer_box(), Path::new("/data"));
1054 let (unknown, unknown_line) = decide_growth(
1055 &GrowthFacts {
1056 kind: None,
1057 is_pid1: false,
1058 grow: None,
1059 runtime_engine: false,
1060 hotplug: false,
1061 },
1062 Path::new("/tmp/x"),
1063 );
1064
1065 assert_eq!(sealed, SetGrowth::GrowthSealed);
1068 assert_eq!(operator, SetGrowth::GrowthOperatorOnly);
1069 assert_eq!(unknown, SetGrowth::GrowthUnknown);
1070 assert!(
1071 sealed != operator && operator != unknown && sealed != unknown,
1072 "the same facts must not collapse to one verdict"
1073 );
1074
1075 assert!(
1077 sealed_line.contains("UNUSED AND INVISIBLE"),
1078 "the sealed clause must say an attached disk is wasted: {sealed_line}"
1079 );
1080 assert!(
1081 sealed_line.contains("fresh install") && sealed_line.contains("not another disk"),
1082 "and must name what WOULD work: {sealed_line}"
1083 );
1084 assert!(
1085 sealed_line.contains("4\u{d7}75G raid0 xfs"),
1086 "and must name the measured set, not a generic one: {sealed_line}"
1087 );
1088 assert!(
1091 !operator_line.contains("UNUSED AND INVISIBLE")
1092 && operator_line.contains("an operator with a shell"),
1093 "{operator_line}"
1094 );
1095 assert!(
1096 unknown_line.contains("UNKNOWN, which is not a yes"),
1097 "{unknown_line}"
1098 );
1099 }
1100
1101 #[test]
1105 fn ninety_more_gigabytes_on_the_sealed_appliance_is_a_no_that_names_the_reinstall() {
1106 let mut q = question(gunnar_rs_today(), appliance());
1107 q.namespace = "tenant".into();
1108 q.held_bytes = 10 * GIB;
1109 q.requested_bytes = 100 * GIB;
1110 let a = answer(&q);
1111
1112 assert_eq!(
1113 a.verdict,
1114 CapacityVerdict::CapacityExhausted as i32,
1115 "{}",
1116 a.reason
1117 );
1118 assert_eq!(a.growth, SetGrowth::GrowthSealed as i32);
1119 assert_eq!(a.free_bytes, 70 * GIB);
1121 assert_eq!(a.floor_bytes, 15 * GIB, "5 % of 300 GiB");
1122 assert_eq!(a.servable_bytes, 55 * GIB, "free minus the floor, not free");
1123 assert_eq!(
1124 a.additional_bytes,
1125 90 * GIB,
1126 "the raise needs the DELTA, not the cap"
1127 );
1128 assert_eq!(a.shortfall_bytes, 35 * GIB);
1129 assert_eq!(a.data_set, "4\u{d7}75G raid0 xfs");
1130 assert!(a.reason.contains("NO."), "{}", a.reason);
1132 assert!(a.reason.contains("UNUSED AND INVISIBLE"), "{}", a.reason);
1133 }
1134
1135 #[test]
1140 fn the_same_shortfall_on_a_box_with_a_shell_does_not_say_a_disk_is_wasted() {
1141 let mut sealed = question(gunnar_rs_today(), appliance());
1142 sealed.requested_bytes = 90 * GIB;
1143 let mut shelled = sealed.clone();
1144 shelled.growth = developer_box();
1145
1146 let a = answer(&sealed);
1147 let b = answer(&shelled);
1148 assert_eq!(a.verdict, b.verdict, "the shortfall is identical");
1149 assert_eq!(a.shortfall_bytes, b.shortfall_bytes);
1150 assert_ne!(a.growth, b.growth, "the growth answer is NOT the verdict");
1151 assert_ne!(
1152 a.reason, b.reason,
1153 "and it changes what the operator is told"
1154 );
1155 assert!(b.reason.contains("NO.") && !b.reason.contains("UNUSED AND INVISIBLE"));
1156 }
1157
1158 #[test]
1161 fn a_request_that_fits_is_a_yes_that_still_says_the_set_is_sealed() {
1162 let mut q = question(gunnar_rs_today(), appliance());
1163 q.requested_bytes = 20 * GIB;
1164 let a = answer(&q);
1165 assert_eq!(a.verdict, CapacityVerdict::CapacityAvailable as i32);
1166 assert_eq!(a.shortfall_bytes, 0);
1167 assert!(a.reason.contains("YES"), "{}", a.reason);
1168 assert_eq!(
1169 a.growth,
1170 SetGrowth::GrowthSealed as i32,
1171 "the growth fact is reported on the YES arm too"
1172 );
1173 assert!(a.reason.contains("UNUSED AND INVISIBLE"), "{}", a.reason);
1174
1175 q.requested_bytes = 60 * GIB;
1178 assert_eq!(
1179 answer(&q).verdict,
1180 CapacityVerdict::CapacityExhausted as i32,
1181 "a request under free but over free-minus-floor must be refused: the floor is not \
1182 for sale"
1183 );
1184 q.requested_bytes = 55 * GIB;
1185 assert_eq!(
1186 answer(&q).verdict,
1187 CapacityVerdict::CapacityAvailable as i32
1188 );
1189 }
1190
1191 #[test]
1192 fn a_filesystem_that_cannot_be_measured_is_not_a_yes_and_reports_no_numbers() {
1193 let mut q = question(gunnar_rs_today(), appliance());
1194 q.room = Err("No such file or directory (os error 2)".into());
1195 q.requested_bytes = 1;
1196 let a = answer(&q);
1197 assert_eq!(a.verdict, CapacityVerdict::CapacityUnmeasured as i32);
1198 assert_ne!(
1199 a.verdict,
1200 CapacityVerdict::CapacityAvailable as i32,
1201 "one byte would fit on any real disk; an unmeasured one must still not say yes"
1202 );
1203 assert_eq!(
1204 (a.total_bytes, a.free_bytes, a.floor_bytes, a.servable_bytes),
1205 (0, 0, 0, 0),
1206 "nothing was measured, so nothing may be reported"
1207 );
1208 assert!(a.reason.contains("os error 2"), "{}", a.reason);
1209 assert!(a.reason.contains("has not said yes"), "{}", a.reason);
1210 }
1211
1212 #[test]
1217 fn the_appliance_box_is_reported_in_millicores_and_bytes_with_its_sources_named() {
1218 let q = question(gunnar_rs_today(), appliance());
1219 let a = answer(&q);
1220 assert_eq!(a.cpu_millicores_total, Some(4000));
1221 assert_eq!(a.ram_bytes_total, Some(16 * GIB));
1222 assert_eq!(a.ram_bytes_available, Some(12 * GIB));
1223 assert_eq!(a.cpu_millicores_busy, Some(420), "0.42 × 1000, rounded");
1224 for word in [
1225 "affinity mask",
1226 "/proc/meminfo",
1227 "/proc/loadavg",
1228 "APPROXIMATION",
1229 ] {
1230 assert!(
1231 a.machine_reason.contains(word),
1232 "{word}: {}",
1233 a.machine_reason
1234 );
1235 }
1236 assert!(
1237 !a.machine_reason.contains("clamped"),
1238 "no cgroup clamped the appliance: {}",
1239 a.machine_reason
1240 );
1241
1242 let mut unmeasured = q.clone();
1245 unmeasured.room = Err("No such file or directory (os error 2)".into());
1246 let u = answer(&unmeasured);
1247 assert_eq!(
1248 (
1249 u.cpu_millicores_total,
1250 u.ram_bytes_total,
1251 u.ram_bytes_available,
1252 u.cpu_millicores_busy
1253 ),
1254 (
1255 a.cpu_millicores_total,
1256 a.ram_bytes_total,
1257 a.ram_bytes_available,
1258 a.cpu_millicores_busy
1259 )
1260 );
1261 assert_eq!(u.machine_reason, a.machine_reason);
1262 }
1263
1264 #[test]
1268 fn an_unreadable_meminfo_leaves_the_ram_fields_absent_and_names_why() {
1269 let mut q = question(gunnar_rs_today(), appliance());
1270 q.machine.memory =
1271 Err("/proc/meminfo could not be read (No such file or directory (os error 2))".into());
1272 q.machine.load1 =
1273 Err("/proc/loadavg could not be read (Permission denied (os error 13))".into());
1274 let a = answer(&q);
1275 assert_eq!(a.ram_bytes_total, None, "absent, not 0: {a:?}");
1276 assert_eq!(a.ram_bytes_available, None);
1277 assert_eq!(a.cpu_millicores_busy, None);
1278 assert_ne!(
1279 a.ram_bytes_total,
1280 Some(0),
1281 "a zero here would be a box with no RAM"
1282 );
1283 assert_eq!(
1284 a.cpu_millicores_total,
1285 Some(4000),
1286 "the cores were measured and stay"
1287 );
1288 assert!(
1289 a.machine_reason.contains("RAM UNMEASURED"),
1290 "{}",
1291 a.machine_reason
1292 );
1293 assert!(
1294 a.machine_reason.contains("os error 2"),
1295 "{}",
1296 a.machine_reason
1297 );
1298 assert!(
1299 a.machine_reason.contains("load UNMEASURED"),
1300 "{}",
1301 a.machine_reason
1302 );
1303 assert!(
1304 a.machine_reason.contains("os error 13"),
1305 "{}",
1306 a.machine_reason
1307 );
1308
1309 q.machine.cpus = Err("the affinity mask could not be read (os error 22)".into());
1311 let none = answer(&q);
1312 assert_eq!(
1313 (
1314 none.cpu_millicores_total,
1315 none.ram_bytes_total,
1316 none.ram_bytes_available,
1317 none.cpu_millicores_busy
1318 ),
1319 (None, None, None, None)
1320 );
1321 assert!(
1322 none.machine_reason.contains("cores UNMEASURED"),
1323 "{}",
1324 none.machine_reason
1325 );
1326 assert_eq!(none.verdict, a.verdict);
1328 assert_eq!(none.reason, a.reason);
1329 }
1330
1331 #[test]
1334 fn a_cgroup_memory_clamp_is_reported_as_the_ram_and_named_in_the_reason() {
1335 let mut q = question(gunnar_rs_today(), appliance());
1336 q.machine.memory = Ok((8 * GIB, 6 * GIB));
1337 q.machine.cgroup_memory_max = Some(8 * GIB);
1338 let a = answer(&q);
1339 assert_eq!(a.ram_bytes_total, Some(8 * GIB));
1340 assert!(
1341 a.machine_reason
1342 .contains("clamped to the cgroup memory.max of 8.0 GiB"),
1343 "{}",
1344 a.machine_reason
1345 );
1346 }
1347
1348 #[test]
1353 fn the_disk_answer_does_not_change_with_the_box() {
1354 let q = question(gunnar_rs_today(), appliance());
1355 let mut blind = q.clone();
1356 blind.machine = MachineFacts {
1357 cpus: Err("x".into()),
1358 memory: Err("y".into()),
1359 cgroup_memory_max: None,
1360 load1: Err("z".into()),
1361 disk: None,
1362 };
1363 let (a, b) = (answer(&q), answer(&blind));
1364 assert_ne!(a.machine_reason, b.machine_reason, "the fixtures differ");
1365 assert_ne!(a.ram_bytes_total, b.ram_bytes_total);
1366 let disk = |r: &CapacityResponse| {
1373 CapacityResponse {
1374 cpu_millicores_total: None,
1375 ram_bytes_total: None,
1376 ram_bytes_available: None,
1377 cpu_millicores_busy: None,
1378 machine_reason: String::new(),
1379 disk_test: 0,
1380 disk_seq_write_bps: None,
1381 disk_seq_read_bps: None,
1382 disk_sync_milliops_per_s: None,
1383 disk_read_how: String::new(),
1384 disk_reason: String::new(),
1385 disk_test_bytes: None,
1388 disk_write_us: None,
1389 disk_read_us: None,
1390 disk_sync_ops: None,
1391 disk_sync_us: None,
1392 disk_test_us: None,
1393 ..r.clone()
1394 }
1395 };
1396 assert_eq!(disk(&a), disk(&b));
1397 assert_ne!(a.disk_test, b.disk_test);
1400 assert_ne!(a.disk_reason, b.disk_reason);
1401 }
1402}
1403
1404#[cfg(test)]
1412mod disk_on_the_wire {
1413 use super::*;
1414 use crate::disktest::{Outcome, ReadHow, Report};
1415 use gunnar_proto::DiskTest;
1416 use std::time::Duration;
1417
1418 fn done(seq_read_bps: f64) -> Outcome {
1419 Outcome::Done(Report {
1420 seq_write_bps: 812e6,
1421 seq_read_bps,
1422 read_how: ReadHow::Direct,
1423 sync_ops_per_s: 3100.4,
1424 elapsed: Duration::from_millis(1600),
1425 file_bytes: 256 << 20,
1426 write_elapsed: Duration::from_millis(330),
1427 read_elapsed: Duration::from_millis(141),
1428 sync_ops: 1550,
1429 sync_elapsed: Duration::from_millis(500),
1430 })
1431 }
1432
1433 #[test]
1435 fn a_measured_disk_carries_its_three_numbers_and_says_how_it_read() {
1436 let a = disk_fields(&Some(done(1.9e9)));
1437 assert_eq!(a.disk_test, DiskTest::DisktestMeasured as i32);
1438 assert_eq!(a.disk_seq_write_bps, Some(812_000_000));
1439 assert_eq!(a.disk_seq_read_bps, Some(1_900_000_000));
1440 assert_eq!(a.disk_sync_milliops_per_s, Some(3_100_400));
1442 assert_eq!(a.disk_read_how, "direct");
1443 assert!(a.disk_reason.contains("812 MB/s"), "{}", a.disk_reason);
1444 assert!(a.disk_reason.contains("1.9 GB/s"), "{}", a.disk_reason);
1445 assert_eq!(a.disk_test_bytes, Some(256 << 20));
1447 assert_eq!(
1448 (a.disk_write_us, a.disk_read_us),
1449 (Some(330_000), Some(141_000))
1450 );
1451 assert_eq!(
1452 (a.disk_sync_ops, a.disk_sync_us),
1453 (Some(1550), Some(500_000))
1454 );
1455 assert_eq!(a.disk_test_us, Some(1_600_000));
1456 }
1457
1458 #[test]
1463 fn a_cached_read_is_absent_from_the_wire_and_is_never_reported_as_a_fast_disk() {
1464 let a = disk_fields(&Some(done(7.2e9)));
1465 assert_eq!(
1466 a.disk_test,
1467 DiskTest::DisktestMeasured as i32,
1468 "the run DID measure"
1469 );
1470 assert_eq!(a.disk_seq_read_bps, None, "not 7.2 GB/s and not 0 — absent");
1471 assert_eq!(
1472 a.disk_seq_write_bps,
1473 Some(812_000_000),
1474 "the other legs stand"
1475 );
1476 assert_eq!(a.disk_sync_milliops_per_s, Some(3_100_400));
1477 assert!(a.disk_reason.contains("CACHED"), "{}", a.disk_reason);
1478 assert!(
1479 a.disk_reason.contains("not a fast disk"),
1480 "the reason says what the absence MEANS: {}",
1481 a.disk_reason
1482 );
1483 }
1484
1485 #[test]
1488 fn a_disk_that_was_not_measured_is_named_and_carries_no_number_at_all() {
1489 let cases = [
1490 (None, DiskTest::DisktestNotRun, "gate"),
1491 (
1492 Some(Outcome::Skipped {
1493 free: 800 << 20,
1494 need: 1 << 30,
1495 }),
1496 DiskTest::DisktestSkipped,
1497 "SKIPPED",
1498 ),
1499 (
1500 Some(Outcome::Failed("write: No space left on device".into())),
1501 DiskTest::DisktestFailed,
1502 "FAILED",
1503 ),
1504 ];
1505 for (outcome, want, word) in cases {
1506 let a = disk_fields(&outcome);
1507 assert_eq!(a.disk_test, want as i32, "{outcome:?}");
1508 assert_eq!(a.disk_seq_write_bps, None, "{outcome:?}");
1509 assert_eq!(a.disk_seq_read_bps, None, "{outcome:?}");
1510 assert_eq!(a.disk_sync_milliops_per_s, None, "{outcome:?}");
1511 assert!(a.disk_read_how.is_empty(), "{outcome:?}");
1512 assert_eq!(
1513 (
1514 a.disk_test_bytes,
1515 a.disk_write_us,
1516 a.disk_read_us,
1517 a.disk_sync_ops,
1518 a.disk_sync_us,
1519 a.disk_test_us
1520 ),
1521 (None, None, None, None, None, None),
1522 "no run, no bytes and no times: {outcome:?}"
1523 );
1524 assert!(!a.disk_reason.is_empty(), "never silent: {outcome:?}");
1525 assert!(a.disk_reason.contains(word), "{}", a.disk_reason);
1526 }
1527 let s = disk_fields(&Some(Outcome::Skipped {
1529 free: 800 << 20,
1530 need: 1 << 30,
1531 }));
1532 assert!(
1533 s.disk_reason.contains("800") && s.disk_reason.contains("1.0"),
1534 "{}",
1535 s.disk_reason
1536 );
1537 let reasons: std::collections::BTreeSet<String> = [
1539 disk_fields(&None).disk_reason,
1540 disk_fields(&Some(Outcome::Skipped { free: 0, need: 1 })).disk_reason,
1541 disk_fields(&Some(Outcome::Failed("x".into()))).disk_reason,
1542 ]
1543 .into_iter()
1544 .collect();
1545 assert_eq!(reasons.len(), 3);
1546 }
1547
1548 #[test]
1551 fn the_box_fields_and_the_disk_fields_come_out_of_one_call() {
1552 let m = MachineFacts {
1553 cpus: Ok(4),
1554 memory: Ok((16 << 30, 12 << 30)),
1555 cgroup_memory_max: None,
1556 load1: Ok(0.42),
1557 disk: Some(done(1.9e9)),
1558 };
1559 let a = machine_fields(&m);
1560 assert_eq!(a.cpu_millicores_total, Some(4000));
1561 assert_eq!(a.disk_seq_write_bps, Some(812_000_000));
1562 assert!(!a.machine_reason.is_empty() && !a.disk_reason.is_empty());
1563 let blind = machine_fields(&MachineFacts { disk: None, ..m });
1565 assert_eq!(blind.cpu_millicores_total, Some(4000));
1566 assert_eq!(blind.disk_test, DiskTest::DisktestNotRun as i32);
1567 }
1568
1569 #[test]
1572 fn the_latch_hands_back_what_was_recorded_and_runs_nothing() {
1573 let before = crate::disktest::last();
1574 crate::disktest::record(&done(1.9e9));
1575 assert_eq!(crate::disktest::last(), Some(done(1.9e9)));
1576 crate::disktest::record(&Outcome::Failed("boom".into()));
1577 assert_eq!(
1578 crate::disktest::last(),
1579 Some(Outcome::Failed("boom".into()))
1580 );
1581 if let Some(b) = before {
1583 crate::disktest::record(&b);
1584 }
1585 }
1586}
1587
1588#[cfg(test)]
1589mod disk_tests {
1590 use super::*;
1591
1592 #[test]
1596 fn a_snapshot_reaches_the_wire_and_an_unmeasured_mount_is_not_a_number() {
1597 let mount = |src: &str, at: &str| disk_snapshot::Mount {
1598 source: src.to_owned(),
1599 mount_point: PathBuf::from(at),
1600 fstype: "xfs".to_owned(),
1601 read_only: false,
1602 };
1603 let snap = disk_snapshot::Snapshot {
1604 disks: vec![
1605 disk_snapshot::DiskEntry {
1606 mount: mount("/dev/md0", "/data"),
1607 labels: vec!["data".to_owned()],
1608 paths: vec![PathBuf::from("/data")],
1609 figures: disk_snapshot::Figures::from_counts(1000, 400, 350, 4096, 10, 5),
1610 floor_bytes: 4096 * 50,
1611 servable_bytes: 4096 * 300,
1612 },
1613 disk_snapshot::DiskEntry {
1614 mount: mount("/dev/vda2", "/"),
1615 labels: vec!["system".to_owned()],
1616 paths: vec![PathBuf::from("/")],
1617 figures: Err("statvfs(/): EIO".to_owned()),
1618 floor_bytes: 0,
1619 servable_bytes: 0,
1620 },
1621 ],
1622 unresolved: vec![],
1623 block_devices: vec![disk_snapshot::BlockDevice {
1624 name: "md0".to_owned(),
1625 size_bytes: 4096 * 1000,
1626 members: vec!["vdb1".to_owned(), "vdc1".to_owned()],
1627 raid_level: "raid0".to_owned(),
1628 ..Default::default()
1629 }],
1630 problems: vec![],
1631 };
1632 let wire = disks_to_wire(snap, 42);
1633 assert_eq!(wire.measured_at_unix_ms, 42);
1634 let data = &wire.mounts[0];
1635 assert!(data.measured && data.labels == ["data"]);
1636 assert_eq!(
1637 (
1638 data.total_bytes,
1639 data.free_bytes,
1640 data.available_bytes,
1641 data.used_bytes,
1642 data.reserved_bytes
1643 ),
1644 (4096 * 1000, 4096 * 400, 4096 * 350, 4096 * 600, 4096 * 50)
1645 );
1646 assert_eq!(
1647 (data.floor_bytes, data.servable_bytes),
1648 (4096 * 50, 4096 * 300)
1649 );
1650 let system = &wire.mounts[1];
1651 assert!(!system.measured && system.error.contains("EIO") && system.total_bytes == 0);
1652 assert_eq!(wire.devices[0].members, ["vdb1", "vdc1"]);
1653 assert_eq!(wire.devices[0].raid_level, "raid0");
1654 }
1655
1656 #[test]
1661 fn the_serialdat_breakdown_sums_to_the_mounts_use() {
1662 use std::io::Write as _;
1663 let dir = tempfile::tempdir().expect("a temp mount");
1664 let root = dir.path();
1665 let write = |rel: &str, n: usize| {
1666 let p = root.join(rel);
1667 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1668 let mut f = std::fs::File::create(&p).unwrap();
1669 f.write_all(&vec![7u8; n]).unwrap();
1670 f.sync_all().unwrap();
1671 };
1672 write("meta/principals.json", 10_000);
1673 write("log/audit-chain.jsonl", 70_000);
1674 write("log/console.log", 30_000);
1675 write("crash/last.txt", 5_000);
1676 let known = vec![
1677 ("metadata copy", root.join("meta")),
1678 ("crash record", root.join("crash")),
1679 ("audit trail", root.join("log/audit-chain.jsonl")),
1680 ("logs", root.join("log")),
1681 ("grow journal", root.join("grow-journal")),
1682 ];
1683 let used = 10 << 20;
1684 let rows = serialdat_breakdown(&known, Some(used));
1685 let get = |label: &str| {
1686 rows.iter()
1687 .find(|r| r.label == label)
1688 .unwrap_or_else(|| panic!("{label}: {rows:?}"))
1689 };
1690 assert!(rows.iter().all(|r| r.measured), "{rows:?}");
1691 assert!(get("audit trail").bytes >= 70_000, "{rows:?}");
1692 let logs = get("logs").bytes;
1693 assert!(
1694 (30_000..70_000).contains(&logs),
1695 "the audit trail was counted inside the logs too: {rows:?}"
1696 );
1697 assert_eq!(
1698 get("grow journal").bytes,
1699 0,
1700 "nothing there is 0, not an error"
1701 );
1702 let sum: u64 = rows.iter().map(|r| r.bytes).sum();
1703 assert_eq!(
1704 sum, used,
1705 "the rows must add up to the mount's used bytes: {rows:?}"
1706 );
1707 assert!(serialdat_breakdown(&known, None)
1709 .iter()
1710 .all(|r| r.label != "other"));
1711 }
1712
1713 #[test]
1716 fn this_machines_disks_carry_gunnars_labels() {
1717 let dir = tempfile::tempdir().expect("a temp dir");
1718 let wire = measure_disks(dir.path(), None, gunnar_ssh::DiskFloor::default());
1719 assert!(wire.problems.is_empty(), "{:?}", wire.problems);
1720 let data = wire
1721 .mounts
1722 .iter()
1723 .find(|m| m.labels.iter().any(|l| l == "data"))
1724 .expect("the data root is on some mount");
1725 assert!(data.measured && data.total_bytes > 0, "{data:?}");
1726 assert!(
1727 wire.mounts
1728 .iter()
1729 .any(|m| m.labels.iter().any(|l| l == "system")),
1730 "{:?}",
1731 wire.mounts
1732 );
1733 }
1734}