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