Recover backup capacity with verified adaptive retention

Resume pending publications under local pressure, allow verified offsite-only history as a final local recovery stage, and prune old complete remote sets against actual upload quota while preserving the newest verified copies. Document defaults, recovery limits and validation; add pressure and quota regression coverage.
This commit is contained in:
admin committed 2026-10-22 15:08:35 +02:00
1 parent a79f48bc2a
commit 6737405b35
10 files changed
+601 -31

No files matched your search

+53 -3
View File
@@ -5,7 +5,9 @@ Only private, recognized backup scratch is disposable. Recovery journals and
manual restore extractions always win over attempts to reclaim disk space.
"""
import argparse
import json
import os
import sys
from pathlib import Path
import re
import shutil
@@ -105,6 +107,48 @@ def eligible(archives, prefix, keep, protected=False):
print(name)
def pending(archives, prefix):
if not private(archives):
raise ValueError('Pending uploads require private canonical archives')
for path in sorted(archives.iterdir()):
if not private(path) or not path.name.startswith(prefix+'_') or (path/'UPLOADED').exists() or (path/'UPLOADED').is_symlink():
continue
try:
data = backup_set.load_set(path, metadata_only=True)
if data['run_id'] == path.name:
print(path.name)
except (ValueError, OSError, KeyError, TypeError):
continue
def remote_valid(folder, name):
data = backup_set.load_set(folder, metadata_only=True)
if data['run_id'] != name:
raise ValueError('Remote directory/manifest identity mismatch')
entries = json.loads((folder/'listing.json').read_text())
expected = {a['file']: a['size'] for a in data['archives']}
expected.update({p: (folder/p).stat().st_size for p in ('manifest.json', 'COMPLETE')})
actual = {}
for entry in entries:
path = entry['Path']
if path in actual or entry['IsDir'] or type(entry['Size']) is not int:
raise ValueError('Invalid remote file inventory')
actual[path] = entry['Size']
if actual != expected:
raise ValueError('Remote files missing, unexpected or wrong sizes')
def quota():
try:
data = json.load(sys.stdin)
except (ValueError, TypeError):
sys.exit(1)
free = data.get('free') if isinstance(data, dict) else None
if type(free) is not int or not 0 <= free <= 10**18:
sys.exit(1)
print(free)
def inventory(archives, prefix, keep):
valid = uploaded_sets(archives, prefix)
protected = set(valid[:keep])
@@ -127,10 +171,16 @@ def inventory(archives, prefix, keep):
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('mode', choices=('clean', 'eligible', 'protected', 'inventory', 'remove'))
parser.add_argument('paths', nargs='+')
parser.add_argument('mode', choices=('clean', 'eligible', 'protected', 'inventory', 'remove', 'pending', 'remote-valid', 'quota'))
parser.add_argument('paths', nargs='*')
args = parser.parse_args()
if args.mode == 'clean':
if args.mode == 'quota':
quota()
elif args.mode == 'remote-valid':
remote_valid(Path(args.paths[0]), args.paths[1])
elif args.mode == 'pending':
pending(Path(args.paths[0]), args.paths[1])
elif args.mode == 'clean':
clean(*(Path(p) for p in args.paths[:3]), args.paths[3], Path(args.paths[4]) if len(args.paths) > 4 else None)
elif args.mode in ('eligible', 'protected'):
eligible(Path(args.paths[0]), args.paths[1], int(args.paths[2]), args.mode == 'protected')