139 lines
4.0 KiB
Python
139 lines
4.0 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
# retoor <retoor@molodetz.nl>
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import sys
|
||
|
|
import time
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
MIN_AGE_SECONDS = 3600
|
||
|
|
PARTIAL_SUFFIX = ".partial"
|
||
|
|
|
||
|
|
|
||
|
|
def human_bytes(size: int) -> str:
|
||
|
|
value = float(max(0, int(size or 0)))
|
||
|
|
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||
|
|
if value < 1024 or unit == "TB":
|
||
|
|
return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
|
||
|
|
value /= 1024
|
||
|
|
return f"{value:.1f} TB"
|
||
|
|
|
||
|
|
|
||
|
|
def backups_root() -> Path:
|
||
|
|
return Path(__file__).resolve().parent / "data" / "backups"
|
||
|
|
|
||
|
|
|
||
|
|
def find_partials(
|
||
|
|
root: Path, now: float, min_age: int
|
||
|
|
) -> tuple[list[tuple[Path, int, float]], list[tuple[Path, int, float]]]:
|
||
|
|
abandoned: list[tuple[Path, int, float]] = []
|
||
|
|
in_progress: list[tuple[Path, int, float]] = []
|
||
|
|
if not root.is_dir():
|
||
|
|
return abandoned, in_progress
|
||
|
|
for path in root.rglob(f"*{PARTIAL_SUFFIX}"):
|
||
|
|
if not path.is_file() or path.suffix != PARTIAL_SUFFIX:
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
info = path.stat()
|
||
|
|
except OSError as exc:
|
||
|
|
print(f"skip {path}: {exc}", file=sys.stderr)
|
||
|
|
continue
|
||
|
|
age = now - info.st_mtime
|
||
|
|
row = (path, info.st_size, age)
|
||
|
|
if age < min_age:
|
||
|
|
in_progress.append(row)
|
||
|
|
else:
|
||
|
|
abandoned.append(row)
|
||
|
|
abandoned.sort(key=lambda item: item[1], reverse=True)
|
||
|
|
in_progress.sort(key=lambda item: item[1], reverse=True)
|
||
|
|
return abandoned, in_progress
|
||
|
|
|
||
|
|
|
||
|
|
def prune_empty_dirs(root: Path) -> int:
|
||
|
|
removed = 0
|
||
|
|
if not root.is_dir():
|
||
|
|
return 0
|
||
|
|
for directory in sorted(
|
||
|
|
(path for path in root.rglob("*") if path.is_dir()),
|
||
|
|
key=lambda path: len(path.parts),
|
||
|
|
reverse=True,
|
||
|
|
):
|
||
|
|
try:
|
||
|
|
next(directory.iterdir())
|
||
|
|
except StopIteration:
|
||
|
|
try:
|
||
|
|
directory.rmdir()
|
||
|
|
removed += 1
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
return removed
|
||
|
|
|
||
|
|
|
||
|
|
def report(label: str, rows: list[tuple[Path, int, float]]) -> int:
|
||
|
|
total = 0
|
||
|
|
if not rows:
|
||
|
|
return total
|
||
|
|
print(label)
|
||
|
|
for path, size, age in rows:
|
||
|
|
total += size
|
||
|
|
print(f" {human_bytes(size):>10} {age / 3600:.1f}h {path}")
|
||
|
|
print(f" {human_bytes(total):>10} total")
|
||
|
|
return total
|
||
|
|
|
||
|
|
|
||
|
|
def main() -> int:
|
||
|
|
parser = argparse.ArgumentParser(
|
||
|
|
description=(
|
||
|
|
"Remove abandoned backup *.partial files under data/backups. "
|
||
|
|
"Does not open SQLite and does not touch completed .tar.gz archives."
|
||
|
|
)
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--apply",
|
||
|
|
action="store_true",
|
||
|
|
help="Delete the abandoned files. Default is a dry run.",
|
||
|
|
)
|
||
|
|
parser.add_argument(
|
||
|
|
"--min-age-seconds",
|
||
|
|
type=int,
|
||
|
|
default=MIN_AGE_SECONDS,
|
||
|
|
help="Skip files newer than this many seconds (in-flight backup guard).",
|
||
|
|
)
|
||
|
|
args = parser.parse_args()
|
||
|
|
if args.min_age_seconds < 0:
|
||
|
|
print("--min-age-seconds must be >= 0", file=sys.stderr)
|
||
|
|
return 2
|
||
|
|
root = backups_root()
|
||
|
|
abandoned, in_progress = find_partials(root, time.time(), args.min_age_seconds)
|
||
|
|
if in_progress:
|
||
|
|
report("In-flight (kept)", in_progress)
|
||
|
|
if not abandoned:
|
||
|
|
print(f"No abandoned {PARTIAL_SUFFIX} files under {root}")
|
||
|
|
return 0
|
||
|
|
mode = "DELETE" if args.apply else "DRY RUN"
|
||
|
|
report(f"{mode}: {len(abandoned)} abandoned file(s) under {root}", abandoned)
|
||
|
|
if not args.apply:
|
||
|
|
print("Re-run with --apply to delete.")
|
||
|
|
return 0
|
||
|
|
freed = 0
|
||
|
|
failed = 0
|
||
|
|
for path, size, _age in abandoned:
|
||
|
|
try:
|
||
|
|
path.unlink()
|
||
|
|
freed += size
|
||
|
|
except OSError as exc:
|
||
|
|
print(f"failed {path}: {exc}", file=sys.stderr)
|
||
|
|
failed += 1
|
||
|
|
empty = prune_empty_dirs(root)
|
||
|
|
print(
|
||
|
|
f"Removed {human_bytes(freed)} in {len(abandoned) - failed} file(s), "
|
||
|
|
f"{failed} failed, {empty} empty dir(s)"
|
||
|
|
)
|
||
|
|
return 1 if failed else 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
raise SystemExit(main())
|