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).ok_or_else(|| {
133 format!(
134 "--disk-floor {spec:?}: {term:?} is not a byte count (try 2GiB, 500M, 4096)"
135 )
136 })?;
137 if saw_bytes {
138 return Err(format!("--disk-floor {spec:?}: two byte terms"));
139 }
140 saw_bytes = true;
141 floor.bytes = bytes;
142 }
143 if floor.percent == 0 && floor.bytes == 0 {
144 return Err(format!(
145 "--disk-floor {spec:?} disables both terms; a gate that can never refuse is not \
146 a gate. Give a percentage, a byte count, or leave the flag off for the default \
147 ({DEFAULT_FLOOR_PERCENT}%,{})",
148 human(DEFAULT_FLOOR_BYTES)
149 ));
150 }
151 Ok(floor)
152 }
153
154 pub fn bytes_for(&self, total: u64) -> u64 {
156 let percent = (total / 100).saturating_mul(u64::from(self.percent));
157 percent.max(self.bytes)
158 }
159}
160
161impl std::fmt::Display for DiskFloor {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 write!(f, "{}%,{}", self.percent, human(self.bytes))
164 }
165}
166
167fn parse_bytes(term: &str) -> Option<u64> {
168 let term = term.trim();
169 let split = term
170 .find(|c: char| !c.is_ascii_digit())
171 .unwrap_or(term.len());
172 let (digits, unit) = term.split_at(split);
173 let value: u64 = digits.parse().ok()?;
174 let multiplier = match unit.trim().to_ascii_lowercase().as_str() {
175 "" | "b" => 1,
176 "k" | "kib" | "kb" => KIB,
177 "m" | "mib" | "mb" => MIB,
178 "g" | "gib" | "gb" => GIB,
179 "t" | "tib" | "tb" => TIB,
180 _ => return None,
181 };
182 value.checked_mul(multiplier)
183}
184
185pub fn human(bytes: u64) -> String {
187 if bytes >= TIB {
188 format!("{:.1} TiB", bytes as f64 / TIB as f64)
189 } else if bytes >= GIB {
190 format!("{:.1} GiB", bytes as f64 / GIB as f64)
191 } else if bytes >= MIB {
192 format!("{:.1} MiB", bytes as f64 / MIB as f64)
193 } else if bytes >= KIB {
194 format!("{:.1} KiB", bytes as f64 / KIB as f64)
195 } else {
196 format!("{bytes} B")
197 }
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub struct Headroom {
203 pub free: u64,
205 pub total: u64,
207 pub floor: u64,
209}
210
211impl Headroom {
212 pub fn has_room(&self) -> bool {
214 self.free > self.floor
215 }
216}
217
218#[derive(Debug, Clone)]
227pub struct DiskHeadroomGate {
228 root: PathBuf,
229 floor: DiskFloor,
230 ledger: Arc<SpaceLedger>,
231}
232
233impl DiskHeadroomGate {
234 pub fn new(root: impl Into<PathBuf>, floor: DiskFloor) -> Self {
238 let root = root.into();
239 let ledger = Arc::new(SpaceLedger::new(root.clone(), floor));
240 DiskHeadroomGate {
241 root,
242 floor,
243 ledger,
244 }
245 }
246
247 pub fn over(ledger: Arc<SpaceLedger>) -> Self {
250 DiskHeadroomGate {
251 root: ledger.root().to_path_buf(),
252 floor: ledger.floor(),
253 ledger,
254 }
255 }
256
257 pub fn ledger(&self) -> &Arc<SpaceLedger> {
260 &self.ledger
261 }
262
263 pub fn root(&self) -> &Path {
265 &self.root
266 }
267
268 pub fn floor(&self) -> DiskFloor {
270 self.floor
271 }
272
273 pub fn headroom(&self) -> std::io::Result<Headroom> {
280 let (free, total) = self.ledger.probe().measure()?;
281 Ok(Headroom {
282 free,
283 total,
284 floor: self.floor.bytes_for(total),
285 })
286 }
287
288 pub fn refusal(&self) -> Option<String> {
297 match self.headroom() {
298 Ok(room) if room.has_room() => self.ledger.full_before_the_first_byte(),
299 Ok(room) => {
302 tracing::warn!(
303 target: "gunnar::space",
304 event = "push.space.at_floor",
305 root = %self.root.display(),
306 free = %human(room.free),
307 floor = %human(room.floor),
308 disk_floor = %self.floor,
309 "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"
310 );
311 Some(format!(
312 "{AT_FLOOR}: no new repository and no push until space is freed or the \
313 storage is grown. Nothing was received"
314 ))
315 }
316 Err(err) => {
317 tracing::error!(
318 target: "gunnar::space",
319 event = "push.space.unmeasured",
320 root = %self.root.display(),
321 %err,
322 "the data set could not be measured: writes are refused rather than guessing there is room"
323 );
324 Some(
325 "Gunnar could not check its storage, so this was refused rather than risk it; \
326 try again in a while"
327 .to_string(),
328 )
329 }
330 }
331 }
332}
333
334impl PlanGate for DiskHeadroomGate {
335 fn verdict(&self, _namespace: &str, _action: PlanAction) -> PlanVerdict {
336 PlanVerdict::Allow
337 }
338
339 fn pack_quota(&self, _namespace: &str) -> PlanVerdict {
340 match self.refusal() {
341 None => PlanVerdict::Allow,
342 Some(line) => PlanVerdict::Refuse(line),
343 }
344 }
345
346 fn applies_to_machine(&self) -> bool {
347 true
348 }
349
350 fn name(&self) -> &'static str {
351 "disk-headroom"
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn the_default_floor_is_five_percent_or_two_gib_whichever_is_larger() {
361 let floor = DiskFloor::default();
362 assert_eq!(floor.bytes_for(25 * GIB), 2 * GIB);
364 let big = 2249 * GIB / 10;
366 assert_eq!(floor.bytes_for(big), (big / 100) * 5);
367 assert!(floor.bytes_for(big) > 2 * GIB);
368 }
369
370 #[test]
371 fn the_flag_grammar_takes_a_percent_a_byte_count_or_both_and_refuses_nothing_at_all() {
372 assert_eq!(
373 DiskFloor::parse("10%").unwrap(),
374 DiskFloor {
375 percent: 10,
376 bytes: DEFAULT_FLOOR_BYTES
377 }
378 );
379 assert_eq!(
380 DiskFloor::parse("512MiB").unwrap(),
381 DiskFloor {
382 percent: DEFAULT_FLOOR_PERCENT,
383 bytes: 512 * MIB
384 }
385 );
386 assert_eq!(
387 DiskFloor::parse("0%, 4096").unwrap(),
388 DiskFloor {
389 percent: 0,
390 bytes: 4096
391 }
392 );
393 assert_eq!(DiskFloor::parse("1T").unwrap().bytes, TIB);
394 assert_eq!(DiskFloor::parse("3g").unwrap().bytes, 3 * GIB);
395 for bad in ["", "abc", "5%%", "100%", "2GiB,3GiB", "5%,6%", "0%,0"] {
396 let err = DiskFloor::parse(bad).expect_err(bad);
397 assert!(err.contains("--disk-floor"), "{bad:?}: {err}");
398 }
399 assert!(
400 DiskFloor::parse("0%,0")
401 .unwrap_err()
402 .contains("can never refuse"),
403 "the all-zero floor must be refused BY NAME as a non-gate"
404 );
405 }
406
407 #[test]
408 fn a_floor_above_the_free_space_refuses_and_names_the_free_bytes_and_the_floor() {
409 let dir = tempfile::tempdir().expect("tempdir");
410 let gate = DiskHeadroomGate::new(
412 dir.path(),
413 DiskFloor {
414 percent: 0,
415 bytes: u64::MAX,
416 },
417 );
418 let room = gate.headroom().expect("statvfs on a tempdir");
419 assert!(room.total > 0, "a real filesystem has a size: {room:?}");
420 assert!(!room.has_room());
421 let line = gate.refusal().expect("under the floor is a refusal");
422 assert!(
423 line.starts_with(AT_FLOOR),
424 "the floor's own, final line: {line}"
425 );
426 assert!(
427 matches!(gate.pack_quota("anyone"), PlanVerdict::Refuse(l) if l == line),
428 "pack_quota is the same refusal"
429 );
430 assert_eq!(
432 gate.verdict("anyone", PlanAction::Write),
433 PlanVerdict::Allow
434 );
435 assert_eq!(
436 gate.verdict("anyone", PlanAction::AnonymousRead),
437 PlanVerdict::Allow
438 );
439 assert!(
440 gate.applies_to_machine(),
441 "a full standby refuses its primary too"
442 );
443 }
444
445 #[test]
451 fn the_floor_line_is_plain_words_and_the_numbers_go_to_the_journal() {
452 let dir = tempfile::tempdir().expect("tempdir");
453 let gate = DiskHeadroomGate::new(
454 dir.path(),
455 DiskFloor {
456 percent: 0,
457 bytes: u64::MAX,
458 },
459 );
460 let line = gate.refusal().expect("under the floor is a refusal");
461 assert!(line.starts_with("Gunnar's storage is full"), "{line}");
462 assert!(line.contains("no new repository and no push"), "{line}");
463 for internal in [
464 "--disk-floor",
465 "gunnar gc",
466 "repo delete",
467 "MiB",
468 "GiB",
469 " free, under the ",
470 ] {
471 assert!(
472 !line.contains(internal),
473 "{internal:?} reaches the customer: {line}"
474 );
475 }
476 assert!(
477 !line.contains(&dir.path().display().to_string()),
478 "the data root reaches the customer: {line}"
479 );
480 assert!(
481 !line.chars().any(|c| c.is_ascii_digit()),
482 "a number reaches the customer: {line}"
483 );
484 let gone = DiskHeadroomGate::new(dir.path().join("does-not-exist"), DiskFloor::default());
485 let line = gone.refusal().expect("an unmeasurable root refuses");
486 assert!(
487 !line.contains("does-not-exist") && line.contains("try again"),
488 "{line}"
489 );
490 }
491
492 #[test]
493 fn a_floor_below_the_free_space_allows() {
494 let dir = tempfile::tempdir().expect("tempdir");
495 let gate = DiskHeadroomGate::new(
496 dir.path(),
497 DiskFloor {
498 percent: 0,
499 bytes: 1,
500 },
501 );
502 let room = gate.headroom().expect("statvfs");
503 assert!(
504 room.free > 1,
505 "this test needs a disk with more than one free byte"
506 );
507 assert_eq!(gate.refusal(), None);
508 assert_eq!(gate.pack_quota("anyone"), PlanVerdict::Allow);
509 }
510
511 #[test]
512 fn a_root_that_cannot_be_measured_is_a_refusal_that_says_so() {
513 let dir = tempfile::tempdir().expect("tempdir");
514 let gate = DiskHeadroomGate::new(dir.path().join("does-not-exist"), DiskFloor::default());
515 let line = gate.refusal().expect("an unmeasurable root refuses");
516 assert!(line.contains("could not check its storage"), "{line}");
517 }
518
519 #[test]
520 fn human_units_read_as_an_operator_reads_them() {
521 assert_eq!(human(512), "512 B");
522 assert_eq!(human(2 * GIB), "2.0 GiB");
523 assert_eq!(human(1_288_490_189), "1.2 GiB");
524 assert_eq!(human(3 * MIB / 2), "1.5 MiB");
525 }
526}