Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-ssh/src/headroom.rs

//! **The disk-headroom gate: the community [`PlanGate`], the "free lunch".**
//!
//! Decided 2026-09-04: there are two implementations — 1) the free lunch,
//! basically checking disk has size before a new repo is created; 2) the
//! monetize-bound one. This is the first. It has no monetize dependency, it is always
//! compiled, and it is the first link of every `Control`'s gate chain — on the
//! community binary the only one.
//!
//! # What it refuses, and where
//!
//! | request | asked | answer |
//! |---|---|---|
//! | `git-receive-pack`, LFS upload (`Dispatch::authorise_service`) | [`PlanGate::pack_quota`] | `Refuse` under the floor, **before a pack byte** |
//! | push-to-create (the first-repository door) | the same, because it is the same call | the same |
//! | `Repos.Create` (`gunnar-server::control_plane`) | [`DiskHeadroomGate::refusal`] | `RESOURCE_EXHAUSTED` with the same line |
//! | any read, anonymous or not | nothing | never — a full disk still serves clones |
//!
//! One function, [`DiskHeadroomGate::refusal`], produces the line for all three
//! doors (one implementation): "the disk is full" must be spelled identically to a `git
//! push` and to a `gunnar control repo create`, or an operator debugging the
//! second will not recognise the first.
//!
//! # The floor: 5 % or 2 GiB, whichever is LARGER
//!
//! Two numbers because each is wrong alone:
//!
//! * **5 %** is the size of the reserve the box needs to keep *operating*
//!   after it stops accepting: `znippy`'s append-side gc needs scratch to
//!   compact a pack, the control store commits a new document by writing a
//!   fresh blob beside the old one, and `replication.json` / `webhooks.json`
//!   are rewritten whole. It is also `mkfs.ext4`'s own reserved-blocks default,
//!   and refusing at the same line means gunnar says no BEFORE root's reserve is
//!   what keeps the box alive. On a 224.9 GiB `/data` (gunnar.rs) that is
//!   ~11 GiB — enough for the largest single push measured there.
//! * **2 GiB** is the absolute floor for the small disk, where 5 % is nothing:
//!   on a 25 GiB appliance volume 5 % is 1.25 GiB, smaller than one pack of
//!   `alice/linux-mirror`, so the percentage alone would admit a push that
//!   fills the disk mid-stream — and a pack that dies mid-write is the power-cut
//!   power-cut shape (a torn file) without the power cut.
//!
//! The larger of the two wins, so a big disk keeps a proportionate reserve and
//! a small one keeps a survivable one. `--disk-floor` overrides both; see
//! [`DiskFloor::parse`].
//!
//! # Measured with `statvfs`, never `df`
//!
//! Pure Rust: `rustix::fs::statvfs` on the store root, one syscall, no
//! subprocess. `f_bavail` — the blocks available to an UNPRIVILEGED caller —
//! not `f_bfree`, because the appliance runs as root and `f_bfree` would count
//! the reserved blocks the box must never eat. A `statvfs` that fails is a
//! **refusal**, not an allow: a gate that cannot measure and says yes is the
//! always-allow gate that proves nothing, and the line says why so the operator
//! learns the mount is the problem, not the space.

use std::path::{Path, PathBuf};
use std::sync::Arc;

use crate::plan::{PlanAction, PlanGate, PlanVerdict};
use crate::space::SpaceLedger;

/// **The start of the floor's refusal** (wiring tracer B 2026-10-08): the data set is AT its
/// floor, final until space is freed or the storage grown — a kit stops on it. Plain words for
/// the customer; the numbers go to the journal (`push.space.at_floor`). Not
/// [`crate::space::FULL_NOW`], which is "try again".
pub const AT_FLOOR: &str = "Gunnar's storage is full";

/// The default percentage floor. See the module header for why 5.
pub const DEFAULT_FLOOR_PERCENT: u8 = 5;
/// The default absolute floor, 2 GiB. See the module header for why.
pub const DEFAULT_FLOOR_BYTES: u64 = 2 * GIB;

