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)
133                .ok_or_else(|| format!("--disk-floor {spec:?}: {term:?} is not a byte count (try 2GiB, 500M, 4096)"))?;
134            if saw_bytes {
135                return Err(format!("--disk-floor {spec:?}: two byte terms"));
136            }
137            saw_bytes = true;
138            floor.bytes = bytes;
139        }
140        if floor.percent == 0 && floor.bytes == 0 {
141            return Err(format!(
142                "--disk-floor {spec:?} disables both terms; a gate that can never refuse is not \
143                 a gate. Give a percentage, a byte count, or leave the flag off for the default \
144                 ({DEFAULT_FLOOR_PERCENT}%,{})",
145                human(DEFAULT_FLOOR_BYTES)
146            ));
147        }
148        Ok(floor)
149    }
150
151    /// The floor in bytes for a filesystem of `total` bytes.
152    pub fn bytes_for(&self, total: u64) -> u64 {
153        let percent = (total / 100).saturating_mul(u64::from(self.percent));
154        percent.max(self.bytes)
155    }
156}
157
158impl std::fmt::Display for DiskFloor {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        write!(f, "{}%,{}", self.percent, human(self.bytes))
161    }
162}
163
164fn parse_bytes(term: &str) -> Option<u64> {
165    let term = term.trim();
166    let split = term
167        .find(|c: char| !c.is_ascii_digit())
168        .unwrap_or(term.len());
169    let (digits, unit) = term.split_at(split);
170    let value: u64 = digits.parse().ok()?;
171    let multiplier = match unit.trim().to_ascii_lowercase().as_str() {
172        "" | "b" => 1,
173        "k" | "kib" | "kb" => KIB,
174        "m" | "mib" | "mb" => MIB,
175        "g" | "gib" | "gb" => GIB,
176        "t" | "tib" | "tb" => TIB,
177        _ => return None,
178    };
179    value.checked_mul(multiplier)
180}
181
182/// Bytes as an operator reads them: `1.2 GiB`, `512.0 MiB`, `4096 B`.
183pub fn human(bytes: u64) -> String {
184    if bytes >= TIB {
185        format!("{:.1} TiB", bytes as f64 / TIB as f64)
186    } else if bytes >= GIB {
187        format!("{:.1} GiB", bytes as f64 / GIB as f64)
188    } else if bytes >= MIB {
189        format!("{:.1} MiB", bytes as f64 / MIB as f64)
190    } else if bytes >= KIB {
191        format!("{:.1} KiB", bytes as f64 / KIB as f64)
192    } else {
193        format!("{bytes} B")
194    }
195}
196
197/// One measurement of the store root's filesystem.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub struct Headroom {
200    /// Bytes available to an unprivileged writer (`f_bavail * f_frsize`).
201    pub free: u64,
202    /// The filesystem's size (`f_blocks * f_frsize`).
203    pub total: u64,
204    /// The floor in force for this filesystem, [`DiskFloor::bytes_for`].
205    pub floor: u64,
206}
207
208impl Headroom {
209    /// Is there room? `false` is a refusal.
210    pub fn has_room(&self) -> bool {
211        self.free > self.floor
212    }
213}
214
215/// **The community gate.** `verdict` is always `Allow` — this gate knows no
216/// plan; `pack_quota` refuses when the store root is under the floor, or when
217/// everything above the floor is already promised to transfers still landing.
218///
219/// It owns the data set's [`SpaceLedger`] (`crate::space`): the same floor and
220/// the same measurement, so the door before the first byte and the receive
221/// loop after it cannot disagree about where the line is. `gunnar serve` hands
222/// [`Self::ledger`] to every door that writes.
223#[derive(Debug, Clone)]
224pub struct DiskHeadroomGate {
225    root: PathBuf,
226    floor: DiskFloor,
227    ledger: Arc<SpaceLedger>,
228}
229
230impl DiskHeadroomGate {
231    /// Guard the filesystem `root` lives on. `root` is the store root
232    /// (`/data` on the appliance); the measurement is of its filesystem, so a
233    /// path anywhere on the same mount reads the same.
234    pub fn new(root: impl Into<PathBuf>, floor: DiskFloor) -> Self {
235        let root = root.into();
236        let ledger = Arc::new(SpaceLedger::new(root.clone(), floor));
237        DiskHeadroomGate { root, floor, ledger }
238    }
239
240    /// The gate over an existing ledger — its root, its floor, its probe (a
241    /// test's fake disk measures through here too).
242    pub fn over(ledger: Arc<SpaceLedger>) -> Self {
243        DiskHeadroomGate {
244            root: ledger.root().to_path_buf(),
245            floor: ledger.floor(),
246            ledger,
247        }
248    }
249
250    /// **The data set's space ledger**: what every writing door asks before
251    /// each chunk it takes.
252    pub fn ledger(&self) -> &Arc<SpaceLedger> {
253        &self.ledger
254    }
255
256    /// The path this gate measures.
257    pub fn root(&self) -> &Path {
258        &self.root
259    }
260
261    /// The floor this gate was configured with.
262    pub fn floor(&self) -> DiskFloor {
263        self.floor
264    }
265
266    /// Measure. One `statvfs` (the ledger's probe); the error is the OS's.
267    ///
268    /// The RAW reading — what is on the disk, no promises subtracted. This is
269    /// what `Admin.Capacity` reports and monetize's growth trigger reads
270    /// (`used = total − servable`): a promise is not a byte used, and a refused
271    /// burst must not read as a full set (see `crate::space`).
272    pub fn headroom(&self) -> std::io::Result<Headroom> {
273        let (free, total) = self.ledger.probe().measure()?;
274        Ok(Headroom {
275            free,
276            total,
277            floor: self.floor.bytes_for(total),
278        })
279    }
280
281    /// **The one refusal, for every door.** `None` is room; `Some(line)` is
282    /// what the client is told, naming the free bytes and the floor. A
283    /// measurement that fails is a refusal that says so.
284    ///
285    /// Two lines, and the difference is the point: UNDER the floor is the
286    /// floor's line (final until space is reclaimed or grown); above it but
287    /// with everything above it promised to transfers still landing is
288    /// [`crate::space::FULL_NOW`]'s (try again in a moment).
289    pub fn refusal(&self) -> Option<String> {
290        match self.headroom() {
291            Ok(room) if room.has_room() => self.ledger.full_before_the_first_byte(),
292            // The customer is told in plain words (wiring tracer B 2026-10-08: a customer
293            // never sees room left); what was free, the floor and the root are the journal's.
294            Ok(room) => {
295                tracing::warn!(
296                    target: "gunnar::space",
297                    event = "push.space.at_floor",
298                    root = %self.root.display(),
299                    free = %human(room.free),
300                    floor = %human(room.floor),
301                    disk_floor = %self.floor,
302                    "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"
303                );
304                Some(format!(
305                    "{AT_FLOOR}: no new repository and no push until space is freed or the \
306                     storage is grown. Nothing was received"
307                ))
308            }
309            Err(err) => {
310                tracing::error!(
311                    target: "gunnar::space",
312                    event = "push.space.unmeasured",
313                    root = %self.root.display(),
314                    %err,
315                    "the data set could not be measured: writes are refused rather than guessing there is room"
316                );
317                Some(
318                    "Gunnar could not check its storage, so this was refused rather than risk it; \
319                     try again in a while"
320                        .to_string(),
321                )
322            }
323        }
324    }
325}
326
327impl PlanGate for DiskHeadroomGate {
328    fn verdict(&self, _namespace: &str, _action: PlanAction) -> PlanVerdict {
329        PlanVerdict::Allow
330    }
331
332    fn pack_quota(&self, _namespace: &str) -> PlanVerdict {
333        match self.refusal() {
334            None => PlanVerdict::Allow,
335            Some(line) => PlanVerdict::Refuse(line),
336        }
337    }
338
339    fn applies_to_machine(&self) -> bool {
340        true
341    }
342
343    fn name(&self) -> &'static str {
344        "disk-headroom"
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn the_default_floor_is_five_percent_or_two_gib_whichever_is_larger() {
354        let floor = DiskFloor::default();
355        // A 25 GiB disk: 5 % is 1.25 GiB, so the 2 GiB absolute floor wins.
356        assert_eq!(floor.bytes_for(25 * GIB), 2 * GIB);
357        // gunnar.rs's 224.9 GiB /data: 5 % is ~11.2 GiB and wins.
358        let big = 2249 * GIB / 10;
359        assert_eq!(floor.bytes_for(big), (big / 100) * 5);
360        assert!(floor.bytes_for(big) > 2 * GIB);
361    }
362
363    #[test]
364    fn the_flag_grammar_takes_a_percent_a_byte_count_or_both_and_refuses_nothing_at_all() {
365        assert_eq!(
366            DiskFloor::parse("10%").unwrap(),
367            DiskFloor {
368                percent: 10,
369                bytes: DEFAULT_FLOOR_BYTES
370            }
371        );
372        assert_eq!(
373            DiskFloor::parse("512MiB").unwrap(),
374            DiskFloor {
375                percent: DEFAULT_FLOOR_PERCENT,
376                bytes: 512 * MIB
377            }
378        );
379        assert_eq!(
380            DiskFloor::parse("0%, 4096").unwrap(),
381            DiskFloor {
382                percent: 0,
383                bytes: 4096
384            }
385        );
386        assert_eq!(DiskFloor::parse("1T").unwrap().bytes, TIB);
387        assert_eq!(DiskFloor::parse("3g").unwrap().bytes, 3 * GIB);
388        for bad in ["", "abc", "5%%", "100%", "2GiB,3GiB", "5%,6%", "0%,0"] {
389            let err = DiskFloor::parse(bad).expect_err(bad);
390            assert!(err.contains("--disk-floor"), "{bad:?}: {err}");
391        }
392        assert!(
393            DiskFloor::parse("0%,0")
394                .unwrap_err()
395                .contains("can never refuse"),
396            "the all-zero floor must be refused BY NAME as a non-gate"
397        );
398    }
399
400    #[test]
401    fn a_floor_above_the_free_space_refuses_and_names_the_free_bytes_and_the_floor() {
402        let dir = tempfile::tempdir().expect("tempdir");
403        // u64::MAX bytes can never be free.
404        let gate = DiskHeadroomGate::new(
405            dir.path(),
406            DiskFloor {
407                percent: 0,
408                bytes: u64::MAX,
409            },
410        );
411        let room = gate.headroom().expect("statvfs on a tempdir");
412        assert!(room.total > 0, "a real filesystem has a size: {room:?}");
413        assert!(!room.has_room());
414        let line = gate.refusal().expect("under the floor is a refusal");
415        assert!(line.starts_with(AT_FLOOR), "the floor's own, final line: {line}");
416        assert!(
417            matches!(gate.pack_quota("anyone"), PlanVerdict::Refuse(l) if l == line),
418            "pack_quota is the same refusal"
419        );
420        // Reads are never refused by this gate, whatever the disk says.
421        assert_eq!(gate.verdict("anyone", PlanAction::Write), PlanVerdict::Allow);
422        assert_eq!(
423            gate.verdict("anyone", PlanAction::AnonymousRead),
424            PlanVerdict::Allow
425        );
426        assert!(gate.applies_to_machine(), "a full standby refuses its primary too");
427    }
428
429    /// ★ **The customer is told the storage is full in plain words** (wiring
430    /// tracer B 2026-10-08; owner: a customer never sees caps or room left):
431    /// no free bytes, no floor, no `--disk-floor`, no path, no operator
432    /// command. The line starts with a stable phrase a kit can stop on; the
433    /// numbers are the journal's.
434    #[test]
435    fn the_floor_line_is_plain_words_and_the_numbers_go_to_the_journal() {
436        let dir = tempfile::tempdir().expect("tempdir");
437        let gate = DiskHeadroomGate::new(dir.path(), DiskFloor { percent: 0, bytes: u64::MAX });
438        let line = gate.refusal().expect("under the floor is a refusal");
439        assert!(line.starts_with("Gunnar's storage is full"), "{line}");
440        assert!(line.contains("no new repository and no push"), "{line}");
441        for internal in ["--disk-floor", "gunnar gc", "repo delete", "MiB", "GiB", " free, under the "] {
442            assert!(!line.contains(internal), "{internal:?} reaches the customer: {line}");
443        }
444        assert!(!line.contains(&dir.path().display().to_string()), "the data root reaches the customer: {line}");
445        assert!(!line.chars().any(|c| c.is_ascii_digit()), "a number reaches the customer: {line}");
446        let gone = DiskHeadroomGate::new(dir.path().join("does-not-exist"), DiskFloor::default());
447        let line = gone.refusal().expect("an unmeasurable root refuses");
448        assert!(!line.contains("does-not-exist") && line.contains("try again"), "{line}");
449    }
450
451    #[test]
452    fn a_floor_below_the_free_space_allows() {
453        let dir = tempfile::tempdir().expect("tempdir");
454        let gate = DiskHeadroomGate::new(
455            dir.path(),
456            DiskFloor {
457                percent: 0,
458                bytes: 1,
459            },
460        );
461        let room = gate.headroom().expect("statvfs");
462        assert!(room.free > 1, "this test needs a disk with more than one free byte");
463        assert_eq!(gate.refusal(), None);
464        assert_eq!(gate.pack_quota("anyone"), PlanVerdict::Allow);
465    }
466
467    #[test]
468    fn a_root_that_cannot_be_measured_is_a_refusal_that_says_so() {
469        let dir = tempfile::tempdir().expect("tempdir");
470        let gate = DiskHeadroomGate::new(dir.path().join("does-not-exist"), DiskFloor::default());
471        let line = gate.refusal().expect("an unmeasurable root refuses");
472        assert!(line.contains("could not check its storage"), "{line}");
473    }
474
475    #[test]
476    fn human_units_read_as_an_operator_reads_them() {
477        assert_eq!(human(512), "512 B");
478        assert_eq!(human(2 * GIB), "2.0 GiB");
479        assert_eq!(human(1_288_490_189), "1.2 GiB");
480        assert_eq!(human(3 * MIB / 2), "1.5 MiB");
481    }
482}