Clients deleted every auto-backup_ snapshot on the server except the newest, so the backup held one state only. prune-snapshots now keeps the newest per day, week and month (14/8/24 by default) independent of the source, and the clients call it after each receive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RywMtsL8qXG7GDY3MHq9Uh
85 lines
3.3 KiB
Python
85 lines
3.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Thin out the auto-backup_ snapshots of one received dataset, independent of the source.
|
|
|
|
Usage: prune-snapshots [--dry-run] DATASET
|
|
|
|
Keeps the newest snapshot per day, ISO week and month for the most recent days, weeks and months
|
|
in /etc/backup-server/retention.json, and always the newest snapshot, which the next incremental
|
|
receive builds on. Other snapshots of the dataset are never touched. Called by the clients'
|
|
backup_path_via_zfs after each successful receive, so it runs while this server is awake.
|
|
Exit codes: 0 ok, 1 zfs failed, 2 usage.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
PREFIX = "auto-backup_"
|
|
CONFIG = "/etc/backup-server/retention.json"
|
|
# only datasets received from clients (tank/<client uuid>/...), as the backup user calls this via sudo
|
|
DATASET_RE = re.compile(r"\Atank/[0-9a-f-]{36}/[A-Za-z0-9._/-]+\Z")
|
|
BUCKETS = {
|
|
"daily": lambda d: d.strftime("%Y-%m-%d"),
|
|
"weekly": lambda d: "%d-W%02d" % d.isocalendar()[:2],
|
|
"monthly": lambda d: d.strftime("%Y-%m"),
|
|
}
|
|
|
|
|
|
def zfs(*args: str) -> str:
|
|
return subprocess.run(["zfs", *args], capture_output=True, text=True, check=True, timeout=120).stdout
|
|
|
|
|
|
def keep(snapshots: list[tuple[str, int]], retention: dict[str, int]) -> set[str]:
|
|
"""Names to keep from (name, creation epoch) pairs: per granularity the newest snapshot of each
|
|
of the most recent buckets, plus the newest snapshot overall."""
|
|
newest_first = sorted(snapshots, key=lambda s: s[1], reverse=True)
|
|
kept = {newest_first[0][0]} if newest_first else set()
|
|
for granularity, count in retention.items():
|
|
seen: set[str] = set()
|
|
for name, creation in newest_first:
|
|
bucket = BUCKETS[granularity](datetime.datetime.fromtimestamp(creation))
|
|
if bucket not in seen and len(seen) < count:
|
|
seen.add(bucket)
|
|
kept.add(name)
|
|
return kept
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
dry_run = "--dry-run" in argv
|
|
args = [a for a in argv if a != "--dry-run"]
|
|
if len(args) != 1 or not DATASET_RE.match(args[0]):
|
|
print(__doc__, file=sys.stderr)
|
|
return 2
|
|
dataset = args[0]
|
|
with open(CONFIG, encoding="utf-8") as fh:
|
|
retention = {k: int(v) for k, v in json.load(fh).items() if k in BUCKETS}
|
|
try:
|
|
lines = zfs("list", "-H", "-p", "-t", "snapshot", "-d", "1", "-o", "name,creation", dataset).splitlines()
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"prune-snapshots: {e.stderr.strip()}", file=sys.stderr)
|
|
return 1
|
|
snapshots = []
|
|
for line in lines:
|
|
name, creation = line.split("\t")
|
|
if name.split("@", 1)[1].startswith(PREFIX):
|
|
snapshots.append((name, int(creation)))
|
|
kept = keep(snapshots, retention)
|
|
for name, _ in snapshots:
|
|
if name in kept:
|
|
continue
|
|
print(f"{'would destroy' if dry_run else 'destroying'} {name}")
|
|
if not dry_run:
|
|
try:
|
|
zfs("destroy", name)
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"prune-snapshots: {e.stderr.strip()}", file=sys.stderr)
|
|
return 1
|
|
print(f"{dataset}: {len(kept)} of {len(snapshots)} {PREFIX} snapshots kept")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|