1use std::path::{Path, PathBuf};
56use std::sync::Arc;
57
58use crate::plan::{PlanAction, PlanGate, PlanVerdict};
59use crate::space::SpaceLedger;
60
61pub const AT_FLOOR: &str = "Gunnar's storage is full";
66
67pub const DEFAULT_FLOOR_PERCENT: u8 = 5;
69pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct DiskFloor {
81 pub percent: u8,
84 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 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 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
182pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub struct Headroom {
200 pub free: u64,
202 pub total: u64,
204 pub floor: u64,
206}
207
208impl Headroom {
209 pub fn has_room(&self) -> bool {
211 self.free > self.floor
212 }
213}
214
215#[derive(Debug, Clone)]
224pub struct DiskHeadroomGate {
225 root: PathBuf,
226 floor: DiskFloor,
227 ledger: Arc<SpaceLedger>,
228}
229
230impl DiskHeadroomGate {
231 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 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 pub fn ledger(&self) -> &Arc<SpaceLedger> {
253 &self.ledger
254 }
255
256 pub fn root(&self) -> &Path {
258 &self.root
259 }
260
261 pub fn floor(&self) -> DiskFloor {
263 self.floor
264 }
265
266 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 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 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 assert_eq!(floor.bytes_for(25 * GIB), 2 * GIB);
357 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 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 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 #[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}