Skip to main content

gunnar_server/
capacity.rs

1//! **"If a tenant's cap rose to N bytes, could you serve it?"** — `Admin.Capacity`.
2//!
3//! The question a seller must ask before the money moves. monetize grows an
4//! "increase my quota" button: a tenant on 10 GB asks for 100 GB and something
5//! upstream is about to buy 90 GB from a cloud vendor and charge for it. gunnar
6//! is the only component that knows whether those bytes can ever reach a
7//! repository, and on the appliance the honest answer is frequently **no**.
8//!
9//! # Two facts, not one number
10//!
11//! A single free-bytes answer is the dangerous one: a caller reads a small
12//! number as "buy another disk", and on the appliance that is money converted
13//! into nothing. So [`answer`] returns a `verdict` — can this request be served
14//! out of what exists right now — **and**, independently, a `growth` — can this
15//! data set ever hold more than it does, and by whose hand. `growth` is
16//! reported on the yes arm as well, because "yes, and this is the last capacity
17//! this box will ever have" is a different sale from "yes, and there is more
18//! where that came from".
19//!
20//! ## When the appliance is SEALED — and when it is NOT
21//!
22//! SEALED was once the only answer, on three facts:
23//!
24//! * `/data` is an md set laid once by `korp-installer`'s `lay_set`
25//!   (`edda/crates/korp-installer/src/install.rs`), which runs **only** on
26//!   `InstallMode::Fresh`.
27//! * gunnar had no data-set growth of any kind.
28//! * gunnar is **PID 1 with no shell**, so nothing on the machine can
29//!   `xfs_growfs` a newly attached disk either.
30//!
31//! The second is no longer true, and [`decide_growth`] must not say it is.
32//! `korp-start` carries the boot-time grow engine
33//! (`korp_installer::grow`, `DATA-SET-GROWTH-DESIGN.md`) and, under the
34//! `grow-runtime` feature, THIS server links the same engine's runtime half
35//! (`korp_installer::grow::runtime`, driven by [`crate::grow`]). So the
36//! verdict is a ladder over MEASURED facts — the journal on the box
37//! ([`GrowthFacts::grow`]), this build's engine ([`GrowthFacts::runtime_engine`])
38//! and the kernel's PCI hot-plug ([`GrowthFacts::hotplug`]) — and SEALED is
39//! only its bottom rung, reached when **this box keeps no grow journal**.
40//! Nothing here may claim "no such path in any build": that is a statement
41//! about the binary, and the binary is measured one field away.
42//!
43//! On that bottom rung a disk bought and attached to the box is **billed and
44//! invisible**. That is what [`SetGrowth::GrowthSealed`] says, in those words,
45//! in the reason line — so a caller that renders nothing but the sentence
46//! still cannot mistake it for "not enough space right now".
47//!
48//! # Measured, never declared, and measured ONCE (one implementation)
49//!
50//! The bytes are [`gunnar_ssh::DiskHeadroomGate::headroom`] — the same
51//! `statvfs` the push door refuses under, so the capacity a seller is quoted
52//! and the floor a push dies at cannot disagree. The set's shape is
53//! [`crate::dashboard::probe::disk_kind`] — the same `/proc/mounts` + md sysfs
54//! read the console's header band prints `4×75G raid0 xfs` from. This module
55//! opens no third spelling of either question; it decides over their output.
56//!
57//! [`answer`] is pure over [`Question`] precisely so the SEALED arm can be
58//! driven red on a developer box, where gunnar is never PID 1 and `/data` is
59//! never an md set.
60//!
61//! # The box: cores and RAM, measured (product scope)
62//!
63//! Disk is a tenant's: a cap is bytes in one namespace. Cores and RAM are the
64//! **box's**: no tenant owns a core, so the ceiling a seller may sell either
65//! up to is whatever this machine has — and that must be read off the
66//! machine, not declared in a config that was true of the plan somebody
67//! ordered. [`MachineFacts`] reads them the way the rest of the server
68//! already does (one implementation): `available_parallelism` is what
69//! [`crate::budget::Machine`] sizes the thread pools from, `/proc/meminfo`
70//! is [`crate::dashboard::probe::meminfo`] behind the header band's `ram`
71//! cell, the cgroup clamp is [`crate::budget::cgroup_memory`]'s, and the
72//! load is the band's `load1`.
73//!
74//! Every box field on the wire is `optional`, and **absent means unmeasured**
75//! — never `0`. A `0` is a number a caller will divide by, sell against, or
76//! read as "this box has no RAM"; an absence with `machine_reason` naming
77//! the OS error is the same UNMEASURED discipline the disk half has.
78
79use 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/// **What the machine says about the set under the data root.** Two measured
87/// facts and nothing inferred; [`decide_growth`] turns them into a verdict.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct GrowthFacts {
90    /// The mount and the array under it, as
91    /// [`crate::dashboard::probe::disk_kind`] reads them. `None` when the data
92    /// root is not a mount point of its own, or `/proc/mounts` is unreadable —
93    /// which is the honest UNKNOWN, not a growable answer.
94    pub kind: Option<DiskKind>,
95    /// **This process is PID 1.** On the appliance gunnar IS init: there is no
96    /// shell beside it, so nothing on the machine can grow a filesystem
97    /// out of band. On a developer box or in a container beside an init this
98    /// is false and an operator's hands still exist.
99    pub is_pid1: bool,
100    /// **The boot stack's grow journal** (`/serialdat/grow-journal`,
101    /// `DATA-SET-GROWTH-DESIGN.md`), when this box's `korp-start` keeps one —
102    /// which is the measured fact that the boot stack CAN grow the set at a
103    /// restart. `None` on an image older than the engine, where the
104    /// appliance is as sealed as it ever was.
105    pub grow: Option<gunnar_grpc::grow_journal::Summary>,
106    /// **This gunnar-server links the runtime grow engine** (`crate::grow`,
107    /// feature `grow-runtime`): with the journal on the box, the set grows
108    /// WITHOUT a restart through `Admin.GrowStart`. A build fact, read once.
109    pub runtime_engine: bool,
110    /// **The running kernel has PCI hot-plug**
111    /// ([`crate::dashboard::probe::pci_hotplug`]). UpCloud resizes only
112    /// DETACHED volumes, so without it a member detached and re-attached while
113    /// gunnar runs is invisible: the engine is there, the growth is not.
114    pub hotplug: bool,
115}
116
117impl GrowthFacts {
118    /// Read them off the running machine.
119    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
130/// The growth verdict and the clause the reason line carries for it.
131pub 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        // The boot stack carries the grow engine (its journal is on this
142        // box) AND this server links it: the set grows WITHOUT a restart
143        // (`Admin.GrowStart`: drain, unmount, wait for larger members, grow,
144        // mount, reopen — DATA-SET-GROWTH-FLOW.md §3). The restart path
145        // below still holds as the fallback for a crash mid-way.
146        (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        // The boot stack carries the grow engine (its journal is on this
162        // box): the set grows at the NEXT RESTART into enlarged member
163        // volumes. Still no hot-plug, still no shell — an attached disk is
164        // as wasted as under SEALED, and that is said in the same breath.
165        (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            // The `lay_set` clause is claimed only where an md set was
187            // actually measured. A single-disk PID-1 box is just as sealed —
188            // no growth path, no shell — but it was not laid by
189            // `korp-installer`'s array pass, and saying so would be inventing
190            // a fact about a machine nobody looked at.
191            let laid = if kind.set.is_some() {
192                " laid once at install (korp-installer `lay_set`, InstallMode::Fresh only)"
193            } else {
194                ""
195            };
196            // The MEASURED reason this box cannot grow is that it keeps no
197            // grow journal — its boot stack predates the engine. Whether
198            // THIS binary links the runtime half is a separate fact, and it
199            // is measured (`runtime_engine`), so the line names it instead
200            // of asserting "no such path in any build" over a binary that
201            // may well carry one.
202            //
203            // "so it refuses by name" below was a CLAIM until 2026-09-18 and
204            // is a fact since: an audit found `GrowMachine::start` had no
205            // journal precondition, so a `GrowStart` on exactly the box this
206            // arm describes was accepted and drained the gate before failing
207            // at `note(Step::Drain)`. The refusal now sits in
208            // `grow::GrowMachine::start`, on the SAME file `facts.grow`
209            // measures here, so this sentence and that door cannot disagree.
210            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/// **What the box has: cores, RAM, and how busy it is.** Product-scope facts,
239/// each either measured or carrying the reason it was not; [`machine_fields`]
240/// turns them into the wire's `optional` fields and the `machine_reason`.
241#[derive(Debug, Clone, PartialEq)]
242pub struct MachineFacts {
243    /// CPUs this process may **run on**: the affinity mask, further limited by
244    /// a cgroup v2 `cpu.max` quota when one is set
245    /// (`std::thread::available_parallelism`, the spelling
246    /// [`crate::budget::Machine`] sizes the pools from). `Err` is the OS's
247    /// reason the kernel would not say.
248    pub cpus: Result<usize, String>,
249    /// `(MemTotal, MemAvailable)` in bytes as `/proc/meminfo` reports them,
250    /// then clamped by `cgroup_memory_max` when a limit is set. `Err` names why
251    /// `/proc/meminfo` could not be read or parsed.
252    pub memory: Result<(u64, u64), String>,
253    /// The cgroup v2 `memory.max` that clamped `memory`, when one did and was
254    /// smaller than the box — reported so the reason line can say the quoted
255    /// RAM is the container's, not the host's.
256    pub cgroup_memory_max: Option<u64>,
257    /// The 1-minute load average, or why `/proc/loadavg` could not be read.
258    pub load1: Result<f64, String>,
259    /// **What the data root's disk did at start-up**: the last outcome of
260    /// [`crate::disktest`], or `None` when the test has not finished one (the
261    /// gate was closed, or the thread is still running). A LOOKUP — reading
262    /// this never starts a disk test, because the answer wanted is what the
263    /// disk did at start-up on an idle box, not what it does under whatever
264    /// load is on it now.
265    pub disk: Option<crate::disktest::Outcome>,
266}
267
268impl MachineFacts {
269    /// Read them off the running machine. Every reader is one the server
270    /// already has (one implementation); this only keeps their failures instead of
271    /// flattening them to a dash or a zero.
272    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        // The clamp `budget::Machine::read` applies, for the reason it gives:
279        // inside a cgroup `/proc/meminfo` is the HOST's memory, and the limit
280        // is the smaller truth. The appliance has no limit; there this is a
281        // no-op.
282        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
300/// The wire's `disk_*` fields out of one [`crate::disktest::Outcome`], and
301/// nothing else set — the same shape and the same contract as
302/// [`machine_fields`], so both arms of [`answer`] fill them the same way.
303///
304/// **Absent, never zero, and never a fast disk.** Every arm below either
305/// fills a number that was really measured or leaves the field `None` and
306/// says why in `disk_reason`. The one subtle case is `cached`: the read leg
307/// came back over [`crate::disktest::CACHED_BPS`], which means the page cache
308/// answered and the device was never touched, so `disk_seq_read_bps` is
309/// ABSENT while the write and sync legs of the same run stand. A caller
310/// comparing two boxes must be able to tell "this disk is slow" from "this
311/// disk was not measured", and that is the whole reason this function exists
312/// instead of a `unwrap_or(0)`.
313pub 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        // A negative or non-finite rate is not a measurement.
319        (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                // What the three rates are made of, raw: the same run, its
405                // bytes and each leg's own clock. Present even when the read
406                // came back cached — the TIME is still what happened; it is
407                // the rate derived from it that is not a disk's.
408                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
425/// The wire's box fields out of [`MachineFacts`] — and nothing else set, so a
426/// caller builds a response as `CapacityResponse { ..., ..machine_fields(&m) }`
427/// and both arms of [`answer`] fill these the same way (one implementation).
428///
429/// **Absent, never zero.** An `Err` fact leaves its fields `None` and puts the
430/// reason in `machine_reason`; `machine_reason` is never empty, because a
431/// caller that renders only the strings must still learn what was measured.
432pub 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    // The disk's own fields ride along, because every caller builds its
491    // answer as `CapacityResponse { ..., ..machine_fields(&m) }` and two
492    // struct updates cannot compose — one seam, one implementation, and the
493    // disk can never be filled on one arm of `answer` and not the other.
494    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
505/// **Every disk on the box, for `Admin.Capacity { disks: true }`** (2026-09-28).
506///
507/// edda's product-neutral `disk-snapshot` does the reading — the mount table,
508/// one statvfs per storage mount, `/sys/block` — and this names gunnar's own
509/// paths on it: `system` is `/`, `data` the store root (the explode read cache
510/// lives inside each repository there, so it is ON this mount and not a disk of
511/// its own), `slow` the off-stripe root. The floor is the `--disk-floor` in
512/// force, applied per mount, so the `data` row's floor is the number the push
513/// door refuses at.
514pub 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
536/// **What is on /serialdat, thing by thing** — the known paths gunnar keeps
537/// there, each measured as the blocks it holds (a directory summed), plus
538/// `other`: the mount's used bytes no known path accounts for.
539///
540/// `known` is `(label, path)`; a path that does not exist holds 0 bytes and is
541/// measured as such (nothing is there). A path whose walk failed is
542/// `measured: false` with the error, and is left out of the sum, so `other`
543/// is never made smaller by a number nobody read. `used` is the mount's own
544/// statvfs used bytes; `None` (unmeasured mount) → no `other` row.
545pub 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    // A known path INSIDE another (the audit trail inside the log directory)
568    // is counted once: the outer row keeps only what the inner ones do not.
569    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
599/// The blocks a path holds on disk (`st_blocks × 512`), a directory summed
600/// without following symlinks. A missing path is 0: nothing is there.
601fn 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
619/// The snapshot, as the wire carries it. Pure, so a recorded snapshot can be
620/// checked field by field: an unmeasured mount keeps `measured = false` and its
621/// reason, and its numbers stay 0 rather than being invented.
622pub 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        // Filled by the caller, which knows gunnar's own paths on /serialdat.
688        serialdat: Vec::new(),
689    }
690}
691
692/// Everything [`answer`] decides from. Built by
693/// [`crate::control_plane::CoreControlPlane`] out of the disk gate and the
694/// namespace's meter; built by hand in the tests, which is the point.
695#[derive(Debug, Clone)]
696pub struct Question {
697    /// The store root the numbers are of.
698    pub root: PathBuf,
699    /// The one `statvfs`, or the OS's reason it failed. `Err` is
700    /// [`CapacityVerdict::CapacityUnmeasured`] — never a yes.
701    pub room: Result<Headroom, String>,
702    /// `--disk-floor` as configured, for the reason line.
703    pub floor_spec: String,
704    /// The cap being contemplated.
705    pub requested_bytes: u64,
706    /// What the namespace already holds (`Usage.Read`'s `pack_bytes`).
707    pub held_bytes: u64,
708    /// Whose cap is rising; empty asks about bytes that are not here yet.
709    pub namespace: String,
710    pub growth: GrowthFacts,
711    /// The box, for the product-scope fields. Independent of the question:
712    /// the same cores and RAM are reported whatever cap was asked about.
713    pub machine: MachineFacts,
714}
715
716/// **The answer.** Pure: every field below is arithmetic over [`Question`].
717pub fn answer(q: &Question) -> CapacityResponse {
718    let (growth, growth_clause) = decide_growth(&q.growth, &q.root);
719    // The box fields, once for both arms: what the machine has does not
720    // depend on whether the disk could be measured.
721    let mut machine = machine_fields(&q.machine);
722    // The journal rides both arms: what the boot stack did about the set is
723    // a fact whether or not the filesystem could be measured now.
724    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    // Bytes the raise actually needs: what a tenant already holds is already
735    // spent out of free space, so a 10 GB tenant asking for 100 GB needs 90,
736    // not 100. An unnamed namespace has held 0 and needs the whole cap.
737    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                // Nothing was measured, so nothing is reported. A plausible
764                // number here would be a blind guard, asserting nothing.
765                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    // The floor is space that EXISTS and may never be sold: it is what lets the
777    // box compact a pack and rewrite its control documents after it has stopped
778    // accepting pushes. Quoting against `free` rather than against this is how
779    // a seller sells the reserve.
780    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    // One constructor for both arms (one implementation): the verdict, the shortfall and
792    // the clause differ; the twelve measured fields do not, and writing them
793    // twice is how the two arms drift into reporting different things.
794    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    /// The live gunnar.rs appliance, as `/proc/mounts` and the md sysfs read
838    /// on it: `/data` is a 4×75 GiB raid0 xfs set and gunnar is PID 1.
839    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    /// The same disk under an init, with a shell beside gunnar.
858    fn developer_box() -> GrowthFacts {
859        GrowthFacts {
860            is_pid1: false,
861            ..appliance()
862        }
863    }
864
865    /// The appliance re-imaged with the grow engine in its boot stack: the
866    /// journal exists, with `text` in it.
867    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    /// **The runtime arm** (`DATA-SET-GROWTH-FLOW.md` ruling 1): the journal
878    /// on the box AND this server linking the engine is `GROWTH_AT_RUNTIME`;
879    /// either fact alone is not — the journal without the engine is the
880    /// restart arm, the engine without the journal is SEALED (measured, not
881    /// declared). The clause names the verb and "ALL 4 members".
882    #[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    /// **No hot-plug, no runtime growth** (owner's rule 2026-09-15): the engine
915    /// linked and the journal on the box, but a kernel without PCI hot-plug, is
916    /// `GROWTH_AT_RESTART` with the reason named — and `hotplug` rides the wire
917    /// on every arm so monetize stops guessing.
918    #[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    /// **The verdict the grow engine adds** (`DATA-SET-GROWTH-DESIGN.md`
945    /// §3, §5): a PID-1 md-set box whose boot stack keeps the journal is
946    /// `GROWTH_AT_RESTART`, never SEALED — and the clause still says an
947    /// attached disk is wasted, names "ALL 4 members" and carries the
948    /// journal's own sentence (idle / in progress / done / PARTIAL). The
949    /// journal rides the wire on the yes arm, the no arm and the unmeasured
950    /// arm alike; a box with no journal has no `grow_state` and stays SEALED.
951    #[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        // partial resize: the last boot refused, and the clause says which members grew
969        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        // on the wire, on every arm
978        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    /// The live gunnar.rs appliance's box: the UpCloud STARTER plan, 4 vCPU
1005    /// and 16 GiB, no cgroup (gunnar is PID 1), a quiet minute.
1006    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    /// gunnar.rs today: 300 GB of set, ~230 GB used, the 5 % floor in force.
1041    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        // Three answers, never two: an unmeasured set is not a growable one,
1066        // and a sealed set is not merely "gunnar will not do it".
1067        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        // The sealed clause says the thing that stops a purchase, in words.
1076        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        // The difference is real, not a wording variant: the operator arm
1089        // promises the opposite thing.
1090        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    /// **The row this whole RPC exists for.** gunnar.rs, 300 GiB of raid0,
1102    /// 70 GiB free, a tenant holding 10 GiB asking to be raised to 100 GiB —
1103    /// 90 GiB of new bytes, against 55 GiB servable.
1104    #[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        // The arithmetic, every term of it.
1120        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        // And the sentence carries both halves.
1131        assert!(a.reason.contains("NO."), "{}", a.reason);
1132        assert!(a.reason.contains("UNUSED AND INVISIBLE"), "{}", a.reason);
1133    }
1134
1135    /// The SAME numbers on a box with a shell answer NO too — but for a
1136    /// different reason, and that difference is the whole point of the second
1137    /// field. A caller reading only `verdict` would buy a disk here and be
1138    /// right; on the appliance it would buy one and be robbed.
1139    #[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    /// A yes still reports the growth fact — "yes, and this is the last
1159    /// capacity this box will ever have" is a different sale.
1160    #[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        // The boundary is `servable`, not `free`: 60 GiB is under the 70 GiB
1176        // free and over the 55 GiB servable, and must be refused.
1177        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    /// **The box, as the live appliance would answer it**: 4 vCPU = 4000
1213    /// millicores, 16 GiB / 12 GiB, load 0.42 ≈ 420 millicores — and the
1214    /// reason line names each source, calls the load an approximation, and
1215    /// is carried on the UNMEASURED-disk arm too.
1216    #[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        // The same box on the disk-unmeasured arm: the fields do not depend
1243        // on whether statvfs worked, so one constructor serves both.
1244        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    /// **Absent, never zero.** A box whose `/proc/meminfo` cannot be read
1265    /// reports NO RAM fields — `None` on the wire, not `Some(0)` — and the
1266    /// reason names the OS error; the cores it DID measure are still there.
1267    #[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        // And the whole box unmeasured is a reason line, not an empty one.
1310        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        // The disk answer is untouched by any of it.
1327        assert_eq!(none.verdict, a.verdict);
1328        assert_eq!(none.reason, a.reason);
1329    }
1330
1331    /// A cgroup limit smaller than the host is the RAM gunnar may use, and
1332    /// the reason says the number is the container's, not the host's.
1333    #[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    /// **The thirteen disk fields are independent of the box.** The same
1349    /// question with a measured box and with an unmeasured one yields
1350    /// byte-identical disk fields — and the wire proves it in
1351    /// `tests/capacity.rs`, where the bytes are decoded by an OLDER client.
1352    #[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        // Mask the BOX's own fields and the start-up disk TEST's — both are
1367        // facts about the machine rather than about the store's free space,
1368        // and this test is about the `statvfs` answer alone. The disk test
1369        // joined the mask 2026-09-21 when it reached the wire: a box that has
1370        // measured its disk and one that has not must still give the same
1371        // capacity verdict, which is exactly what the mask asserts.
1372        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                // The run's raw bytes and clocks (2026-09-28) are the same
1386                // disk test's, so they are masked with it.
1387                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        // …and the two fixtures really did differ on the disk test, or the
1398        // mask above would be asserting nothing.
1399        assert_ne!(a.disk_test, b.disk_test);
1400        assert_ne!(a.disk_reason, b.disk_reason);
1401    }
1402}
1403
1404/// **The disk on the wire** — added 2026-09-21 because the twin election has
1405/// to compare two boxes' disks and `Admin.Capacity` did not carry them.
1406///
1407/// The property under test is one sentence: *a box that could not measure
1408/// must be distinguishable from a box that measured badly, and neither may
1409/// look like a fast disk.* Every arm below drives one way of not measuring
1410/// and asserts that it is named, and that no speed field carries a zero.
1411#[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    /// A real run puts three numbers on the wire, in the wire's units.
1434    #[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        // ops/s × 1000, so the wire stays integral the way millicores do.
1441        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        // …and what they are made of: the bytes and each leg's clock.
1446        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    /// **The cached read is the subtle one.** Over 5 GB/s the page cache
1459    /// answered and the device was never touched — so the read is ABSENT,
1460    /// not fast; and the write and fsync legs of the same run still stand,
1461    /// because they were really measured.
1462    #[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    /// The four silences are four different facts, each named, and none of
1486    /// them puts a number anywhere.
1487    #[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        // Skipped names BOTH figures, so an operator can act on it.
1528        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        // And the three silences do not say the same thing as each other.
1538        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    /// The disk rides on the SAME seam as the box's cores and RAM, so it can
1549    /// never be filled on one arm of `answer` and forgotten on the other.
1550    #[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        // A box with no disk reading still reports its cores.
1564        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    /// The latch is a LOOKUP: reading it never starts a disk test, and an
1570    /// outcome recorded is the outcome read back.
1571    #[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        // Put back whatever another test in this binary may have left.
1582        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    /// A recorded snapshot reaches the wire field by field, and an unmeasured
1593    /// mount stays unmeasured: `measured = false`, the reason, and zeroes that
1594    /// the flag says are not readings.
1595    #[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    /// **The /serialdat breakdown sums**: each known path measured by its
1657    /// blocks, the audit trail counted once though it sits inside the log
1658    /// directory, a missing path 0, and `other` = the mount's used bytes minus
1659    /// what the known paths hold — so the rows add up to the mount's use.
1660    #[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        // An unmeasured mount: no `other`, nothing invented.
1708        assert!(serialdat_breakdown(&known, None)
1709            .iter()
1710            .all(|r| r.label != "other"));
1711    }
1712
1713    /// The live road on this machine: a data root in a temp dir is placed on
1714    /// the mount it sits on and measured, and `/` is labelled `system`.
1715    #[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}