Files
app-backup/lib/space.py
T

100 lines
4.3 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 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]))