Recover disk space safely and complete metadata-only backup updates
This commit is contained in:
1 parent
04283a607c
commit
a5a7e38853
17 files changed
+827
-209
No files matched your search
+49
-8
@@ -9,6 +9,7 @@ import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import backup_set
|
||||
|
||||
@@ -18,12 +19,21 @@ def private(path):
|
||||
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, _ in os.walk(path, followlinks=False):
|
||||
for name in dirs:
|
||||
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))
|
||||
@@ -66,7 +76,9 @@ def clean(work, staging, archives, prefix, restore=None):
|
||||
print('-- Removed abandoned verification download: ' + path.name, flush=True)
|
||||
|
||||
|
||||
def eligible(archives, prefix, keep):
|
||||
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+'_'):
|
||||
@@ -81,19 +93,48 @@ def eligible(archives, prefix, keep):
|
||||
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]:
|
||||
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 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', 'remove'))
|
||||
parser.add_argument('mode', choices=('clean', 'eligible', 'protected', 'inventory', '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]))
|
||||
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]))
|
||||
Reference in new issue
Block a user