const KIB: u64 = 1024;
const MIB: u64 = 1024 * KIB;
const GIB: u64 = 1024 * MIB;
const TIB: u64 = 1024 * GIB;

/// **How much must stay free**, as a percentage of the filesystem and as an
/// absolute byte count; the larger of the two is the floor.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DiskFloor {
    /// Percent of `f_blocks` that must remain available. `0` disables the
    /// percentage term.
    pub percent: u8,
    /// Bytes that must remain available. `0` disables the absolute term.
    pub bytes: u64,
}

impl Default for DiskFloor {
    fn default() -> Self {
        DiskFloor {
            percent: DEFAULT_FLOOR_PERCENT,
            bytes: DEFAULT_FLOOR_BYTES,
        }
    }
}

impl DiskFloor {
    /// **`--disk-floor`'s grammar.** A comma-separated list of terms, each
    /// either a percentage (`5%`) or a byte count with a unit (`2GiB`, `500M`,
    /// `1T`, `4096`); the larger of the two kinds wins at measurement time. A
    /// term of one kind replaces the default of that kind, so `--disk-floor
    /// 10%` keeps the 2 GiB absolute floor and `--disk-floor 0%,512MiB` is a
    /// pure byte floor. `0` on both is refused by name: a gate that can never
    /// refuse is not a gate, and an operator who wants none should not have
    /// one that merely looks like one.
    pub fn parse(spec: &str) -> Result<DiskFloor, String> {
        let mut floor = DiskFloor::default();
        let mut saw_percent = false;
        let mut saw_bytes = false;
        for raw in spec.split(',') {
            let term = raw.trim();
            if term.is_empty() {
                return Err(format!("--disk-floor {spec:?}: an empty term"));
            }
            if let Some(number) = term.strip_suffix('%') {
                let percent: u8 = number
                    .trim()
                    .parse()
                    .map_err(|_| format!("--disk-floor {spec:?}: {term:?} is not a percentage"))?;
                if percent > 99 {
                    return Err(format!(
                        "--disk-floor {spec:?}: {percent}% would refuse every push forever"
                    ));
                }
                if saw_percent {
                    return Err(format!("--disk-floor {spec:?}: two percentage terms"));
                }
                saw_percent = true;
                floor.percent = percent;
                continue;
            }
            let bytes = parse_bytes(term).ok_or_else(|| {
                format!(
                    "--disk-floor {spec:?}: {term:?} is not a byte count (try 2GiB, 500M, 4096)"
                )
            })?;
            if saw_bytes {
                return Err(format!("--disk-floor {spec:?}: two byte terms"));
            }
            saw_bytes = true;
            floor.bytes = bytes;
        }
        if floor.percent == 0 && floor.bytes == 0 {
            return Err(format!(
                "--disk-floor {spec:?} disables both terms; a gate that can never refuse is not \
                 a gate. Give a percentage, a byte count, or leave the flag off for the default \
                 ({DEFAULT_FLOOR_PERCENT}%,{})",
                human(DEFAULT_FLOOR_BYTES)
            ));
        }
        Ok(floor)
    }

    /// The floor in bytes for a filesystem of `total` bytes.
    pub fn bytes_for(&self, total: u64) -> u64 {
        let percent = (total / 100).saturating_mul(u64::from(self.percent));
        percent.max(self.bytes)
    }
}

impl std::fmt::Display for DiskFloor {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}%,{}", self.percent, human(self.bytes))
    }
}

fn parse_bytes(term: &str) -> Option<u64> {
    let term = term.trim();
    let split = term
        .find(|c: char| !c.is_ascii_digit())
        .unwrap_or(term.len());
    let (digits, unit) = term.split_at(split);
    let value: u64 = digits.parse().ok()?;
    let multiplier = match unit.trim().to_ascii_lowercase().as_str() {
        "" | "b" => 1,
        "k" | "kib" | "kb" => KIB,
        "m" | "mib" | "mb" => MIB,
        "g" | "gib" | "gb" => GIB,
        "t" | "tib" | "tb" => TIB,
        _ => return None,
    };
    value.checked_mul(multiplier)
}

