use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::plan::{PlanAction, PlanGate, PlanVerdict};
use crate::space::SpaceLedger;
pub const AT_FLOOR: &str = "Gunnar's storage is full";
pub const DEFAULT_FLOOR_PERCENT: u8 = 5;
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;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DiskFloor {
pub percent: u8,
pub bytes: u64,
}
impl Default for DiskFloor {
fn default() -> Self {
DiskFloor {
percent: DEFAULT_FLOOR_PERCENT,
bytes: DEFAULT_FLOOR_BYTES,
}
}
}
impl DiskFloor {
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)
}
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)
}
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")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Headroom {
pub free: u64,
pub total: u64,
pub floor: u64,
}
impl Headroom {
pub fn has_room(&self) -> bool {
self.free > self.floor
}
}
#[derive(Debug, Clone)]
pub struct DiskHeadroomGate {
root: PathBuf,
floor: DiskFloor,
ledger: Arc<SpaceLedger>,
}
impl DiskHeadroomGate {
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 }
}
pub fn over(ledger: Arc<SpaceLedger>) -> Self {
DiskHeadroomGate {
root: ledger.root().to_path_buf(),
floor: ledger.floor(),
ledger,
}
}
pub fn ledger(&self) -> &Arc<SpaceLedger> {
&self.ledger
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn floor(&self) -> DiskFloor {
self.floor
}
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),
})
}
pub fn refusal(&self) -> Option<String> {
match self.headroom() {
Ok(room) if room.has_room() => self.ledger.full_before_the_first_byte(),
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();
assert_eq!(floor.bytes_for(25 * GIB), 2 * GIB);
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");
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"
);
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");
}
#[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");
}
}