crates/gunnar-ui-server/src/api/disks.rs
//! **Every disk, for the administrator** — `GET /api/disks`.
//!
//! The data set (used / free / floor / servable, and its members), the
//! system disk, every other storage mount, the block devices under them, the
//! exploded read cache (its size, its budget and its share of the data set),
//! and each twin's volume — on one admin read.
//!
//! # Admin, and enforced here, before gunnar is dialled
//!
//! `/api/disks` is on `role_floor`'s `ADMIN_READS`, beside `/api/machine` and
//! for the same reason: device names, md members and every mount point on the
//! box are its hardware identity, not any tenant's business. A viewer or an
//! operator is refused by the middleware, so the handler never runs and no
//! call reaches gunnar. A tenant's OWN usage is `/api/namespaces/{ns}/usage`,
//! answered with their own key (and 404 for anybody else's, `api::absent`).
//!
//! # Three calls, each allowed to fail alone
//!
//! * `Admin.Capacity { disks: true }` — the data set's figures and, from a
//! gunnar that has it (CapacityResponse field 27, gunnar branch
//! `ui-404-admin-disks`), every mount and block device, measured by edda's
//! `disk-snapshot` on the box. A gunnar older than the field answers the data
//! set and no disks, and the page says so by name.
//! * `Admin.StorageInfo` — the explode read cache's bytes and budget.
//! * `Admin.Health` — each twin's reported volume.
//!
//! A source that does not answer is a line in `unanswered`, in gunnar's words,
//! and its part of the page is empty rather than zero. Nothing here is derived
//! that the box did not measure, with one exception said out loud: the read
//! cache's `share` is its bytes over the data set's used bytes, two readings
//! of the same box a moment apart.
//!
//! # The read cache's hysteresis is not reported yet, and that is carried
//!
//! znippy-plugin-git 0.1.6 brings the 80 % / 75 % high/low-water state of the
//! explode cache; the gunnar this console talks to today (plugin 0.1.5) exposes
//! only the bytes and the budget. `hysteresis` is therefore always absent and
//! `hysteresis_note` says why, so the page never draws a state nobody measured.
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use crate::api::ApiResult;
use crate::app::AppState;
use crate::pb;
/// Why the read cache has no hysteresis state on this page.
pub const HYSTERESIS_NOT_REPORTED: &str = "Gunnar does not report the read cache's 80 % / 75 % \
high/low-water state yet; it arrives with znippy-plugin-git 0.1.6";
/// Why there are no per-disk rows from an older gunnar.
pub const NO_DISKS: &str = "this Gunnar answers Admin.Capacity without `disks`: it is older than \
the per-disk report (CapacityResponse field 27), so only the data set is shown";
/// `GET /api/disks`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct Disks {
/// The store's data set, `Admin.Capacity`'s own figures. `None` when that
/// call did not answer.
pub data_set: Option<DataSet>,
/// The mount gunnar labels `system` (`/`). `None` when not reported.
pub system: Option<Mount>,
/// Every storage mount on the box, the data set's and the system's
/// included, in mount-table order.
pub mounts: Vec<Mount>,
/// Every `/sys/block` device.
pub devices: Vec<Device>,
/// The exploded read cache. `None` when `Admin.StorageInfo` did not answer.
pub read_cache: Option<ReadCache>,
/// Each twin's volume, as its heartbeat reports it. Empty is "no twin
/// reported" only when `Admin.Health` answered (see `unanswered`).
pub twins: Vec<TwinDisk>,
/// When the disks were measured, the box's clock.
pub measured_at_unix_ms: Option<i64>,
/// What the box could not read or place, in its words.
pub problems: Vec<String>,
/// The sources that did not answer, in words. Their parts are empty.
pub unanswered: Vec<String>,
/// **The server's MEASURED disk speed**: the start-up disk test, with the
/// bytes and the time behind each rate. `None` when `Admin.Capacity` did
/// not answer.
pub disk_speed: Option<DiskSpeed>,
/// **Write amplification**: bytes that land on disk per byte pushed.
/// Always reported as not measured today, with why — see [`NotMeasured`].
pub write_amplification: NotMeasured,
/// **The priority transfer** (used / limit over 30 days, class, queue
/// length) from edda's `admit` trait. Not in the gunnar this console talks
/// to, so not measured, with why.
pub priority_transfer: NotMeasured,
/// **/serialdat** — the system disk's P3 (metadata copy, crash record,
/// logs, audit trail, grow journal): its mount and what is on it.
pub serialdat: Option<Serialdat>,
/// The same, and every disk, on the TWIN (`GUNNAR_UI_TWIN`), asked with
/// the console's key. `None` when no twin is configured.
pub twin_box: Option<TwinBox>,
/// Things an administrator must act on: a /serialdat past 80 % (here or
/// on the twin).
pub warnings: Vec<String>,
}
/// /serialdat: the mount's statvfs and the breakdown by known path.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct Serialdat {
pub mount: Mount,
pub breakdown: Vec<PathRow>,
}
/// One known path on /serialdat (or `other`).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct PathRow {
pub label: String,
pub path: String,
/// `None` when the walk failed (`error` says why) — never a 0.
pub bytes: Option<u64>,
pub error: String,
}
/// The twin's disks, as its own gunnar reports them.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct TwinBox {
pub address: String,
pub mounts: Vec<Mount>,
pub serialdat: Option<Serialdat>,
/// Why there is nothing, in words, when the twin did not answer.
pub unanswered: String,
}
/// /serialdat's warning line: past this share of its mount, an admin acts.
pub const SERIALDAT_WARN_PERMILLE: u32 = 800;
fn serialdat_of(mounts: &[Mount], breakdown: Vec<pb::PathUse>) -> Option<Serialdat> {
let mount = mounts.iter().find(|m| m.labels.iter().any(|l| l == "serialdat"))?.clone();
Some(Serialdat {
mount,
breakdown: breakdown
.into_iter()
.map(|p| PathRow {
bytes: p.measured.then_some(p.bytes),
label: p.label,
path: p.path,
error: p.error,
})
.collect(),
})
}
fn serialdat_warning(where_: &str, s: &Serialdat) -> Option<String> {
let permille = s.mount.used_permille?;
(permille >= SERIALDAT_WARN_PERMILLE).then(|| {
format!(
"{where_}/serialdat ({}) is {}.{} % full — past the 80 % line; the metadata copy, \
crash record and audit trail live there",
s.mount.mount_point,
permille / 10,
permille % 10
)
})
}
/// The twin's answer to the same `Admin.Capacity { disks: true }`.
pub fn render_twin(address: &str, answer: Result<pb::CapacityResponse, String>) -> TwinBox {
match answer {
Ok(c) => match c.disks {
Some(snap) => {
let mounts: Vec<Mount> = snap.mounts.into_iter().map(mount).collect();
let serialdat = serialdat_of(&mounts, snap.serialdat);
TwinBox { address: address.to_owned(), mounts, serialdat, unanswered: String::new() }
}
None => TwinBox { address: address.to_owned(), unanswered: NO_DISKS.to_owned(), ..TwinBox::default() },
},
Err(why) => TwinBox {
address: address.to_owned(),
unanswered: format!("the twin's Admin.Capacity: {why}"),
..TwinBox::default()
},
}
}
/// The start-up disk test, as the box reports it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct DiskSpeed {
/// `measured` | `not_run` | `skipped` | `failed` | `unknown`.
pub test: &'static str,
pub seq_write_bps: Option<u64>,
/// Absent when the read came back from the page cache (`cached`).
pub seq_read_bps: Option<u64>,
pub sync_milliops_per_s: Option<u64>,
/// `direct` | `dropped`.
pub read_how: String,
/// gunnar's own sentence. Never empty from a gunnar that has the test.
pub reason: String,
/// What the rates are made of (CapacityResponse 28-33): the bytes, each
/// leg's clock, the fsync'd operations. `None` from a gunnar older than
/// the fields, or when nothing was measured.
pub bytes: Option<u64>,
pub write_us: Option<u64>,
pub read_us: Option<u64>,
pub sync_ops: Option<u64>,
pub sync_us: Option<u64>,
pub run_us: Option<u64>,
}
/// **A figure this box does not measure — shown as N/A, never 0.**
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct NotMeasured {
/// Always `None` today; the field exists so the day a gunnar reports it
/// the page has a place for it, and so a JSON reader cannot mistake the
/// absence for a zero.
pub value: Option<String>,
pub why: &'static str,
}
/// Why write amplification is N/A.
pub const WRITE_AMPLIFICATION_NOT_MEASURED: &str = "Gunnar counts the bytes pushed (Usage) but not the bytes that reach the disk for them (transaction log + packs), so the ratio cannot be measured on this box; a push is known to land about 4.6 times its size, but that is a lab figure, not this box's";
/// Why the priority transfer is N/A.
pub const PRIORITY_TRANSFER_NOT_MEASURED: &str = "the priority transfer (used / limit over 30 days, class, queue length) comes from edda's admit trait, which the running Gunnar does not carry yet (or carries as PassThrough, which measures nothing)";
fn disk_test_word(code: i32) -> &'static str {
match pb::DiskTest::try_from(code) {
Ok(pb::DiskTest::DisktestMeasured) => "measured",
Ok(pb::DiskTest::DisktestNotRun) => "not_run",
Ok(pb::DiskTest::DisktestSkipped) => "skipped",
Ok(pb::DiskTest::DisktestFailed) => "failed",
_ => "unknown",
}
}
fn disk_speed(c: &pb::CapacityResponse) -> DiskSpeed {
DiskSpeed {
test: disk_test_word(c.disk_test),
seq_write_bps: c.disk_seq_write_bps,
seq_read_bps: c.disk_seq_read_bps,
sync_milliops_per_s: c.disk_sync_milliops_per_s,
read_how: c.disk_read_how.clone(),
reason: c.disk_reason.clone(),
bytes: c.disk_test_bytes,
write_us: c.disk_write_us,
read_us: c.disk_read_us,
sync_ops: c.disk_sync_ops,
sync_us: c.disk_sync_us,
run_us: c.disk_test_us,
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct DataSet {
pub root: String,
pub total_bytes: u64,
pub free_bytes: u64,
pub used_bytes: u64,
/// The `--disk-floor` in force: exists, never sold.
pub floor_bytes: u64,
pub servable_bytes: u64,
/// gunnar's words for the layout: `4×75G raid0 xfs`.
pub layout: String,
pub growth: &'static str,
/// The block device under the store root and its md members, when the
/// disk report places it. Empty from an older gunnar or a plain disk.
pub device: String,
pub raid_level: String,
pub members: Vec<Member>,
}
/// One member of the data set. Its SIZE is measured; its use is not a
/// separate reading — a striped set is used as a whole — so none is invented.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct Member {
pub name: String,
/// `None` when the member is named by md but its size was not found.
pub size_bytes: Option<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct Mount {
pub source: String,
pub mount_point: String,
pub fs_type: String,
pub read_only: bool,
pub labels: Vec<String>,
pub measured: bool,
pub error: String,
/// The figures below are `None` when `measured` is false: a number that
/// was not read is never shown as one.
pub total_bytes: Option<u64>,
pub free_bytes: Option<u64>,
pub available_bytes: Option<u64>,
pub used_bytes: Option<u64>,
pub reserved_bytes: Option<u64>,
pub floor_bytes: Option<u64>,
pub servable_bytes: Option<u64>,
pub used_permille: Option<u32>,
pub inodes_total: Option<u64>,
pub inodes_free: Option<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct Device {
pub name: String,
pub size_bytes: u64,
pub rotational: bool,
pub removable: bool,
pub read_only: bool,
pub partitions: Vec<(String, u64)>,
pub holders: Vec<String>,
pub members: Vec<String>,
pub raid_level: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct ReadCache {
/// Exploded bytes on disk now (`StorageInfoResponse.total_explode_bytes`).
pub bytes: u64,
/// Their summed budget; 0 = unbounded.
pub budget_bytes: u64,
/// `bytes / budget`, ‰. `None` when unbounded.
pub of_budget_permille: Option<u32>,
/// `bytes / data set used`, ‰. `None` when the data set did not answer or
/// holds nothing.
pub of_data_used_permille: Option<u32>,
/// The 80 % / 75 % high/low-water state. Always `None` today; see
/// `hysteresis_note`.
pub hysteresis: Option<String>,
pub hysteresis_note: &'static str,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct TwinDisk {
pub principal: String,
pub address: String,
pub state: String,
/// `None` when the twin did not measure its volume (total 0).
pub total_bytes: Option<u64>,
pub used_bytes: Option<u64>,
pub fill_permille: Option<u32>,
}
fn permille(part: u64, whole: u64) -> Option<u32> {
(whole > 0).then(|| ((u128::from(part) * 1000) / u128::from(whole)).min(u128::from(u32::MAX)) as u32)
}
fn mount(m: pb::DiskMount) -> Mount {
let read = |v: u64| m.measured.then_some(v);
Mount {
total_bytes: read(m.total_bytes),
free_bytes: read(m.free_bytes),
available_bytes: read(m.available_bytes),
used_bytes: read(m.used_bytes),
reserved_bytes: read(m.reserved_bytes),
floor_bytes: read(m.floor_bytes),
servable_bytes: read(m.servable_bytes),
used_permille: if m.measured { permille(m.used_bytes, m.total_bytes) } else { None },
inodes_total: read(m.inodes_total),
inodes_free: read(m.inodes_free),
source: m.source,
mount_point: m.mount_point,
fs_type: m.fs_type,
read_only: m.read_only,
labels: m.labels,
measured: m.measured,
error: m.error,
}
}
fn device(d: pb::BlockDevice) -> Device {
Device {
name: d.name,
size_bytes: d.size_bytes,
rotational: d.rotational,
removable: d.removable,
read_only: d.read_only,
partitions: d.partitions.into_iter().map(|p| (p.name, p.size_bytes)).collect(),
holders: d.holders,
members: d.members,
raid_level: d.raid_level,
}
}
/// The size of a device or partition named `name`, from the device table.
fn size_of(devices: &[Device], name: &str) -> Option<u64> {
devices
.iter()
.find(|d| d.name == name)
.map(|d| d.size_bytes)
.or_else(|| {
devices
.iter()
.flat_map(|d| d.partitions.iter())
.find(|(p, _)| p == name)
.map(|(_, size)| *size)
})
}
/// **The one reader of the three answers.** Pure, so every rule above is a
/// test and not a hope.
pub fn render(
capacity: Result<pb::CapacityResponse, String>,
storage: Result<pb::StorageInfoResponse, String>,
health: Result<pb::HealthResponse, String>,
) -> Disks {
let mut out = Disks {
write_amplification: NotMeasured { value: None, why: WRITE_AMPLIFICATION_NOT_MEASURED },
priority_transfer: NotMeasured { value: None, why: PRIORITY_TRANSFER_NOT_MEASURED },
..Disks::default()
};
match capacity {
Ok(c) => {
out.disk_speed = Some(disk_speed(&c));
let used = c.total_bytes.saturating_sub(c.free_bytes);
let mut set = DataSet {
root: c.root.clone(),
total_bytes: c.total_bytes,
free_bytes: c.free_bytes,
used_bytes: used,
floor_bytes: c.floor_bytes,
servable_bytes: c.servable_bytes,
layout: c.data_set.clone(),
growth: crate::api::usage::growth_word(c.growth),
..DataSet::default()
};
match c.disks {
Some(snap) => {
out.measured_at_unix_ms = (snap.measured_at_unix_ms > 0).then_some(snap.measured_at_unix_ms);
out.mounts = snap.mounts.into_iter().map(mount).collect();
out.devices = snap.devices.into_iter().map(device).collect();
out.problems = snap.problems;
out.problems.extend(
snap.unresolved
.into_iter()
.map(|u| format!("{} ({}) is on no mount: {}", u.label, u.path, u.why)),
);
out.system = out.mounts.iter().find(|m| m.labels.iter().any(|l| l == "system")).cloned();
out.serialdat = serialdat_of(&out.mounts, snap.serialdat);
if let Some(w) = out.serialdat.as_ref().and_then(|s| serialdat_warning("", s)) {
out.warnings.push(w);
}
// The block device under the store root, and its members.
if let Some(data) = out.mounts.iter().find(|m| m.labels.iter().any(|l| l == "data")) {
let name = data.source.rsplit('/').next().unwrap_or_default().to_owned();
if let Some(dev) = out.devices.iter().find(|d| d.name == name) {
set.device = dev.name.clone();
set.raid_level = dev.raid_level.clone();
set.members = dev
.members
.iter()
.map(|m| Member {
name: m.clone(),
size_bytes: size_of(&out.devices, m),
})
.collect();
} else {
set.device = name;
}
}
}
None => out.unanswered.push(NO_DISKS.to_owned()),
}
out.data_set = Some(set);
}
Err(why) => out.unanswered.push(format!("Admin.Capacity: {why}")),
}
match storage {
Ok(s) => {
let used = out.data_set.as_ref().map(|d| d.used_bytes).unwrap_or(0);
out.read_cache = Some(ReadCache {
bytes: s.total_explode_bytes,
budget_bytes: s.total_explode_budget_bytes,
of_budget_permille: permille(s.total_explode_bytes, s.total_explode_budget_bytes),
of_data_used_permille: permille(s.total_explode_bytes, used),
hysteresis: None,
hysteresis_note: HYSTERESIS_NOT_REPORTED,
});
}
Err(why) => out.unanswered.push(format!("Admin.StorageInfo: {why}")),
}
match health {
Ok(h) => {
out.twins = h
.twins
.into_iter()
.map(|t| {
let measured = t.disk_total_bytes > 0;
TwinDisk {
principal: t.principal,
address: t.address,
state: t.state,
total_bytes: measured.then_some(t.disk_total_bytes),
used_bytes: measured.then_some(t.disk_used_bytes),
fill_permille: measured.then_some(t.fill_permille),
}
})
.collect();
}
Err(why) => out.unanswered.push(format!("Admin.Health: {why}")),
}
out
}
fn words(status: tonic::Status) -> String {
if status.message().trim().is_empty() {
format!("{:?}", status.code())
} else {
status.message().to_owned()
}
}
/// `GET /api/disks` — admin only (`role_floor`'s `ADMIN_READS`). Not cached:
/// an administrator reloading this page is looking for a number that moved.
pub async fn disks(
reader: crate::api::Reader,
State(state): State<AppState>,
) -> ApiResult<Disks> {
let (mut a, mut b, mut c) = (reader.gunnar.admin(), reader.gunnar.admin(), reader.gunnar.admin());
let (capacity, storage, health) = tokio::join!(
a.capacity(pb::CapacityRequest {
requested_bytes: 0,
namespace: String::new(),
disks: true,
}),
b.storage_info(pb::StorageInfoRequest { repo: None }),
c.health(pb::HealthRequest {}),
);
let mut out = render(
capacity.map(tonic::Response::into_inner).map_err(words),
storage.map(tonic::Response::into_inner).map_err(words),
health.map(tonic::Response::into_inner).map_err(words),
);
// The twin's own disks, when one is configured: the same question to its
// gunnar. A failure is the twin's line, never the page's.
if let Some(twin) = state.twin.as_ref() {
let answer = twin
.gunnar
.admin()
.capacity(pb::CapacityRequest { requested_bytes: 0, namespace: String::new(), disks: true })
.await
.map(tonic::Response::into_inner)
.map_err(words);
let tb = render_twin(&twin.address, answer);
if let Some(w) = tb.serialdat.as_ref().and_then(|s| serialdat_warning("the twin's ", s)) {
out.warnings.push(w);
}
out.twin_box = Some(tb);
}
Ok(Json(out))
}
#[cfg(test)]
mod tests {
use super::*;
const GIB: u64 = 1 << 30;
fn snapshot() -> pb::DiskSnapshot {
pb::DiskSnapshot {
measured_at_unix_ms: 1_790_000_000_000,
mounts: vec![
pb::DiskMount {
source: "/dev/vda2".into(),
mount_point: "/".into(),
fs_type: "ext4".into(),
labels: vec!["system".into()],
measured: true,
total_bytes: 10 * GIB,
free_bytes: 6 * GIB,
available_bytes: 5 * GIB,
used_bytes: 4 * GIB,
reserved_bytes: GIB,
floor_bytes: 2 * GIB,
servable_bytes: 3 * GIB,
..Default::default()
},
pb::DiskMount {
source: "/dev/md0".into(),
mount_point: "/data".into(),
fs_type: "xfs".into(),
labels: vec!["data".into()],
measured: true,
total_bytes: 300 * GIB,
free_bytes: 200 * GIB,
available_bytes: 200 * GIB,
used_bytes: 100 * GIB,
floor_bytes: 15 * GIB,
servable_bytes: 185 * GIB,
..Default::default()
},
pb::DiskMount {
source: "/dev/vdz1".into(),
mount_point: "/broken".into(),
fs_type: "xfs".into(),
measured: false,
error: "statvfs(/broken): EIO".into(),
..Default::default()
},
],
devices: vec![
pb::BlockDevice { name: "md0".into(), size_bytes: 300 * GIB, members: vec!["vdb1".into(), "vdc1".into(), "vdq9".into()], raid_level: "raid0".into(), ..Default::default() },
pb::BlockDevice { name: "vdb".into(), size_bytes: 151 * GIB, partitions: vec![pb::BlockPartition { name: "vdb1".into(), size_bytes: 150 * GIB }], holders: vec!["md0".into()], ..Default::default() },
pb::BlockDevice { name: "vdc".into(), size_bytes: 151 * GIB, partitions: vec![pb::BlockPartition { name: "vdc1".into(), size_bytes: 150 * GIB }], holders: vec!["md0".into()], ..Default::default() },
],
unresolved: vec![pb::UnresolvedDisk { label: "slow".into(), path: "/serialdat".into(), why: "no such file".into() }],
problems: vec![],
serialdat: vec![],
}
}
fn capacity(disks: Option<pb::DiskSnapshot>) -> pb::CapacityResponse {
pb::CapacityResponse {
root: "/data".into(),
total_bytes: 300 * GIB,
free_bytes: 200 * GIB,
floor_bytes: 15 * GIB,
servable_bytes: 185 * GIB,
data_set: "2×150G raid0 xfs".into(),
growth: pb::SetGrowth::GrowthSealed as i32,
disks,
..Default::default()
}
}
fn health() -> pb::HealthResponse {
pb::HealthResponse {
twins: vec![
pb::TwinHeartbeat { principal: "twin".into(), address: "10.13.8.122:40000".into(), disk_total_bytes: 400 * GIB, disk_used_bytes: 100 * GIB, fill_permille: 250, state: "ok".into(), ..Default::default() },
pb::TwinHeartbeat { principal: "silent".into(), state: "silent".into(), ..Default::default() },
],
..Default::default()
}
}
#[test]
fn every_disk_the_read_cache_and_the_twins_on_one_answer() {
let storage = pb::StorageInfoResponse { total_explode_bytes: 20 * GIB, total_explode_budget_bytes: 40 * GIB, ..Default::default() };
let d = render(Ok(capacity(Some(snapshot()))), Ok(storage), Ok(health()));
assert!(d.unanswered.is_empty(), "{:?}", d.unanswered);
let set = d.data_set.as_ref().expect("the data set");
assert_eq!((set.total_bytes, set.used_bytes, set.free_bytes, set.floor_bytes, set.servable_bytes), (300 * GIB, 100 * GIB, 200 * GIB, 15 * GIB, 185 * GIB));
assert_eq!((set.device.as_str(), set.raid_level.as_str(), set.growth), ("md0", "raid0", "sealed"));
assert_eq!(
set.members,
vec![
Member { name: "vdb1".into(), size_bytes: Some(150 * GIB) },
Member { name: "vdc1".into(), size_bytes: Some(150 * GIB) },
// Named by md, not found in the table: no size is invented.
Member { name: "vdq9".into(), size_bytes: None },
]
);
let system = d.system.as_ref().expect("the system disk");
assert_eq!((system.mount_point.as_str(), system.used_bytes, system.used_permille), ("/", Some(4 * GIB), Some(400)));
assert_eq!(d.mounts.len(), 3);
let broken = &d.mounts[2];
assert!(!broken.measured && broken.total_bytes.is_none() && broken.used_permille.is_none() && broken.error.contains("EIO"), "{broken:?}");
assert_eq!(d.devices.len(), 3);
assert!(d.problems.iter().any(|p| p.contains("slow") && p.contains("no such file")), "{:?}", d.problems);
let cache = d.read_cache.as_ref().expect("the read cache");
assert_eq!((cache.bytes, cache.budget_bytes, cache.of_budget_permille, cache.of_data_used_permille), (20 * GIB, 40 * GIB, Some(500), Some(200)));
assert!(cache.hysteresis.is_none() && cache.hysteresis_note.contains("0.1.6"));
assert_eq!(d.twins[0].fill_permille, Some(250));
assert_eq!((d.twins[1].total_bytes, d.twins[1].fill_permille), (None, None), "an unmeasured twin volume is not 0");
// N/A, never 0, for what this box does not measure.
assert!(d.write_amplification.value.is_none() && d.write_amplification.why.contains("4.6"));
assert!(d.priority_transfer.value.is_none() && d.priority_transfer.why.contains("admit"));
}
/// The measured disk speed, with the bytes and the time behind each rate.
#[test]
fn the_disk_speed_carries_its_bytes_and_clocks_and_an_unrun_test_carries_none() {
let c = pb::CapacityResponse {
disk_test: pb::DiskTest::DisktestMeasured as i32,
disk_seq_write_bps: Some(812_000_000),
disk_seq_read_bps: None,
disk_sync_milliops_per_s: Some(3_100_400),
disk_read_how: "direct".into(),
disk_reason: "This disk, at start-up …".into(),
disk_test_bytes: Some(256 << 20),
disk_write_us: Some(330_000),
disk_read_us: Some(20_000),
disk_sync_ops: Some(1550),
disk_sync_us: Some(500_000),
disk_test_us: Some(1_600_000),
..capacity(None)
};
let s = render(Ok(c), Err("x".into()), Err("x".into())).disk_speed.expect("speed");
assert_eq!(s.test, "measured");
assert_eq!((s.seq_write_bps, s.seq_read_bps), (Some(812_000_000), None), "a cached read is absent, not fast");
assert_eq!((s.bytes, s.write_us, s.read_us, s.sync_ops, s.sync_us, s.run_us), (Some(256 << 20), Some(330_000), Some(20_000), Some(1550), Some(500_000), Some(1_600_000)));
let s = render(Ok(capacity(None)), Err("x".into()), Err("x".into())).disk_speed.expect("speed");
assert_eq!(s.test, "unknown");
assert_eq!((s.seq_write_bps, s.bytes, s.run_us), (None, None, None));
}
/// /serialdat: its mount, the breakdown (an unread path is None, not 0),
/// the 80 % warning — here and on the twin.
#[test]
fn serialdat_is_broken_down_and_warned_about_past_eighty_percent() {
let mut snap = snapshot();
snap.mounts.push(pb::DiskMount {
source: "/dev/vda3".into(),
mount_point: "/serialdat".into(),
labels: vec!["serialdat".into()],
measured: true,
total_bytes: 10 * GIB,
used_bytes: 17 * GIB / 2,
..Default::default()
});
snap.serialdat = vec![
pb::PathUse { label: "audit trail".into(), path: "/serialdat/log/audit-chain.jsonl".into(), measured: true, bytes: GIB, error: String::new() },
pb::PathUse { label: "crash record".into(), path: "/serialdat/crash".into(), measured: false, bytes: 0, error: "EACCES".into() },
pb::PathUse { label: "other".into(), measured: true, bytes: 15 * GIB / 2, ..Default::default() },
];
let d = render(Ok(capacity(Some(snap.clone()))), Err("x".into()), Err("x".into()));
let s = d.serialdat.as_ref().expect("serialdat");
assert_eq!(s.mount.used_permille, Some(850));
assert_eq!(s.breakdown[0].bytes, Some(GIB));
assert_eq!((s.breakdown[1].bytes, s.breakdown[1].error.as_str()), (None, "EACCES"), "an unread path is not 0");
assert!(d.warnings.iter().any(|w| w.contains("85.0 %") && w.contains("80 %")), "{:?}", d.warnings);
let t = render_twin("10.13.8.122:50051", Ok(capacity(Some(snap))));
assert!(t.serialdat.is_some() && t.unanswered.is_empty());
let t = render_twin("10.13.8.122:50051", Err("unreachable".into()));
assert!(t.mounts.is_empty() && t.unanswered.contains("unreachable"));
// Below 80 %: no warning.
let d = render(Ok(capacity(Some(snapshot()))), Err("x".into()), Err("x".into()));
assert!(d.warnings.is_empty());
}
#[test]
fn an_older_gunnar_and_a_silent_source_are_said_in_words_not_drawn_as_zero() {
let d = render(Ok(capacity(None)), Err("unimplemented".into()), Err("Admin.Health is not served".into()));
assert!(d.data_set.is_some(), "the data set still answers");
assert!(d.mounts.is_empty() && d.system.is_none() && d.read_cache.is_none() && d.twins.is_empty());
assert!(d.unanswered.iter().any(|u| u == NO_DISKS));
assert!(d.unanswered.iter().any(|u| u.starts_with("Admin.StorageInfo")));
assert!(d.unanswered.iter().any(|u| u.starts_with("Admin.Health")));
let d = render(Err("gunnar is down".into()), Ok(pb::StorageInfoResponse { total_explode_bytes: 5, ..Default::default() }), Ok(pb::HealthResponse::default()));
assert!(d.data_set.is_none());
let cache = d.read_cache.expect("the cache still answers");
assert_eq!((cache.of_budget_permille, cache.of_data_used_permille), (None, None), "no budget and no data set: no share invented");
}
}