use std::path::{Path, PathBuf};
use gunnar_proto::{CapacityResponse, CapacityVerdict, SetGrowth};
use gunnar_ssh::{headroom::human, Headroom};
use crate::dashboard::probe::{self, DiskKind};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GrowthFacts {
pub kind: Option<DiskKind>,
pub is_pid1: bool,
pub grow: Option<gunnar_grpc::grow_journal::Summary>,
pub runtime_engine: bool,
pub hotplug: bool,
}
impl GrowthFacts {
pub fn measure(root: &Path) -> GrowthFacts {
GrowthFacts {
kind: crate::dashboard::probe::disk_kind(root),
is_pid1: std::process::id() == 1,
grow: gunnar_grpc::grow_journal::Summary::measure(),
runtime_engine: cfg!(feature = "grow-runtime"),
hotplug: crate::dashboard::probe::pci_hotplug().present,
}
}
}
pub fn decide_growth(facts: &GrowthFacts, root: &Path) -> (SetGrowth, String) {
let root = root.display();
match (&facts.kind, facts.is_pid1) {
(None, _) => (
SetGrowth::GrowthUnknown,
format!(
"gunnar cannot tell what {root} sits on — it is not a mount point of its own, \
so whether this set can grow is UNKNOWN, which is not a yes"
),
),
(Some(kind), true) if facts.grow.is_some() && facts.runtime_engine && facts.hotplug => {
let journal = facts.grow.as_ref().expect("guarded");
let n = kind.set.as_ref().map(|s| s.members).unwrap_or(1);
(
SetGrowth::GrowthAtRuntime,
format!(
"AND THE SET GROWS WITHOUT A RESTART: {root} is a {kind} set, this gunnar-server \
links the grow engine, the kernel has PCI hot-plug and the journal is on this box — `Admin.GrowStart` drains \
the box, unmounts, waits for ALL {n} members to come back larger at one equal \
size, grows in place and serves again in minutes; a crash mid-way is finished \
at the next boot. Grow journal: {}",
journal.detail()
),
)
}
(Some(kind), true) if facts.grow.is_some() => {
let journal = facts.grow.as_ref().expect("guarded");
let n = kind.set.as_ref().map(|s| s.members).unwrap_or(1);
(
SetGrowth::GrowthAtRestart,
format!(
"AND THE SET GROWS AT RESTART: {root} is a {kind} set and this box's boot stack \
(korp-start) grows it into enlarged member volumes at the next boot — enlarge \
ALL {n} members to one equal size, restart, and the next measurement reads the \
larger set. It does NOT grow while gunnar runs{}, and a disk ATTACHED to this \
machine is still invisible to it. Grow journal: {}",
if facts.runtime_engine && !facts.hotplug {
" (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)"
} else {
""
},
journal.detail()
),
)
}
(Some(kind), true) => {
let laid = if kind.set.is_some() {
" laid once at install (korp-installer `lay_set`, InstallMode::Fresh only)"
} else {
""
};
let engine = if facts.runtime_engine {
" (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)"
} else {
""
};
(
SetGrowth::GrowthSealed,
format!(
"AND THE SET CANNOT GROW WHILE GUNNAR RUNS: {root} is a {kind} set{laid}, \
this box's boot stack keeps no grow journal{engine}, and gunnar is PID 1 \
with no shell — so a disk attached to this machine would be UNUSED AND \
INVISIBLE. Serving more needs a fresh install onto a larger set, not \
another disk"
),
)
}
(Some(kind), false) => (
SetGrowth::GrowthOperatorOnly,
format!(
"gunnar will not grow the set itself here — the grow engine runs only where \
gunnar is PID 1 over a set its own boot stack laid, and this process is not \
PID 1 — but that is also why an operator with a shell can extend {root} \
({kind}) out of band, and the next measurement will see it"
),
),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct MachineFacts {
pub cpus: Result<usize, String>,
pub memory: Result<(u64, u64), String>,
pub cgroup_memory_max: Option<u64>,
pub load1: Result<f64, String>,
pub disk: Option<crate::disktest::Outcome>,
}
impl MachineFacts {
pub fn measure() -> MachineFacts {
let cpus = std::thread::available_parallelism()
.map(|n| n.get())
.map_err(|err| format!("the affinity mask could not be read ({err})"));
let mut memory = probe::meminfo();
let mut cgroup_memory_max = None;
if let (Ok((total, available)), Some((cap, used))) =
(&memory, crate::budget::cgroup_memory())
{
if cap < *total {
cgroup_memory_max = Some(cap);
memory = Ok((cap, (*available).min(cap.saturating_sub(used))));
}
}
MachineFacts {
cpus,
memory,
cgroup_memory_max,
load1: probe::load1_value(),
disk: crate::disktest::last(),
}
}
}
pub fn disk_fields(disk: &Option<crate::disktest::Outcome>) -> CapacityResponse {
use crate::disktest::{Outcome, CACHED_BPS};
use gunnar_proto::DiskTest;
let bps = |v: f64| -> Option<u64> {
(v.is_finite() && v > 0.0).then(|| v.round() as u64)
};
match disk {
None => CapacityResponse {
disk_test: DiskTest::DisktestNotRun as i32,
disk_reason: "The start-up disk test has not finished a run in this process: its \
gate is closed (not the appliance's PID 1 and GUNNAR_DISKTEST unset) \
or it is still running. Every disk_* speed field is absent — this is \
NOT a report of a slow disk."
.to_owned(),
..Default::default()
},
Some(Outcome::Skipped { free, need }) => CapacityResponse {
disk_test: DiskTest::DisktestSkipped as i32,
disk_reason: format!(
"The start-up disk test SKIPPED: {} free on the data root, below the {} it \
needs, and the test must never be the thing that fills the disk. Every disk_* \
speed field is absent — nothing was measured.",
human(*free),
human(*need)
),
..Default::default()
},
Some(Outcome::Failed(why)) => CapacityResponse {
disk_test: DiskTest::DisktestFailed as i32,
disk_reason: format!(
"The start-up disk test FAILED ({why}). Every disk_* speed field is absent — \
the run did not produce numbers."
),
..Default::default()
},
Some(Outcome::Done(r)) => {
let cached = r.seq_read_bps > CACHED_BPS;
let mut clauses = Vec::with_capacity(3);
let disk_seq_write_bps = bps(r.seq_write_bps);
match disk_seq_write_bps {
Some(_) => clauses.push(format!(
"sequential write {}",
crate::disktest::fmt_rate(r.seq_write_bps)
)),
None => clauses
.push("sequential write UNMEASURED (the leg reported no rate)".to_owned()),
}
let disk_seq_read_bps = if cached {
clauses.push(format!(
"sequential read CACHED and therefore ABSENT: the leg came back at {} \
({}), over the {} bar, so the page cache answered and the device was never \
touched — this is not a fast disk, it is no reading of the disk at all",
crate::disktest::fmt_rate(r.seq_read_bps),
r.read_how.as_str(),
crate::disktest::fmt_rate(CACHED_BPS)
));
None
} else {
let v = bps(r.seq_read_bps);
match v {
Some(_) => clauses.push(format!(
"sequential read {} ({})",
crate::disktest::fmt_rate(r.seq_read_bps),
r.read_how.as_str()
)),
None => clauses
.push("sequential read UNMEASURED (the leg reported no rate)".to_owned()),
}
v
};
let disk_sync_milliops_per_s = (r.sync_ops_per_s.is_finite() && r.sync_ops_per_s > 0.0)
.then(|| (r.sync_ops_per_s * 1000.0).round() as u64);
match disk_sync_milliops_per_s {
Some(_) => clauses.push(format!(
"4 KiB overwrite + fsync {:.0} ops/s",
r.sync_ops_per_s
)),
None => {
clauses.push("4 KiB fsync UNMEASURED (the leg reported no rate)".to_owned())
}
}
let us =
|d: std::time::Duration| Some(u64::try_from(d.as_micros()).unwrap_or(u64::MAX));
CapacityResponse {
disk_test: DiskTest::DisktestMeasured as i32,
disk_seq_write_bps,
disk_seq_read_bps,
disk_sync_milliops_per_s,
disk_read_how: r.read_how.as_str().to_owned(),
disk_test_bytes: Some(r.file_bytes),
disk_write_us: us(r.write_elapsed),
disk_read_us: us(r.read_elapsed),
disk_sync_ops: Some(r.sync_ops),
disk_sync_us: us(r.sync_elapsed),
disk_test_us: us(r.elapsed),
disk_reason: format!(
"This disk, at start-up, on an idle box: {}. Taken in {:.1} s.",
clauses.join("; "),
r.elapsed.as_secs_f64()
),
..Default::default()
}
}
}
}
pub fn machine_fields(m: &MachineFacts) -> CapacityResponse {
let mut clauses = Vec::with_capacity(3);
let cpu_millicores_total = match &m.cpus {
Ok(n) => {
clauses.push(format!(
"{n} CPUs = {} millicores gunnar may run on (affinity mask, cgroup cpu.max limit \
applied)",
n * 1000
));
Some((*n as u64) * 1000)
}
Err(why) => {
clauses.push(format!(
"cores UNMEASURED ({why}), so cpu_millicores_total is absent"
));
None
}
};
let (ram_bytes_total, ram_bytes_available) = match &m.memory {
Ok((total, available)) => {
let clamp = match m.cgroup_memory_max {
Some(cap) => format!(
" — clamped to the cgroup memory.max of {}, which is what this process \
may use; /proc/meminfo reports the host",
human(cap)
),
None => String::new(),
};
clauses.push(format!(
"RAM {} total, {} available (/proc/meminfo MemTotal/MemAvailable{clamp})",
human(*total),
human(*available)
));
(Some(*total), Some(*available))
}
Err(why) => {
clauses.push(format!(
"RAM UNMEASURED ({why}), so ram_bytes_total and ram_bytes_available are absent"
));
(None, None)
}
};
let cpu_millicores_busy = match &m.load1 {
Ok(load) => {
let busy = (load * 1000.0).round().max(0.0) as u64;
clauses.push(format!(
"1-min load {load:.2} ≈ {busy} millicores busy (/proc/loadavg × 1000: an \
APPROXIMATION of the whole box's runnable tasks, not gunnar's own CPU time)"
));
Some(busy)
}
Err(why) => {
clauses.push(format!(
"load UNMEASURED ({why}), so cpu_millicores_busy is absent"
));
None
}
};
let d = disk_fields(&m.disk);
CapacityResponse {
cpu_millicores_total,
ram_bytes_total,
ram_bytes_available,
cpu_millicores_busy,
machine_reason: format!("This box: {}.", clauses.join("; ")),
..d
}
}
pub fn measure_disks(
data_root: &Path,
slow_root: Option<&Path>,
floor: gunnar_ssh::DiskFloor,
) -> gunnar_proto::DiskSnapshot {
let mut volumes = vec![
disk_snapshot::Volume::new("system", "/"),
disk_snapshot::Volume::new("data", data_root),
];
if let Some(slow) = slow_root {
volumes.push(disk_snapshot::Volume::new("serialdat", slow));
}
let snap = disk_snapshot::take(
&volumes,
disk_snapshot::Floor {
percent: floor.percent,
min_bytes: floor.bytes,
},
);
disks_to_wire(snap, gunnar_core::now_ms())
}
pub fn serialdat_breakdown(
known: &[(&str, PathBuf)],
used: Option<u64>,
) -> Vec<gunnar_proto::PathUse> {
let mut rows: Vec<gunnar_proto::PathUse> = known
.iter()
.map(|(label, path)| {
let mut row = gunnar_proto::PathUse {
label: (*label).to_owned(),
path: path.display().to_string(),
..Default::default()
};
match allocated_bytes(path) {
Ok(bytes) => {
row.measured = true;
row.bytes = bytes;
}
Err(why) => row.error = why,
}
row
})
.collect();
let inner: Vec<(PathBuf, u64)> = known
.iter()
.zip(rows.iter())
.filter(|(_, r)| r.measured)
.map(|((_, p), r)| (p.clone(), r.bytes))
.collect();
for ((_, outer), row) in known.iter().zip(rows.iter_mut()) {
if !row.measured {
continue;
}
let nested: u64 = inner
.iter()
.filter(|(p, _)| p != outer && p.starts_with(outer))
.map(|(_, b)| *b)
.sum();
row.bytes = row.bytes.saturating_sub(nested);
}
if let Some(used) = used {
let known_sum: u64 = rows.iter().filter(|r| r.measured).map(|r| r.bytes).sum();
rows.push(gunnar_proto::PathUse {
label: "other".to_owned(),
path: String::new(),
measured: true,
bytes: used.saturating_sub(known_sum),
error: String::new(),
});
}
rows
}
fn allocated_bytes(path: &Path) -> Result<u64, String> {
use std::os::unix::fs::MetadataExt as _;
let meta = match std::fs::symlink_metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(e) => return Err(format!("{}: {e}", path.display())),
};
let mut total = meta.blocks() * 512;
if meta.is_dir() {
let entries = std::fs::read_dir(path).map_err(|e| format!("{}: {e}", path.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("{}: {e}", path.display()))?;
total += allocated_bytes(&entry.path())?;
}
}
Ok(total)
}
pub fn disks_to_wire(snap: disk_snapshot::Snapshot, at_unix_ms: i64) -> gunnar_proto::DiskSnapshot {
let mounts = snap
.disks
.into_iter()
.map(|d| {
let mut row = gunnar_proto::DiskMount {
source: d.mount.source,
mount_point: d.mount.mount_point.display().to_string(),
fs_type: d.mount.fstype,
read_only: d.mount.read_only,
labels: d.labels,
floor_bytes: d.floor_bytes,
servable_bytes: d.servable_bytes,
..Default::default()
};
match d.figures {
Ok(f) => {
row.measured = true;
row.total_bytes = f.total_bytes;
row.free_bytes = f.free_bytes;
row.available_bytes = f.available_bytes;
row.used_bytes = f.used_bytes;
row.reserved_bytes = f.reserved_bytes;
row.inodes_total = f.inodes_total;
row.inodes_free = f.inodes_free;
}
Err(why) => row.error = why,
}
row
})
.collect();
let devices = snap
.block_devices
.into_iter()
.map(|b| gunnar_proto::BlockDevice {
name: b.name,
size_bytes: b.size_bytes,
rotational: b.rotational,
removable: b.removable,
read_only: b.read_only,
partitions: b
.partitions
.into_iter()
.map(|(name, size_bytes)| gunnar_proto::BlockPartition { name, size_bytes })
.collect(),
holders: b.holders,
members: b.members,
raid_level: b.raid_level,
})
.collect();
let unresolved = snap
.unresolved
.into_iter()
.map(|u| gunnar_proto::UnresolvedDisk {
label: u.label,
path: u.path.display().to_string(),
why: u.why,
})
.collect();
gunnar_proto::DiskSnapshot {
measured_at_unix_ms: at_unix_ms,
mounts,
devices,
unresolved,
problems: snap.problems,
serialdat: Vec::new(),
}
}
#[derive(Debug, Clone)]
pub struct Question {
pub root: PathBuf,
pub room: Result<Headroom, String>,
pub floor_spec: String,
pub requested_bytes: u64,
pub held_bytes: u64,
pub namespace: String,
pub growth: GrowthFacts,
pub machine: MachineFacts,
}
pub fn answer(q: &Question) -> CapacityResponse {
let (growth, growth_clause) = decide_growth(&q.growth, &q.root);
let mut machine = machine_fields(&q.machine);
machine.grow_state = q.growth.grow.as_ref().map(|s| s.to_wire());
machine.hotplug = q.growth.hotplug;
let data_set = q
.growth
.kind
.as_ref()
.map(|k| k.to_string())
.unwrap_or_default();
let root = q.root.display();
let additional = q.requested_bytes.saturating_sub(q.held_bytes);
let asked = if q.namespace.is_empty() {
format!("a cap of {}", human(q.requested_bytes))
} else {
format!(
"raising {} to {} (it holds {})",
q.namespace,
human(q.requested_bytes),
human(q.held_bytes)
)
};
let room = match &q.room {
Err(err) => {
return CapacityResponse {
verdict: CapacityVerdict::CapacityUnmeasured as i32,
growth: growth as i32,
root: q.root.display().to_string(),
requested_bytes: q.requested_bytes,
held_bytes: q.held_bytes,
additional_bytes: additional,
data_set,
reason: format!(
"{root} could not be measured ({err}), so gunnar will not answer that {asked} \
can be served — an unmeasured box has not said yes. {growth_clause}"
),
total_bytes: 0,
free_bytes: 0,
floor_bytes: 0,
servable_bytes: 0,
shortfall_bytes: 0,
..machine
};
}
Ok(room) => *room,
};
let servable = room.free.saturating_sub(room.floor);
let measured = format!(
"{root} has {} free of {}, {} of it reserved by the disk floor (--disk-floor {}), so {} \
may be served",
human(room.free),
human(room.total),
human(room.floor),
q.floor_spec,
human(servable),
);
let (verdict, shortfall, outcome) = if additional <= servable {
(
CapacityVerdict::CapacityAvailable,
0,
format!("needs {} more: YES, it can be served.", human(additional)),
)
} else {
let shortfall = additional - servable;
(
CapacityVerdict::CapacityExhausted,
shortfall,
format!(
"needs {} more, which is {} beyond that: NO.",
human(additional),
human(shortfall)
),
)
};
CapacityResponse {
verdict: verdict as i32,
growth: growth as i32,
root: q.root.display().to_string(),
total_bytes: room.total,
free_bytes: room.free,
floor_bytes: room.floor,
servable_bytes: servable,
requested_bytes: q.requested_bytes,
held_bytes: q.held_bytes,
additional_bytes: additional,
shortfall_bytes: shortfall,
data_set,
reason: format!("{measured}. {asked} {outcome} {growth_clause}"),
..machine
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dashboard::probe::SetFacts;
const GIB: u64 = 1024 * 1024 * 1024;
fn appliance() -> GrowthFacts {
GrowthFacts {
kind: Some(DiskKind {
fs: "xfs".into(),
set: Some(SetFacts {
level: "raid0".into(),
members: 4,
member_bytes: 75 * GIB,
chunk_bytes: 512 * 1024,
}),
}),
is_pid1: true,
grow: None,
runtime_engine: false,
hotplug: false,
}
}
fn developer_box() -> GrowthFacts {
GrowthFacts {
is_pid1: false,
..appliance()
}
}
fn growing_appliance(text: &str) -> GrowthFacts {
GrowthFacts {
grow: Some(gunnar_grpc::grow_journal::Summary::of(
text,
"/serialdat/grow-journal",
)),
..appliance()
}
}
#[test]
fn the_engine_in_the_server_with_the_journal_on_the_box_grows_at_runtime() {
let both = GrowthFacts {
runtime_engine: true,
hotplug: true,
..growing_appliance("")
};
let (v, line) = decide_growth(&both, Path::new("/data"));
assert_eq!(v, SetGrowth::GrowthAtRuntime);
assert!(
line.contains("WITHOUT A RESTART")
&& line.contains("Admin.GrowStart")
&& line.contains("ALL 4 members"),
"{line}"
);
let engine_only = GrowthFacts {
runtime_engine: true,
hotplug: true,
..appliance()
};
assert_eq!(
decide_growth(&engine_only, Path::new("/data")).0,
SetGrowth::GrowthSealed,
"no journal: the engine alone proves nothing"
);
assert_eq!(
decide_growth(&growing_appliance(""), Path::new("/data")).0,
SetGrowth::GrowthAtRestart,
"journal alone: the restart arm"
);
}
#[test]
fn the_engine_without_a_hotplug_kernel_grows_only_at_restart_and_says_why() {
let no_hp = GrowthFacts {
runtime_engine: true,
hotplug: false,
..growing_appliance("")
};
let (v, line) = decide_growth(&no_hp, Path::new("/data"));
assert_eq!(v, SetGrowth::GrowthAtRestart);
assert!(
line.contains("NO PCI hot-plug") && line.contains("refused by name"),
"{line}"
);
let mut q = question(gunnar_rs_today(), no_hp.clone());
q.requested_bytes = 1;
assert!(!answer(&q).hotplug);
q.growth.hotplug = true;
let a = answer(&q);
assert!(
a.hotplug && a.growth == SetGrowth::GrowthAtRuntime as i32,
"{a:?}"
);
q.room = Err("EIO".into());
assert!(answer(&q).hotplug, "the unmeasured arm carries it too");
}
#[test]
fn a_boot_stack_with_the_grow_engine_grows_at_restart_and_reports_the_journal() {
let (v, line) = decide_growth(&growing_appliance(""), Path::new("/data"));
assert_eq!(v, SetGrowth::GrowthAtRestart);
assert!(
line.contains("GROWS AT RESTART")
&& line.contains("ALL 4 members")
&& line.contains("invisible to it"),
"{line}"
);
assert!(line.contains("no grow has run"), "{line}");
assert_eq!(
decide_growth(&appliance(), Path::new("/data")).0,
SetGrowth::GrowthSealed,
"no journal: sealed as before"
);
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";
let (v, line) = decide_growth(&growing_appliance(partial), Path::new("/data"));
assert_eq!(v, SetGrowth::GrowthAtRestart);
assert!(
line.contains("PARTIAL RESIZE: 2 member(s) [0, 1]"),
"{line}"
);
let done = "epoch=1 target_sectors=134215680 member=- step=G1 at=1\nepoch=1 target_sectors=134215680 member=- step=G6 at=2\n";
let mut q = question(gunnar_rs_today(), growing_appliance(done));
q.requested_bytes = 1;
let a = answer(&q);
assert_eq!(a.growth, SetGrowth::GrowthAtRestart as i32);
let g = a.grow_state.expect("the journal is on the wire");
assert!(
g.engine_present
&& g.phase == "done"
&& g.epoch == 1
&& g.target_sectors == 134_215_680
&& g.journal_path == "/serialdat/grow-journal",
"{g:?}"
);
assert!(a.reason.contains("epoch 1 done"), "{}", a.reason);
q.requested_bytes = 1000 * GIB;
assert!(answer(&q).grow_state.is_some(), "the no arm carries it too");
q.room = Err("EIO".into());
assert!(answer(&q).grow_state.is_some(), "and the unmeasured arm");
let q = question(gunnar_rs_today(), appliance());
assert!(
answer(&q).grow_state.is_none(),
"no journal, no grow_state — an absence, never an empty one"
);
}
fn appliance_box() -> MachineFacts {
MachineFacts {
cpus: Ok(4),
memory: Ok((16 * GIB, 12 * GIB)),
cgroup_memory_max: None,
load1: Ok(0.42),
disk: Some(crate::disktest::Outcome::Done(crate::disktest::Report {
seq_write_bps: 812e6,
seq_read_bps: 1.9e9,
read_how: crate::disktest::ReadHow::Direct,
sync_ops_per_s: 3100.0,
elapsed: std::time::Duration::from_millis(1600),
file_bytes: 256 << 20,
write_elapsed: std::time::Duration::from_millis(330),
read_elapsed: std::time::Duration::from_millis(141),
sync_ops: 1550,
sync_elapsed: std::time::Duration::from_millis(500),
})),
}
}
fn question(room: Headroom, growth: GrowthFacts) -> Question {
Question {
root: PathBuf::from("/data"),
room: Ok(room),
floor_spec: "5%,2.0 GiB".into(),
requested_bytes: 0,
held_bytes: 0,
namespace: String::new(),
growth,
machine: appliance_box(),
}
}
fn gunnar_rs_today() -> Headroom {
let total = 300 * GIB;
Headroom {
total,
free: 70 * GIB,
floor: (total / 100) * 5,
}
}
#[test]
fn the_growth_verdict_separates_a_sealed_appliance_from_a_box_with_a_shell() {
let (sealed, sealed_line) = decide_growth(&appliance(), Path::new("/data"));
let (operator, operator_line) = decide_growth(&developer_box(), Path::new("/data"));
let (unknown, unknown_line) = decide_growth(
&GrowthFacts {
kind: None,
is_pid1: false,
grow: None,
runtime_engine: false,
hotplug: false,
},
Path::new("/tmp/x"),
);
assert_eq!(sealed, SetGrowth::GrowthSealed);
assert_eq!(operator, SetGrowth::GrowthOperatorOnly);
assert_eq!(unknown, SetGrowth::GrowthUnknown);
assert!(
sealed != operator && operator != unknown && sealed != unknown,
"the same facts must not collapse to one verdict"
);
assert!(
sealed_line.contains("UNUSED AND INVISIBLE"),
"the sealed clause must say an attached disk is wasted: {sealed_line}"
);
assert!(
sealed_line.contains("fresh install") && sealed_line.contains("not another disk"),
"and must name what WOULD work: {sealed_line}"
);
assert!(
sealed_line.contains("4\u{d7}75G raid0 xfs"),
"and must name the measured set, not a generic one: {sealed_line}"
);
assert!(
!operator_line.contains("UNUSED AND INVISIBLE")
&& operator_line.contains("an operator with a shell"),
"{operator_line}"
);
assert!(
unknown_line.contains("UNKNOWN, which is not a yes"),
"{unknown_line}"
);
}
#[test]
fn ninety_more_gigabytes_on_the_sealed_appliance_is_a_no_that_names_the_reinstall() {
let mut q = question(gunnar_rs_today(), appliance());
q.namespace = "tenant".into();
q.held_bytes = 10 * GIB;
q.requested_bytes = 100 * GIB;
let a = answer(&q);
assert_eq!(
a.verdict,
CapacityVerdict::CapacityExhausted as i32,
"{}",
a.reason
);
assert_eq!(a.growth, SetGrowth::GrowthSealed as i32);
assert_eq!(a.free_bytes, 70 * GIB);
assert_eq!(a.floor_bytes, 15 * GIB, "5 % of 300 GiB");
assert_eq!(a.servable_bytes, 55 * GIB, "free minus the floor, not free");
assert_eq!(
a.additional_bytes,
90 * GIB,
"the raise needs the DELTA, not the cap"
);
assert_eq!(a.shortfall_bytes, 35 * GIB);
assert_eq!(a.data_set, "4\u{d7}75G raid0 xfs");
assert!(a.reason.contains("NO."), "{}", a.reason);
assert!(a.reason.contains("UNUSED AND INVISIBLE"), "{}", a.reason);
}
#[test]
fn the_same_shortfall_on_a_box_with_a_shell_does_not_say_a_disk_is_wasted() {
let mut sealed = question(gunnar_rs_today(), appliance());
sealed.requested_bytes = 90 * GIB;
let mut shelled = sealed.clone();
shelled.growth = developer_box();
let a = answer(&sealed);
let b = answer(&shelled);
assert_eq!(a.verdict, b.verdict, "the shortfall is identical");
assert_eq!(a.shortfall_bytes, b.shortfall_bytes);
assert_ne!(a.growth, b.growth, "the growth answer is NOT the verdict");
assert_ne!(
a.reason, b.reason,
"and it changes what the operator is told"
);
assert!(b.reason.contains("NO.") && !b.reason.contains("UNUSED AND INVISIBLE"));
}
#[test]
fn a_request_that_fits_is_a_yes_that_still_says_the_set_is_sealed() {
let mut q = question(gunnar_rs_today(), appliance());
q.requested_bytes = 20 * GIB;
let a = answer(&q);
assert_eq!(a.verdict, CapacityVerdict::CapacityAvailable as i32);
assert_eq!(a.shortfall_bytes, 0);
assert!(a.reason.contains("YES"), "{}", a.reason);
assert_eq!(
a.growth,
SetGrowth::GrowthSealed as i32,
"the growth fact is reported on the YES arm too"
);
assert!(a.reason.contains("UNUSED AND INVISIBLE"), "{}", a.reason);
q.requested_bytes = 60 * GIB;
assert_eq!(
answer(&q).verdict,
CapacityVerdict::CapacityExhausted as i32,
"a request under free but over free-minus-floor must be refused: the floor is not \
for sale"
);
q.requested_bytes = 55 * GIB;
assert_eq!(
answer(&q).verdict,
CapacityVerdict::CapacityAvailable as i32
);
}
#[test]
fn a_filesystem_that_cannot_be_measured_is_not_a_yes_and_reports_no_numbers() {
let mut q = question(gunnar_rs_today(), appliance());
q.room = Err("No such file or directory (os error 2)".into());
q.requested_bytes = 1;
let a = answer(&q);
assert_eq!(a.verdict, CapacityVerdict::CapacityUnmeasured as i32);
assert_ne!(
a.verdict,
CapacityVerdict::CapacityAvailable as i32,
"one byte would fit on any real disk; an unmeasured one must still not say yes"
);
assert_eq!(
(a.total_bytes, a.free_bytes, a.floor_bytes, a.servable_bytes),
(0, 0, 0, 0),
"nothing was measured, so nothing may be reported"
);
assert!(a.reason.contains("os error 2"), "{}", a.reason);
assert!(a.reason.contains("has not said yes"), "{}", a.reason);
}
#[test]
fn the_appliance_box_is_reported_in_millicores_and_bytes_with_its_sources_named() {
let q = question(gunnar_rs_today(), appliance());
let a = answer(&q);
assert_eq!(a.cpu_millicores_total, Some(4000));
assert_eq!(a.ram_bytes_total, Some(16 * GIB));
assert_eq!(a.ram_bytes_available, Some(12 * GIB));
assert_eq!(a.cpu_millicores_busy, Some(420), "0.42 × 1000, rounded");
for word in [
"affinity mask",
"/proc/meminfo",
"/proc/loadavg",
"APPROXIMATION",
] {
assert!(
a.machine_reason.contains(word),
"{word}: {}",
a.machine_reason
);
}
assert!(
!a.machine_reason.contains("clamped"),
"no cgroup clamped the appliance: {}",
a.machine_reason
);
let mut unmeasured = q.clone();
unmeasured.room = Err("No such file or directory (os error 2)".into());
let u = answer(&unmeasured);
assert_eq!(
(
u.cpu_millicores_total,
u.ram_bytes_total,
u.ram_bytes_available,
u.cpu_millicores_busy
),
(
a.cpu_millicores_total,
a.ram_bytes_total,
a.ram_bytes_available,
a.cpu_millicores_busy
)
);
assert_eq!(u.machine_reason, a.machine_reason);
}
#[test]
fn an_unreadable_meminfo_leaves_the_ram_fields_absent_and_names_why() {
let mut q = question(gunnar_rs_today(), appliance());
q.machine.memory =
Err("/proc/meminfo could not be read (No such file or directory (os error 2))".into());
q.machine.load1 =
Err("/proc/loadavg could not be read (Permission denied (os error 13))".into());
let a = answer(&q);
assert_eq!(a.ram_bytes_total, None, "absent, not 0: {a:?}");
assert_eq!(a.ram_bytes_available, None);
assert_eq!(a.cpu_millicores_busy, None);
assert_ne!(
a.ram_bytes_total,
Some(0),
"a zero here would be a box with no RAM"
);
assert_eq!(
a.cpu_millicores_total,
Some(4000),
"the cores were measured and stay"
);
assert!(
a.machine_reason.contains("RAM UNMEASURED"),
"{}",
a.machine_reason
);
assert!(
a.machine_reason.contains("os error 2"),
"{}",
a.machine_reason
);
assert!(
a.machine_reason.contains("load UNMEASURED"),
"{}",
a.machine_reason
);
assert!(
a.machine_reason.contains("os error 13"),
"{}",
a.machine_reason
);
q.machine.cpus = Err("the affinity mask could not be read (os error 22)".into());
let none = answer(&q);
assert_eq!(
(
none.cpu_millicores_total,
none.ram_bytes_total,
none.ram_bytes_available,
none.cpu_millicores_busy
),
(None, None, None, None)
);
assert!(
none.machine_reason.contains("cores UNMEASURED"),
"{}",
none.machine_reason
);
assert_eq!(none.verdict, a.verdict);
assert_eq!(none.reason, a.reason);
}
#[test]
fn a_cgroup_memory_clamp_is_reported_as_the_ram_and_named_in_the_reason() {
let mut q = question(gunnar_rs_today(), appliance());
q.machine.memory = Ok((8 * GIB, 6 * GIB));
q.machine.cgroup_memory_max = Some(8 * GIB);
let a = answer(&q);
assert_eq!(a.ram_bytes_total, Some(8 * GIB));
assert!(
a.machine_reason
.contains("clamped to the cgroup memory.max of 8.0 GiB"),
"{}",
a.machine_reason
);
}
#[test]
fn the_disk_answer_does_not_change_with_the_box() {
let q = question(gunnar_rs_today(), appliance());
let mut blind = q.clone();
blind.machine = MachineFacts {
cpus: Err("x".into()),
memory: Err("y".into()),
cgroup_memory_max: None,
load1: Err("z".into()),
disk: None,
};
let (a, b) = (answer(&q), answer(&blind));
assert_ne!(a.machine_reason, b.machine_reason, "the fixtures differ");
assert_ne!(a.ram_bytes_total, b.ram_bytes_total);
let disk = |r: &CapacityResponse| {
CapacityResponse {
cpu_millicores_total: None,
ram_bytes_total: None,
ram_bytes_available: None,
cpu_millicores_busy: None,
machine_reason: String::new(),
disk_test: 0,
disk_seq_write_bps: None,
disk_seq_read_bps: None,
disk_sync_milliops_per_s: None,
disk_read_how: String::new(),
disk_reason: String::new(),
disk_test_bytes: None,
disk_write_us: None,
disk_read_us: None,
disk_sync_ops: None,
disk_sync_us: None,
disk_test_us: None,
..r.clone()
}
};
assert_eq!(disk(&a), disk(&b));
assert_ne!(a.disk_test, b.disk_test);
assert_ne!(a.disk_reason, b.disk_reason);
}
}
#[cfg(test)]
mod disk_on_the_wire {
use super::*;
use crate::disktest::{Outcome, ReadHow, Report};
use gunnar_proto::DiskTest;
use std::time::Duration;
fn done(seq_read_bps: f64) -> Outcome {
Outcome::Done(Report {
seq_write_bps: 812e6,
seq_read_bps,
read_how: ReadHow::Direct,
sync_ops_per_s: 3100.4,
elapsed: Duration::from_millis(1600),
file_bytes: 256 << 20,
write_elapsed: Duration::from_millis(330),
read_elapsed: Duration::from_millis(141),
sync_ops: 1550,
sync_elapsed: Duration::from_millis(500),
})
}
#[test]
fn a_measured_disk_carries_its_three_numbers_and_says_how_it_read() {
let a = disk_fields(&Some(done(1.9e9)));
assert_eq!(a.disk_test, DiskTest::DisktestMeasured as i32);
assert_eq!(a.disk_seq_write_bps, Some(812_000_000));
assert_eq!(a.disk_seq_read_bps, Some(1_900_000_000));
assert_eq!(a.disk_sync_milliops_per_s, Some(3_100_400));
assert_eq!(a.disk_read_how, "direct");
assert!(a.disk_reason.contains("812 MB/s"), "{}", a.disk_reason);
assert!(a.disk_reason.contains("1.9 GB/s"), "{}", a.disk_reason);
assert_eq!(a.disk_test_bytes, Some(256 << 20));
assert_eq!(
(a.disk_write_us, a.disk_read_us),
(Some(330_000), Some(141_000))
);
assert_eq!(
(a.disk_sync_ops, a.disk_sync_us),
(Some(1550), Some(500_000))
);
assert_eq!(a.disk_test_us, Some(1_600_000));
}
#[test]
fn a_cached_read_is_absent_from_the_wire_and_is_never_reported_as_a_fast_disk() {
let a = disk_fields(&Some(done(7.2e9)));
assert_eq!(
a.disk_test,
DiskTest::DisktestMeasured as i32,
"the run DID measure"
);
assert_eq!(a.disk_seq_read_bps, None, "not 7.2 GB/s and not 0 — absent");
assert_eq!(
a.disk_seq_write_bps,
Some(812_000_000),
"the other legs stand"
);
assert_eq!(a.disk_sync_milliops_per_s, Some(3_100_400));
assert!(a.disk_reason.contains("CACHED"), "{}", a.disk_reason);
assert!(
a.disk_reason.contains("not a fast disk"),
"the reason says what the absence MEANS: {}",
a.disk_reason
);
}
#[test]
fn a_disk_that_was_not_measured_is_named_and_carries_no_number_at_all() {
let cases = [
(None, DiskTest::DisktestNotRun, "gate"),
(
Some(Outcome::Skipped {
free: 800 << 20,
need: 1 << 30,
}),
DiskTest::DisktestSkipped,
"SKIPPED",
),
(
Some(Outcome::Failed("write: No space left on device".into())),
DiskTest::DisktestFailed,
"FAILED",
),
];
for (outcome, want, word) in cases {
let a = disk_fields(&outcome);
assert_eq!(a.disk_test, want as i32, "{outcome:?}");
assert_eq!(a.disk_seq_write_bps, None, "{outcome:?}");
assert_eq!(a.disk_seq_read_bps, None, "{outcome:?}");
assert_eq!(a.disk_sync_milliops_per_s, None, "{outcome:?}");
assert!(a.disk_read_how.is_empty(), "{outcome:?}");
assert_eq!(
(
a.disk_test_bytes,
a.disk_write_us,
a.disk_read_us,
a.disk_sync_ops,
a.disk_sync_us,
a.disk_test_us
),
(None, None, None, None, None, None),
"no run, no bytes and no times: {outcome:?}"
);
assert!(!a.disk_reason.is_empty(), "never silent: {outcome:?}");
assert!(a.disk_reason.contains(word), "{}", a.disk_reason);
}
let s = disk_fields(&Some(Outcome::Skipped {
free: 800 << 20,
need: 1 << 30,
}));
assert!(
s.disk_reason.contains("800") && s.disk_reason.contains("1.0"),
"{}",
s.disk_reason
);
let reasons: std::collections::BTreeSet<String> = [
disk_fields(&None).disk_reason,
disk_fields(&Some(Outcome::Skipped { free: 0, need: 1 })).disk_reason,
disk_fields(&Some(Outcome::Failed("x".into()))).disk_reason,
]
.into_iter()
.collect();
assert_eq!(reasons.len(), 3);
}
#[test]
fn the_box_fields_and_the_disk_fields_come_out_of_one_call() {
let m = MachineFacts {
cpus: Ok(4),
memory: Ok((16 << 30, 12 << 30)),
cgroup_memory_max: None,
load1: Ok(0.42),
disk: Some(done(1.9e9)),
};
let a = machine_fields(&m);
assert_eq!(a.cpu_millicores_total, Some(4000));
assert_eq!(a.disk_seq_write_bps, Some(812_000_000));
assert!(!a.machine_reason.is_empty() && !a.disk_reason.is_empty());
let blind = machine_fields(&MachineFacts { disk: None, ..m });
assert_eq!(blind.cpu_millicores_total, Some(4000));
assert_eq!(blind.disk_test, DiskTest::DisktestNotRun as i32);
}
#[test]
fn the_latch_hands_back_what_was_recorded_and_runs_nothing() {
let before = crate::disktest::last();
crate::disktest::record(&done(1.9e9));
assert_eq!(crate::disktest::last(), Some(done(1.9e9)));
crate::disktest::record(&Outcome::Failed("boom".into()));
assert_eq!(
crate::disktest::last(),
Some(Outcome::Failed("boom".into()))
);
if let Some(b) = before {
crate::disktest::record(&b);
}
}
}
#[cfg(test)]
mod disk_tests {
use super::*;
#[test]
fn a_snapshot_reaches_the_wire_and_an_unmeasured_mount_is_not_a_number() {
let mount = |src: &str, at: &str| disk_snapshot::Mount {
source: src.to_owned(),
mount_point: PathBuf::from(at),
fstype: "xfs".to_owned(),
read_only: false,
};
let snap = disk_snapshot::Snapshot {
disks: vec![
disk_snapshot::DiskEntry {
mount: mount("/dev/md0", "/data"),
labels: vec!["data".to_owned()],
paths: vec![PathBuf::from("/data")],
figures: disk_snapshot::Figures::from_counts(1000, 400, 350, 4096, 10, 5),
floor_bytes: 4096 * 50,
servable_bytes: 4096 * 300,
},
disk_snapshot::DiskEntry {
mount: mount("/dev/vda2", "/"),
labels: vec!["system".to_owned()],
paths: vec![PathBuf::from("/")],
figures: Err("statvfs(/): EIO".to_owned()),
floor_bytes: 0,
servable_bytes: 0,
},
],
unresolved: vec![],
block_devices: vec![disk_snapshot::BlockDevice {
name: "md0".to_owned(),
size_bytes: 4096 * 1000,
members: vec!["vdb1".to_owned(), "vdc1".to_owned()],
raid_level: "raid0".to_owned(),
..Default::default()
}],
problems: vec![],
};
let wire = disks_to_wire(snap, 42);
assert_eq!(wire.measured_at_unix_ms, 42);
let data = &wire.mounts[0];
assert!(data.measured && data.labels == ["data"]);
assert_eq!(
(
data.total_bytes,
data.free_bytes,
data.available_bytes,
data.used_bytes,
data.reserved_bytes
),
(4096 * 1000, 4096 * 400, 4096 * 350, 4096 * 600, 4096 * 50)
);
assert_eq!(
(data.floor_bytes, data.servable_bytes),
(4096 * 50, 4096 * 300)
);
let system = &wire.mounts[1];
assert!(!system.measured && system.error.contains("EIO") && system.total_bytes == 0);
assert_eq!(wire.devices[0].members, ["vdb1", "vdc1"]);
assert_eq!(wire.devices[0].raid_level, "raid0");
}
#[test]
fn the_serialdat_breakdown_sums_to_the_mounts_use() {
use std::io::Write as _;
let dir = tempfile::tempdir().expect("a temp mount");
let root = dir.path();
let write = |rel: &str, n: usize| {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
let mut f = std::fs::File::create(&p).unwrap();
f.write_all(&vec![7u8; n]).unwrap();
f.sync_all().unwrap();
};
write("meta/principals.json", 10_000);
write("log/audit-chain.jsonl", 70_000);
write("log/console.log", 30_000);
write("crash/last.txt", 5_000);
let known = vec![
("metadata copy", root.join("meta")),
("crash record", root.join("crash")),
("audit trail", root.join("log/audit-chain.jsonl")),
("logs", root.join("log")),
("grow journal", root.join("grow-journal")),
];
let used = 10 << 20;
let rows = serialdat_breakdown(&known, Some(used));
let get = |label: &str| {
rows.iter()
.find(|r| r.label == label)
.unwrap_or_else(|| panic!("{label}: {rows:?}"))
};
assert!(rows.iter().all(|r| r.measured), "{rows:?}");
assert!(get("audit trail").bytes >= 70_000, "{rows:?}");
let logs = get("logs").bytes;
assert!(
(30_000..70_000).contains(&logs),
"the audit trail was counted inside the logs too: {rows:?}"
);
assert_eq!(
get("grow journal").bytes,
0,
"nothing there is 0, not an error"
);
let sum: u64 = rows.iter().map(|r| r.bytes).sum();
assert_eq!(
sum, used,
"the rows must add up to the mount's used bytes: {rows:?}"
);
assert!(serialdat_breakdown(&known, None)
.iter()
.all(|r| r.label != "other"));
}
#[test]
fn this_machines_disks_carry_gunnars_labels() {
let dir = tempfile::tempdir().expect("a temp dir");
let wire = measure_disks(dir.path(), None, gunnar_ssh::DiskFloor::default());
assert!(wire.problems.is_empty(), "{:?}", wire.problems);
let data = wire
.mounts
.iter()
.find(|m| m.labels.iter().any(|l| l == "data"))
.expect("the data root is on some mount");
assert!(data.measured && data.total_bytes > 0, "{data:?}");
assert!(
wire.mounts
.iter()
.any(|m| m.labels.iter().any(|l| l == "system")),
"{:?}",
wire.mounts
);
}
}