Prepare MariaDB restore tests and recover backup disk space safely
This commit is contained in:
1 parent
f0c761213d
commit
b0416461bc
23 files changed
+918
-129
No files matched your search
@@ -0,0 +1,99 @@
|
||||
#!/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]))
|
||||
Reference in new issue
Block a user