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");
}
}