#!/usr/bin/env python3 """Conservative scratch recovery. Caller MUST hold the shared backup lock. Only private, recognized backup scratch is disposable. Recovery journals and manual restore extractions always win over attempts to reclaim disk space. """ import argparse import os from pathlib import Path import re import shutil import backup_set def private(path): return (path.is_dir() and path.resolve() == path and path.stat().st_uid == 0 and not path.stat().st_mode & 0o077) def remove_tree(path): if not private(path): raise ValueError('Refusing non-private/aliased scratch: ' + str(path)) device = path.stat().st_dev for parent, dirs, _ in os.walk(path, followlinks=False): for name in dirs: child = Path(parent) / name if not child.is_symlink() and (child.stat().st_dev != device or os.path.ismount(child)): raise ValueError('Refusing mounted scratch: ' + str(child)) shutil.rmtree(path) # fd-based, symlink-safe implementation on Linux def clean(work, staging, archives, prefix, restore=None): for path in (work, staging, archives): if not private(path): raise ValueError('Cleanup requires private canonical directories: ' + str(path)) if list(work.glob('recovery-*.json')) or list(work.glob('recovery-*.json.tmp')): raise ValueError('Unresolved service/restore recovery journal; preserve all scratch and review services') pattern = re.compile(re.escape(prefix) + r'_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_[0-9a-f]{8}') for path in sorted(staging.iterdir()): run, dot, suffix = path.name.rpartition('.') if not (dot and re.fullmatch(r'[a-zA-Z0-9]{6}', suffix) and pattern.fullmatch(run) and private(path)): continue identity, version = path/'meta/run-id.txt', path/'meta/format.txt' if any(p.is_symlink() or not p.is_file() or p.stat().st_size > 256 for p in (identity, version)): continue if (path/'meta').is_symlink() or identity.read_text() != run+'\n' or not version.read_text().startswith('format=2\n'): continue partial = archives / ('.partial-' + run) if partial.exists(): # A completion marker might be the only surviving complete snapshot. if (partial/'COMPLETE').exists() or (partial/'COMPLETE').is_symlink(): continue remove_tree(partial) print('-- Removed abandoned partial archive: ' + partial.name, flush=True) remove_tree(path) print('-- Removed abandoned backup staging: ' + path.name, flush=True) if restore is not None and private(restore): for path in sorted(restore.iterdir()): run, _, suffix = path.name.rpartition('.') marker = path/'.verify-only' if (private(path) and pattern.fullmatch(run) and re.fullmatch(r'[a-zA-Z0-9]{6}', suffix) and marker.is_file() and not marker.is_symlink() and marker.stat().st_size < 256 and marker.read_text() == run+'\n'): remove_tree(path) print('-- Removed abandoned verification download: ' + path.name, flush=True) def eligible(archives, prefix, keep): valid = [] for path in archives.iterdir(): if not private(path) or not path.name.startswith(prefix+'_'): continue marker = path/'UPLOADED' if not marker.is_file() or marker.is_symlink(): continue try: manifest = backup_set.load_set(path, metadata_only=True) if manifest['run_id'] != path.name: continue except (ValueError, OSError, KeyError, TypeError): continue valid.append(path.name) # Emergency pruning may ignore age, NEVER the minimum newest-copy count. for name in sorted(valid, reverse=True)[keep:][::-1]: print(name) if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('mode', choices=('clean', 'eligible', 'remove')) parser.add_argument('paths', nargs='+') args = parser.parse_args() if 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 == 'eligible': eligible(Path(args.paths[0]), args.paths[1], int(args.paths[2])) else: remove_tree(Path(args.paths[0]))