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.
This commit is contained in:
1 parent
a79f48bc2a
commit
6737405b35
10 files changed
+601
-31
No files matched your search
+53
-3
@@ -5,7 +5,9 @@ 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
|
||||
@@ -105,6 +107,48 @@ def eligible(archives, prefix, keep, protected=False):
|
||||
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])
|
||||
@@ -127,10 +171,16 @@ def inventory(archives, prefix, keep):
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('mode', choices=('clean', 'eligible', 'protected', 'inventory', 'remove'))
|
||||
parser.add_argument('paths', nargs='+')
|
||||
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 == 'clean':
|
||||
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')
|
||||
|
||||
Reference in new issue
Block a user