/// Bytes as an operator reads them: `1.2 GiB`, `512.0 MiB`, `4096 B`.
pub fn human(bytes: u64) -> String {
    if bytes >= TIB {
        format!("{:.1} TiB", bytes as f64 / TIB as f64)
    } else if bytes >= GIB {
        format!("{:.1} GiB", bytes as f64 / GIB as f64)
    } else if bytes >= MIB {
        format!("{:.1} MiB", bytes as f64 / MIB as f64)
    } else if bytes >= KIB {
        format!("{:.1} KiB", bytes as f64 / KIB as f64)
    } else {
        format!("{bytes} B")
    }
}

/// One measurement of the store root's filesystem.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Headroom {
    /// Bytes available to an unprivileged writer (`f_bavail * f_frsize`).
    pub free: u64,
    /// The filesystem's size (`f_blocks * f_frsize`).
    pub total: u64,
    /// The floor in force for this filesystem, [`DiskFloor::bytes_for`].
    pub floor: u64,
}

impl Headroom {
    /// Is there room? `false` is a refusal.
    pub fn has_room(&self) -> bool {
        self.free > self.floor
    }
}

/// **The community gate.** `verdict` is always `Allow` — this gate knows no
/// plan; `pack_quota` refuses when the store root is under the floor, or when
/// everything above the floor is already promised to transfers still landing.
///
/// It owns the data set's [`SpaceLedger`] (`crate::space`): the same floor and
/// the same measurement, so the door before the first byte and the receive
/// loop after it cannot disagree about where the line is. `gunnar serve` hands
/// [`Self::ledger`] to every door that writes.
#[derive(Debug, Clone)]
pub struct DiskHeadroomGate {
    root: PathBuf,
    floor: DiskFloor,
    ledger: Arc<SpaceLedger>,
}

impl DiskHeadroomGate {
    /// Guard the filesystem `root` lives on. `root` is the store root
    /// (`/data` on the appliance); the measurement is of its filesystem, so a
    /// path anywhere on the same mount reads the same.
    pub fn new(root: impl Into<PathBuf>, floor: DiskFloor) -> Self {
        let root = root.into();
        let ledger = Arc::new(SpaceLedger::new(root.clone(), floor));
        DiskHeadroomGate {
            root,
            floor,
            ledger,
        }
    }

    /// The gate over an existing ledger — its root, its floor, its probe (a
    /// test's fake disk measures through here too).
    pub fn over(ledger: Arc<SpaceLedger>) -> Self {
        DiskHeadroomGate {
            root: ledger.root().to_path_buf(),
            floor: ledger.floor(),
            ledger,
        }
    }

    /// **The data set's space ledger**: what every writing door asks before
    /// each chunk it takes.
    pub fn ledger(&self) -> &Arc<SpaceLedger> {
        &self.ledger
    }

    /// The path this gate measures.
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// The floor this gate was configured with.
    pub fn floor(&self) -> DiskFloor {
        self.floor
    }

    /// Measure. One `statvfs` (the ledger's probe); the error is the OS's.
    ///
    /// The RAW reading — what is on the disk, no promises subtracted. This is
    /// what `Admin.Capacity` reports and monetize's growth trigger reads
    /// (`used = total − servable`): a promise is not a byte used, and a refused
    /// burst must not read as a full set (see `crate::space`).
    pub fn headroom(&self) -> std::io::Result<Headroom> {
        let (free, total) = self.ledger.probe().measure()?;
        Ok(Headroom {
            free,
            total,
            floor: self.floor.bytes_for(total),
        })
    }

