Skip to main content

gunnar_ssh/
headroom.rs

1//! **The disk-headroom gate: the community [`PlanGate`], the "free lunch".**
2//!
3//! Decided 2026-09-04: there are two implementations — 1) the free lunch,
4//! basically checking disk has size before a new repo is created; 2) the
5//! monetize-bound one. This is the first. It has no monetize dependency, it is always
6//! compiled, and it is the first link of every `Control`'s gate chain — on the
7//! community binary the only one.
8//!
9//! # What it refuses, and where
10//!
11//! | request | asked | answer |
12//! |---|---|---|
13//! | `git-receive-pack`, LFS upload (`Dispatch::authorise_service`) | [`PlanGate::pack_quota`] | `Refuse` under the floor, **before a pack byte** |
14//! | push-to-create (the first-repository door) | the same, because it is the same call | the same |
15//! | `Repos.Create` (`gunnar-server::control_plane`) | [`DiskHeadroomGate::refusal`] | `RESOURCE_EXHAUSTED` with the same line |
16//! | any read, anonymous or not | nothing | never — a full disk still serves clones |
17//!
18//! One function, [`DiskHeadroomGate::refusal`], produces the line for all three
19//! doors (one implementation): "the disk is full" must be spelled identically to a `git
20//! push` and to a `gunnar control repo create`, or an operator debugging the
21//! second will not recognise the first.
22//!
23//! # The floor: 5 % or 2 GiB, whichever is LARGER
24//!
25//! Two numbers because each is wrong alone:
26//!
27//! * **5 %** is the size of the reserve the box needs to keep *operating*
28//!   after it stops accepting: `znippy`'s append-side gc needs scratch to
29//!   compact a pack, the control store commits a new document by writing a
30//!   fresh blob beside the old one, and `replication.json` / `webhooks.json`
31//!   are rewritten whole. It is also `mkfs.ext4`'s own reserved-blocks default,
32//!   and refusing at the same line means gunnar says no BEFORE root's reserve is
33//!   what keeps the box alive. On a 224.9 GiB `/data` (gunnar.rs) that is
34//!   ~11 GiB — enough for the largest single push measured there.
35//! * **2 GiB** is the absolute floor for the small disk, where 5 % is nothing:
36//!   on a 25 GiB appliance volume 5 % is 1.25 GiB, smaller than one pack of
37//!   `alice/linux-mirror`, so the percentage alone would admit a push that
38//!   fills the disk mid-stream — and a pack that dies mid-write is the power-cut
39//!   power-cut shape (a torn file) without the power cut.
40//!
41//! The larger of the two wins, so a big disk keeps a proportionate reserve and
42//! a small one keeps a survivable one. `--disk-floor` overrides both; see
43//! [`DiskFloor::parse`].
44//!
45//! # Measured with `statvfs`, never `df`
46//!
47//! Pure Rust: `rustix::fs::statvfs` on the store root, one syscall, no
48//! subprocess. `f_bavail` — the blocks available to an UNPRIVILEGED caller —
49//! not `f_bfree`, because the appliance runs as root and `f_bfree` would count
50//! the reserved blocks the box must never eat. A `statvfs` that fails is a
51//! **refusal**, not an allow: a gate that cannot measure and says yes is the
52//! always-allow gate that proves nothing, and the line says why so the operator
53//! learns the mount is the problem, not the space.
54
55use std::path::{Path, PathBuf};
56use std::sync::Arc;
57
58use crate::plan::{PlanAction, PlanGate, PlanVerdict};
59use crate::space::SpaceLedger;
60
61/// **The start of the floor's refusal** (wiring tracer B 2026-10-08): the data set is AT its
62/// floor, final until space is freed or the storage grown — a kit stops on it. Plain words for
63/// the customer; the numbers go to the journal (`push.space.at_floor`). Not
64/// [`crate::space::FULL_NOW`], which is "try again".
65pub const AT_FLOOR: &str = "Gunnar's storage is full";
66
67/// The default percentage floor. See the module header for why 5.
68pub const DEFAULT_FLOOR_PERCENT: u8 = 5;
69/// The default absolute floor, 2 GiB. See the module header for why.
70pub const DEFAULT_FLOOR_BYTES: u64 = 2 * GIB;
71
72const KIB: u64 = 1024;
73const MIB: u64 = 1024 * KIB;
74const GIB: u64 = 1024 * MIB;
75const TIB: u64 = 1024 * GIB;
76
77/// **How much must stay free**, as a percentage of the filesystem and as an
78/// absolute byte count; the larger of the two is the floor.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct DiskFloor {
81    /// Percent of `f_blocks` that must remain available. `0` disables the
82    /// percentage term.
83    pub percent: u8,
84    /// Bytes that must remain available. `0` disables the absolute term.
85    pub bytes: u64,
86}
87
88impl Default for DiskFloor {
89    fn default() -> Self {
90        DiskFloor {
91            percent: DEFAULT_FLOOR_PERCENT,
92            bytes: DEFAULT_FLOOR_BYTES,
93        }
94    }
95}
96
97impl DiskFloor {
98    /// **`--disk-floor`'s grammar.** A comma-separated list of terms, each
99    /// either a percentage (`5%`) or a byte count with a unit (`2GiB`, `500M`,
100    /// `1T`, `4096`); the larger of the two kinds wins at measurement time. A
101    /// term of one kind replaces the default of that kind, so `--disk-floor
102    /// 10%` keeps the 2 GiB absolute floor and `--disk-floor 0%,512MiB` is a
103    /// pure byte floor. `0` on both is refused by name: a gate that can never
104    /// refuse is not a gate, and an operator who wants none should not have
105    /// one that merely looks like one.
106    pub fn parse(spec: &str) -> Result<DiskFloor, String> {
107        let mut floor = DiskFloor::default();
108        let mut saw_percent = false;
109        let mut saw_bytes = false;
110        for raw in spec.split(',') {
111            let term = raw.trim();
112            if term.is_empty() {
113                return Err(format!("--disk-floor {spec:?}: an empty term"));
114            }
115            if let Some(number) = term.strip_suffix('%') {
116                let percent: u8 = number
117                    .trim()
118                    .parse()
119                    .map_err(|_| format!("--disk-floor {spec:?}: {term:?} is not a percentage"))?;
120                if percent > 99 {
121                    return Err(format!(
122                        "--disk-floor {spec:?}: {percent}% would refuse every push forever"
123                    ));
124                }
125                if saw_percent {
126                    return Err(format!("--disk-floor {spec:?}: two percentage terms"));
127                }
128                saw_percent = true;
129                floor.percent = percent;
130                continue;
131            }
132            let bytes = parse_bytes(term).ok_or_else(|| {
133                format!(
134                    "--disk-floor {spec:?}: {term:?} is not a byte count (try 2GiB, 500M, 4096)"
135                )
136            })?;
137            if saw_bytes {
138                return Err(format!("--disk-floor {spec:?}: two byte terms"));
139            }
140            saw_bytes = true;
141            floor.bytes = bytes;
142        }
143        if floor.percent == 0 && floor.bytes == 0 {
144            return Err(format!(
145                "--disk-floor {spec:?} disables both terms; a gate that can never refuse is not \
146                 a gate. Give a percentage, a byte count, or leave the flag off for the default \
147                 ({DEFAULT_FLOOR_PERCENT}%,{})",
148                human(DEFAULT_FLOOR_BYTES)
149            ));
150        }
151        Ok(floor)
152    }
153
154    /// The floor in bytes for a filesystem of `total` bytes.
155    pub fn bytes_for(&self, total: u64) -> u64 {
156        let percent = (total / 100).saturating_mul(u64::from(self.percent));
157        percent.max(self.bytes)
158    }
159}
160
161impl std::fmt::Display for DiskFloor {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        write!(f, "{}%,{}", self.percent, human(self.bytes))
164    }
165}
166
167fn parse_bytes(term: &str) -> Option<u64> {
168    let term = term.trim();
169    let split = term
170        .find(|c: char| !c.is_ascii_digit())
171        .unwrap_or(term.len());
172    let (digits, unit) = term.split_at(split);
173    let value: u64 = digits.parse().ok()?;
174    let multiplier = match unit.trim().to_ascii_lowercase().as_str() {
175        "" | "b" => 1,
176        "k" | "kib" | "kb" => KIB,
177        "m" | "mib" | "mb" => MIB,
178        "g" | "gib" | "gb" => GIB,
179        "t" | "tib" | "tb" => TIB,
180        _ => return None,
181    };
182    value.checked_mul(multiplier)
183}
184
185/// Bytes as an operator reads them: `1.2 GiB`, `512.0 MiB`, `4096 B`.
186pub fn human(bytes: u64) -> String {
187    if bytes >= TIB {
188        format!("{:.1} TiB", bytes as f64 / TIB as f64)
189    } else if bytes >= GIB {
190        format!("{:.1} GiB", bytes as f64 / GIB as f64)
191    } else if bytes >= MIB {
192        format!("{:.1} MiB", bytes as f64 / MIB as f64)
193    } else if bytes >= KIB {
194        format!("{:.1} KiB", bytes as f64 / KIB as f64)
195    } else {
196        format!("{bytes} B")
197    }
198}
199
200/// One measurement of the store root's filesystem.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub struct Headroom {
203    /// Bytes available to an unprivileged writer (`f_bavail * f_frsize`).
204    pub free: u64,
205    /// The filesystem's size (`f_blocks * f_frsize`).
206    pub total: u64,
207    /// The floor in force for this filesystem, [`DiskFloor::bytes_for`].
208    pub floor: u64,
209}
210
211impl Headroom {
212    /// Is there room? `false` is a refusal.
213    pub fn has_room(&self) -> bool {
214        self.free > self.floor
215    }
216}
217
218/// **The community gate.** `verdict` is always `Allow` — this gate knows no
219/// plan; `pack_quota` refuses when the store root is under the floor, or when
220/// everything above the floor is already promised to transfers still landing.
221///
222/// It owns the data set's [`SpaceLedger`] (`crate::space`): the same floor and
223/// the same measurement, so the door before the first byte and the receive
224/// loop after it cannot disagree about where the line is. `gunnar serve` hands
225/// [`Self::ledger`] to every door that writes.
226#[derive(Debug, Clone)]
227pub struct DiskHeadroomGate {
228    root: PathBuf,
229    floor: DiskFloor,
230    ledger: Arc<SpaceLedger>,
231}
232
233impl DiskHeadroomGate {
234    /// Guard the filesystem `root` lives on. `root` is the store root
235    /// (`/data` on the appliance); the measurement is of its filesystem, so a
236    /// path anywhere on the same mount reads the same.
237    pub fn new(root: impl Into<PathBuf>, floor: DiskFloor) -> Self {
238        let root = root.into();
239        let ledger = Arc::new(SpaceLedger::new(root.clone(), floor));
240        DiskHeadroomGate {
241            root,
242            floor,
243            ledger,
244        }
245    }
246
247    /// The gate over an existing ledger — its root, its floor, its probe (a
248    /// test's fake disk measures through here too).
249    pub fn over(ledger: Arc<SpaceLedger>) -> Self {
250        DiskHeadroomGate {
251            root: ledger.root().to_path_buf(),
252            floor: ledger.floor(),
253            ledger,
254        }
255    }
256
257    /// **The data set's space ledger**: what every writing door asks before
258    /// each chunk it takes.
259    pub fn ledger(&self) -> &Arc<SpaceLedger> {
260        &self.ledger
261    }
262
263    /// The path this gate measures.
264    pub fn root(&self) -> &Path {
265        &self.root
266    }
267
268    /// The floor this gate was configured with.
269    pub fn floor(&self) -> DiskFloor {
270        self.floor
271    }
272
273    /// Measure. One `statvfs` (the ledger's probe); the error is the OS's.
274    ///
275    /// The RAW reading — what is on the disk, no promises subtracted. This is
276    /// what `Admin.Capacity` reports and monetize's growth trigger reads
277    /// (`used = total − servable`): a promise is not a byte used, and a refused
278    /// burst must not read as a full set (see `crate::space`).
279    pub fn headroom(&self) -> std::io::Result<Headroom> {
280        let (free, total) = self.ledger.probe().measure()?;
281        Ok(Headroom {
282            free,
283            total,
284            floor: self.floor.bytes_for(total),
285        })
286    }
287
288    /// **The one refusal, for every door.** `None` is room; `Some(line)` is
289    /// what the client is told, naming the free bytes and the floor. A
290    /// measurement that fails is a refusal that says so.
291    ///
292    /// Two lines, and the difference is the point: UNDER the floor is the
293    /// floor's line (final until space is reclaimed or grown); above it but
294    /// with everything above it promised to transfers still landing is
295    /// [`crate::space::FULL_NOW`]'s (try again in a moment).
296    pub fn refusal(&self) -> Option<String> {
297        match self.headroom() {
298            Ok(room) if room.has_room() => self.ledger.full_before_the_first_byte(),
299            // The customer is told in plain words (wiring tracer B 2026-10-08: a customer
300            // never sees room left); what was free, the floor and the root are the journal's.
301            Ok(room) => {
302                tracing::warn!(
303                    target: "gunnar::space",
304                    event = "push.space.at_floor",
305                    root = %self.root.display(),
306                    free = %human(room.free),
307                    floor = %human(room.floor),
308                    disk_floor = %self.floor,
309                    "the data set is under its disk floor: no new repository and no push until space is reclaimed (`gunnar control repo delete --purge`, `gunnar gc`) or the storage is grown"
310                );
311                Some(format!(
312                    "{AT_FLOOR}: no new repository and no push until space is freed or the \
313                     storage is grown. Nothing was received"
314                ))
315            }
316            Err(err) => {
317                tracing::error!(
318                    target: "gunnar::space",
319                    event = "push.space.unmeasured",
320                    root = %self.root.display(),
321                    %err,
322                    "the data set could not be measured: writes are refused rather than guessing there is room"
323                );
324                Some(
325                    "Gunnar could not check its storage, so this was refused rather than risk it; \
326                     try again in a while"
327                        .to_string(),
328                )
329            }
330        }
331    }
332}
333
334impl PlanGate for DiskHeadroomGate {
335    fn verdict(&self, _namespace: &str, _action: PlanAction) -> PlanVerdict {
336        PlanVerdict::Allow
337    }
338
339    fn pack_quota(&self, _namespace: &str) -> PlanVerdict {
340        match self.refusal() {
341            None => PlanVerdict::Allow,
342            Some(line) => PlanVerdict::Refuse(line),
343        }
344    }
345
346    fn applies_to_machine(&self) -> bool {
347        true
348    }
349
350    fn name(&self) -> &'static str {
351        "disk-headroom"
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    #[test]
360    fn the_default_floor_is_five_percent_or_two_gib_whichever_is_larger() {
361        let floor = DiskFloor::default();
362        // A 25 GiB disk: 5 % is 1.25 GiB, so the 2 GiB absolute floor wins.
363        assert_eq!(floor.bytes_for(25 * GIB), 2 * GIB);
364        // gunnar.rs's 224.9 GiB /data: 5 % is ~11.2 GiB and wins.
365        let big = 2249 * GIB / 10;
366        assert_eq!(floor.bytes_for(big), (big / 100) * 5);
367        assert!(floor.bytes_for(big) > 2 * GIB);
368    }
369
370    #[test]
371    fn the_flag_grammar_takes_a_percent_a_byte_count_or_both_and_refuses_nothing_at_all() {
372        assert_eq!(
373            DiskFloor::parse("10%").unwrap(),
374            DiskFloor {
375                percent: 10,
376                bytes: DEFAULT_FLOOR_BYTES
377            }
378        );
379        assert_eq!(
380            DiskFloor::parse("512MiB").unwrap(),
381            DiskFloor {
382                percent: DEFAULT_FLOOR_PERCENT,
383                bytes: 512 * MIB
384            }
385        );
386        assert_eq!(
387            DiskFloor::parse("0%, 4096").unwrap(),
388            DiskFloor {
389                percent: 0,
390                bytes: 4096
391            }
392        );
393        assert_eq!(DiskFloor::parse("1T").unwrap().bytes, TIB);
394        assert_eq!(DiskFloor::parse("3g").unwrap().bytes, 3 * GIB);
395        for bad in ["", "abc", "5%%", "100%", "2GiB,3GiB", "5%,6%", "0%,0"] {
396            let err = DiskFloor::parse(bad).expect_err(bad);
397            assert!(err.contains("--disk-floor"), "{bad:?}: {err}");
398        }
399        assert!(
400            DiskFloor::parse("0%,0")
401                .unwrap_err()
402                .contains("can never refuse"),
403            "the all-zero floor must be refused BY NAME as a non-gate"
404        );
405    }
406
407    #[test]
408    fn a_floor_above_the_free_space_refuses_and_names_the_free_bytes_and_the_floor() {
409        let dir = tempfile::tempdir().expect("tempdir");
410        // u64::MAX bytes can never be free.
411        let gate = DiskHeadroomGate::new(
412            dir.path(),
413            DiskFloor {
414                percent: 0,
415                bytes: u64::MAX,
416            },
417        );
418        let room = gate.headroom().expect("statvfs on a tempdir");
419        assert!(room.total > 0, "a real filesystem has a size: {room:?}");
420        assert!(!room.has_room());
421        let line = gate.refusal().expect("under the floor is a refusal");
422        assert!(
423            line.starts_with(AT_FLOOR),
424            "the floor's own, final line: {line}"
425        );
426        assert!(
427            matches!(gate.pack_quota("anyone"), PlanVerdict::Refuse(l) if l == line),
428            "pack_quota is the same refusal"
429        );
430        // Reads are never refused by this gate, whatever the disk says.
431        assert_eq!(
432            gate.verdict("anyone", PlanAction::Write),
433            PlanVerdict::Allow
434        );
435        assert_eq!(
436            gate.verdict("anyone", PlanAction::AnonymousRead),
437            PlanVerdict::Allow
438        );
439        assert!(
440            gate.applies_to_machine(),
441            "a full standby refuses its primary too"
442        );
443    }
444
445    /// ★ **The customer is told the storage is full in plain words** (wiring
446    /// tracer B 2026-10-08; owner: a customer never sees caps or room left):
447    /// no free bytes, no floor, no `--disk-floor`, no path, no operator
448    /// command. The line starts with a stable phrase a kit can stop on; the
449    /// numbers are the journal's.
450    #[test]
451    fn the_floor_line_is_plain_words_and_the_numbers_go_to_the_journal() {
452        let dir = tempfile::tempdir().expect("tempdir");
453        let gate = DiskHeadroomGate::new(
454            dir.path(),
455            DiskFloor {
456                percent: 0,
457                bytes: u64::MAX,
458            },
459        );
460        let line = gate.refusal().expect("under the floor is a refusal");
461        assert!(line.starts_with("Gunnar's storage is full"), "{line}");
462        assert!(line.contains("no new repository and no push"), "{line}");
463        for internal in [
464            "--disk-floor",
465            "gunnar gc",
466            "repo delete",
467            "MiB",
468            "GiB",
469            " free, under the ",
470        ] {
471            assert!(
472                !line.contains(internal),
473                "{internal:?} reaches the customer: {line}"
474            );
475        }
476        assert!(
477            !line.contains(&dir.path().display().to_string()),
478            "the data root reaches the customer: {line}"
479        );
480        assert!(
481            !line.chars().any(|c| c.is_ascii_digit()),
482            "a number reaches the customer: {line}"
483        );
484        let gone = DiskHeadroomGate::new(dir.path().join("does-not-exist"), DiskFloor::default());
485        let line = gone.refusal().expect("an unmeasurable root refuses");
486        assert!(
487            !line.contains("does-not-exist") && line.contains("try again"),
488            "{line}"
489        );
490    }
491
492    #[test]
493    fn a_floor_below_the_free_space_allows() {
494        let dir = tempfile::tempdir().expect("tempdir");
495        let gate = DiskHeadroomGate::new(
496            dir.path(),
497            DiskFloor {
498                percent: 0,
499                bytes: 1,
500            },
501        );
502        let room = gate.headroom().expect("statvfs");
503        assert!(
504            room.free > 1,
505            "this test needs a disk with more than one free byte"
506        );
507        assert_eq!(gate.refusal(), None);
508        assert_eq!(gate.pack_quota("anyone"), PlanVerdict::Allow);
509    }
510
511    #[test]
512    fn a_root_that_cannot_be_measured_is_a_refusal_that_says_so() {
513        let dir = tempfile::tempdir().expect("tempdir");
514        let gate = DiskHeadroomGate::new(dir.path().join("does-not-exist"), DiskFloor::default());
515        let line = gate.refusal().expect("an unmeasurable root refuses");
516        assert!(line.contains("could not check its storage"), "{line}");
517    }
518
519    #[test]
520    fn human_units_read_as_an_operator_reads_them() {
521        assert_eq!(human(512), "512 B");
522        assert_eq!(human(2 * GIB), "2.0 GiB");
523        assert_eq!(human(1_288_490_189), "1.2 GiB");
524        assert_eq!(human(3 * MIB / 2), "1.5 MiB");
525    }
526}