Recover disk space safely and complete metadata-only backup updates

This commit is contained in:
admin committed 2026-09-21 16:30:38 +02:00
1 parent 04283a607c
commit a5a7e38853
17 files changed
+827 -209

No files matched your search

+49 -8
View File
@@ -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]))