    /// **The one refusal, for every door.** `None` is room; `Some(line)` is
    /// what the client is told, naming the free bytes and the floor. A
    /// measurement that fails is a refusal that says so.
    ///
    /// Two lines, and the difference is the point: UNDER the floor is the
    /// floor's line (final until space is reclaimed or grown); above it but
    /// with everything above it promised to transfers still landing is
    /// [`crate::space::FULL_NOW`]'s (try again in a moment).
    pub fn refusal(&self) -> Option<String> {
        match self.headroom() {
            Ok(room) if room.has_room() => self.ledger.full_before_the_first_byte(),
            // The customer is told in plain words (wiring tracer B 2026-10-08: a customer
            // never sees room left); what was free, the floor and the root are the journal's.
            Ok(room) => {
                tracing::warn!(
                    target: "gunnar::space",
                    event = "push.space.at_floor",
                    root = %self.root.display(),
                    free = %human(room.free),
                    floor = %human(room.floor),
                    disk_floor = %self.floor,
                    "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"
                );
                Some(format!(
                    "{AT_FLOOR}: no new repository and no push until space is freed or the \
                     storage is grown. Nothing was received"
                ))
            }
            Err(err) => {
                tracing::error!(
                    target: "gunnar::space",
                    event = "push.space.unmeasured",
                    root = %self.root.display(),
                    %err,
                    "the data set could not be measured: writes are refused rather than guessing there is room"
                );
                Some(
                    "Gunnar could not check its storage, so this was refused rather than risk it; \
                     try again in a while"
                        .to_string(),
                )
            }
        }
    }
}

impl PlanGate for DiskHeadroomGate {
    fn verdict(&self, _namespace: &str, _action: PlanAction) -> PlanVerdict {
        PlanVerdict::Allow
    }

    fn pack_quota(&self, _namespace: &str) -> PlanVerdict {
        match self.refusal() {
            None => PlanVerdict::Allow,
            Some(line) => PlanVerdict::Refuse(line),
        }
    }

    fn applies_to_machine(&self) -> bool {
        true
    }

    fn name(&self) -> &'static str {
        "disk-headroom"
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_default_floor_is_five_percent_or_two_gib_whichever_is_larger() {
        let floor = DiskFloor::default();
        // A 25 GiB disk: 5 % is 1.25 GiB, so the 2 GiB absolute floor wins.
        assert_eq!(floor.bytes_for(25 * GIB), 2 * GIB);
        // gunnar.rs's 224.9 GiB /data: 5 % is ~11.2 GiB and wins.
        let big = 2249 * GIB / 10;
        assert_eq!(floor.bytes_for(big), (big / 100) * 5);
        assert!(floor.bytes_for(big) > 2 * GIB);
    }

    #[test]
    fn the_flag_grammar_takes_a_percent_a_byte_count_or_both_and_refuses_nothing_at_all() {
        assert_eq!(
            DiskFloor::parse("10%").unwrap(),
            DiskFloor {
                percent: 10,
                bytes: DEFAULT_FLOOR_BYTES
            }
        );
        assert_eq!(
            DiskFloor::parse("512MiB").unwrap(),
            DiskFloor {
                percent: DEFAULT_FLOOR_PERCENT,
                bytes: 512 * MIB
            }
        );
        assert_eq!(
            DiskFloor::parse("0%, 4096").unwrap(),
            DiskFloor {
                percent: 0,
                bytes: 4096
            }
        );
        assert_eq!(DiskFloor::parse("1T").unwrap().bytes, TIB);
        assert_eq!(DiskFloor::parse("3g").unwrap().bytes, 3 * GIB);
        for bad in ["", "abc", "5%%", "100%", "2GiB,3GiB", "5%,6%", "0%,0"] {
            let err = DiskFloor::parse(bad).expect_err(bad);
            assert!(err.contains("--disk-floor"), "{bad:?}: {err}");
        }
        assert!(
            DiskFloor::parse("0%,0")
                .unwrap_err()
                .contains("can never refuse"),
            "the all-zero floor must be refused BY NAME as a non-gate"
        );
    }

