Files
app-backup/lib/space.py
T
admin 6737405b35 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.
2026-10-22 15:08:35 +02:00

191 lines
8.2 KiB
Python

#!/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 json
import os
import sys
from pathlib import Path
import re
import shutil
import subprocess
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 mounted_paths():
# st_dev/ismount alone miss same-filesystem bind mounts. Linux mountinfo
# lists file and directory mounts, escaping whitespace and backslashes.
return {Path(re.sub(r'\\([0-7]{3})', lambda m: chr(int(m[1], 8)), line.split()[4]))
for line in Path('/proc/self/mountinfo').read_text().splitlines()}
def remove_tree(path):
if not private(path):
raise ValueError('Refusing non-private/aliased scratch: ' + str(path))
if os.path.ismount(path) or any(p == path or path in p.parents for p in mounted_paths()):
raise ValueError('Refusing mounted scratch: ' + str(path))
device = path.stat().st_dev
for parent, dirs, files in os.walk(path, followlinks=False):
for name in dirs + files:
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 uploaded_sets(archives, prefix):
if not private(archives):
raise ValueError('Cleanup requires private canonical archives: ' + str(archives))
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)
return sorted(valid, reverse=True)
def eligible(archives, prefix, keep, protected=False):
if keep < 1:
raise ValueError('At least one uploaded local set must be retained')
# Pressure policy is independent of normal age/count retention.
valid = uploaded_sets(archives, prefix)
for name in (valid[:keep] if protected else valid[keep:][::-1]):
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])
print('-- Remaining local archive inventory (allocated bytes; no deletion):', flush=True)
for path in sorted(archives.iterdir()):
if not private(path):
print(f' {path.name}: protected unknown/aliased/non-private entry', flush=True)
continue
if path.name in protected:
reason = 'protected local minimum'
elif path.name in valid:
reason = 'uploaded; retained (verification/source/device constraints)'
else:
reason = 'protected unconfirmed/incomplete/unknown set'
result = subprocess.run(['du', '-sx', '-B1', '--', str(path)],
text=True, capture_output=True, check=True)
size = int(result.stdout.split()[0])
print(f' {path.name}: {size} bytes; {reason}', flush=True)
if __name__ == '__main__':
parser = argparse.ArgumentParser()
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 == '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')
elif args.mode == 'inventory':
inventory(Path(args.paths[0]), args.paths[1], int(args.paths[2]))
else:
remove_tree(Path(args.paths[0]))