    #[test]
    fn a_floor_above_the_free_space_refuses_and_names_the_free_bytes_and_the_floor() {
        let dir = tempfile::tempdir().expect("tempdir");
        // u64::MAX bytes can never be free.
        let gate = DiskHeadroomGate::new(
            dir.path(),
            DiskFloor {
                percent: 0,
                bytes: u64::MAX,
            },
        );
        let room = gate.headroom().expect("statvfs on a tempdir");
        assert!(room.total > 0, "a real filesystem has a size: {room:?}");
        assert!(!room.has_room());
        let line = gate.refusal().expect("under the floor is a refusal");
        assert!(
            line.starts_with(AT_FLOOR),
            "the floor's own, final line: {line}"
        );
        assert!(
            matches!(gate.pack_quota("anyone"), PlanVerdict::Refuse(l) if l == line),
            "pack_quota is the same refusal"
        );
        // Reads are never refused by this gate, whatever the disk says.
        assert_eq!(
            gate.verdict("anyone", PlanAction::Write),
            PlanVerdict::Allow
        );
        assert_eq!(
            gate.verdict("anyone", PlanAction::AnonymousRead),
            PlanVerdict::Allow
        );
        assert!(
            gate.applies_to_machine(),
            "a full standby refuses its primary too"
        );
    }

    /// ★ **The customer is told the storage is full in plain words** (wiring
    /// tracer B 2026-10-08; owner: a customer never sees caps or room left):
    /// no free bytes, no floor, no `--disk-floor`, no path, no operator
    /// command. The line starts with a stable phrase a kit can stop on; the
    /// numbers are the journal's.
    #[test]
    fn the_floor_line_is_plain_words_and_the_numbers_go_to_the_journal() {
        let dir = tempfile::tempdir().expect("tempdir");
        let gate = DiskHeadroomGate::new(
            dir.path(),
            DiskFloor {
                percent: 0,
                bytes: u64::MAX,
            },
        );
        let line = gate.refusal().expect("under the floor is a refusal");
        assert!(line.starts_with("Gunnar's storage is full"), "{line}");
        assert!(line.contains("no new repository and no push"), "{line}");
        for internal in [
            "--disk-floor",
            "gunnar gc",
            "repo delete",
            "MiB",
            "GiB",
            " free, under the ",
        ] {
            assert!(
                !line.contains(internal),
                "{internal:?} reaches the customer: {line}"
            );
        }
        assert!(
            !line.contains(&dir.path().display().to_string()),
            "the data root reaches the customer: {line}"
        );
        assert!(
            !line.chars().any(|c| c.is_ascii_digit()),
            "a number reaches the customer: {line}"
        );
        let gone = DiskHeadroomGate::new(dir.path().join("does-not-exist"), DiskFloor::default());
        let line = gone.refusal().expect("an unmeasurable root refuses");
        assert!(
            !line.contains("does-not-exist") && line.contains("try again"),
            "{line}"
        );
    }

    #[test]
    fn a_floor_below_the_free_space_allows() {
        let dir = tempfile::tempdir().expect("tempdir");
        let gate = DiskHeadroomGate::new(
            dir.path(),
            DiskFloor {
                percent: 0,
                bytes: 1,
            },
        );
        let room = gate.headroom().expect("statvfs");
        assert!(
            room.free > 1,
            "this test needs a disk with more than one free byte"
        );
        assert_eq!(gate.refusal(), None);
        assert_eq!(gate.pack_quota("anyone"), PlanVerdict::Allow);
    }

    #[test]
    fn a_root_that_cannot_be_measured_is_a_refusal_that_says_so() {
        let dir = tempfile::tempdir().expect("tempdir");
        let gate = DiskHeadroomGate::new(dir.path().join("does-not-exist"), DiskFloor::default());
        let line = gate.refusal().expect("an unmeasurable root refuses");
        assert!(line.contains("could not check its storage"), "{line}");
    }

    #[test]
    fn human_units_read_as_an_operator_reads_them() {
        assert_eq!(human(512), "512 B");
        assert_eq!(human(2 * GIB), "2.0 GiB");
        assert_eq!(human(1_288_490_189), "1.2 GiB");
        assert_eq!(human(3 * MIB / 2), "1.5 MiB");
    }
}