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
+190
-1
@@ -340,7 +340,7 @@ QUIESCE_SERVICES=(nc-cron)
|
||||
(path/'manifest.json').write_text(json.dumps(manifest))
|
||||
(path/'COMPLETE').write_text('APP_BACKUP_V2 '+HELPER.digest(path/'manifest.json')+'\n')
|
||||
subprocess.run(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
|
||||
'copy', str(path), 'crypt:sets/'+path.name], check=True, capture_output=True)
|
||||
'copy', str(path), 'crypt:sets/'+path.name, '--exclude', '/UPLOADED'], check=True, capture_output=True)
|
||||
copies.append(path)
|
||||
self.env['TEST_METADATA_ONLY'] = 'true'
|
||||
self.events.write_text('')
|
||||
@@ -371,6 +371,7 @@ QUIESCE_SERVICES=(nc-cron)
|
||||
def test_pressure_keeps_last_uploaded_and_unconfirmed_copies(self):
|
||||
current, copies = self.pressure_copies()
|
||||
(copies[0]/'UPLOADED').unlink()
|
||||
(copies[0]/'wordpress.tar.zst').write_bytes(b'invalid unconfirmed set')
|
||||
self.fake_pressure(copies[0], always=True)
|
||||
result = self.run_script('app-backup.sh', '--prepare-space', ok=False)
|
||||
self.assertTrue(current.exists())
|
||||
@@ -428,6 +429,194 @@ QUIESCE_SERVICES=(nc-cron)
|
||||
result = self.run_script('app-backup.sh', '--check', ok=False)
|
||||
self.assertIn('SPACE_KEEP_LOCAL must', result.stdout)
|
||||
|
||||
def space_call(self, command, ok=True):
|
||||
self.events.write_text('') # Observe maintenance only, excluding setup backup.
|
||||
result = subprocess.run(['bash', '-c', '''
|
||||
LIB_DIR=$1/lib
|
||||
source "$LIB_DIR/common.sh"
|
||||
CONFIG_FILE=$2
|
||||
OPERATION=backup
|
||||
CHECK=true
|
||||
load_config
|
||||
init_runtime
|
||||
build_plan
|
||||
source "$1/lib/space.sh"
|
||||
clean_backup_scratch
|
||||
check_remote
|
||||
eval "$3"
|
||||
''', 'space-test', str(REPO), str(self.config), command], env=self.env,
|
||||
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=90)
|
||||
if ok: self.assertEqual(result.returncode, 0, result.stdout)
|
||||
else: self.assertNotEqual(result.returncode, 0, result.stdout)
|
||||
return result
|
||||
|
||||
def remote_names(self):
|
||||
return subprocess.check_output(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
|
||||
'lsf', 'crypt:sets', '--dirs-only'], text=True)
|
||||
|
||||
def quota_wrapper(self, mode='recover'):
|
||||
wrapper = self.bin/'rclone'
|
||||
wrapper.write_text('''#!/bin/bash
|
||||
if [[ " $* " == *' about '* ]]; then
|
||||
if [[ $TEST_QUOTA_MODE == unavailable ]]; then exit 9; fi
|
||||
if [[ $TEST_QUOTA_MODE == cancel ]]; then exit 143; fi
|
||||
if [[ $TEST_QUOTA_MODE == small ]]; then echo '{"free":2000000}'; exit 0; fi
|
||||
if [[ $TEST_QUOTA_MODE == malformed ]]; then echo '{"free":true}'; exit 0; fi
|
||||
if [[ $TEST_QUOTA_MODE == recover && -f $TEST_EVENTS.freed ]]; then
|
||||
echo '{"free":1000000000000}'
|
||||
else echo '{"free":0}'; fi
|
||||
exit 0
|
||||
fi
|
||||
if [[ " $* " == *' purge '* ]]; then
|
||||
/usr/bin/rclone "$@" || exit $?
|
||||
touch "$TEST_EVENTS.freed"
|
||||
exit 0
|
||||
fi
|
||||
exec /usr/bin/rclone "$@"
|
||||
''')
|
||||
wrapper.chmod(0o700)
|
||||
self.env['TEST_QUOTA_MODE'] = mode
|
||||
|
||||
def test_pressure_releases_last_local_copy_and_next_backup_succeeds(self):
|
||||
self.crypt_config()
|
||||
current = self.backup()
|
||||
# Real planning budget, but only a one-byte deficit. Releasing the last
|
||||
# uploaded set solves preflight, then a real new backup/upload completes.
|
||||
planning = self.run_script('app-backup.sh', '--prepare-space')
|
||||
import re
|
||||
budget = int(re.search(r'Estimated snapshot budget: (\d+)', planning.stdout)[1]) * 2
|
||||
fake = self.bin/'df'
|
||||
fake.write_text('#!/usr/bin/env python3\nfrom pathlib import Path\n'
|
||||
f'available = {budget-1} if Path({str(current)!r}).exists() else 10**12\n'
|
||||
'print("Filesystem 1B-blocks Used Available Use% Mounted")\n'
|
||||
'print(f"root 2000000000000 0 {available} 0% /")\n')
|
||||
fake.chmod(0o700)
|
||||
result = self.run_script('app-backup.sh')
|
||||
self.assertIn('Emergency space recovery: removed local copy', result.stdout)
|
||||
self.assertIn('SUCCESS:', result.stdout)
|
||||
self.assertFalse(current.exists())
|
||||
self.assertIn(current.name, self.remote_names())
|
||||
new = list(current.parent.glob('appbackup_*'))
|
||||
self.assertEqual(len(new), 1)
|
||||
self.assertTrue((new[0]/'UPLOADED').is_file())
|
||||
self.assertEqual(json.loads(self.state.read_text()), self.initial)
|
||||
|
||||
def test_remote_only_can_be_disabled(self):
|
||||
self.crypt_config()
|
||||
current = self.backup()
|
||||
with self.config.open('a') as f: f.write('SPACE_ALLOW_REMOTE_ONLY=false\n')
|
||||
self.fake_pressure(current)
|
||||
self.space_call('ensure_space "$ARCHIVE_DIR" 1000', ok=False)
|
||||
self.assertTrue(current.exists())
|
||||
|
||||
def test_last_local_copy_is_kept_if_offsite_identity_is_wrong(self):
|
||||
self.crypt_config()
|
||||
current = self.backup()
|
||||
original = (current/'COMPLETE').read_bytes()
|
||||
subprocess.run(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
|
||||
'rcat', 'crypt:sets/'+current.name+'/COMPLETE'],
|
||||
input=b'X'+original[1:], check=True, capture_output=True)
|
||||
self.fake_pressure(current)
|
||||
result = self.space_call('ensure_space "$ARCHIVE_DIR" 1000', ok=False)
|
||||
self.assertTrue(current.exists())
|
||||
self.assertIn('Emergency cleanup keeps', result.stdout)
|
||||
self.assertNotIn('systemctl stop', self.events.read_text())
|
||||
|
||||
def test_last_local_copy_is_kept_if_it_cannot_resolve_deficit(self):
|
||||
self.crypt_config()
|
||||
current = self.backup()
|
||||
self.fake_pressure(current)
|
||||
self.space_call('ensure_space "$ARCHIVE_DIR" 1000000000000', ok=False)
|
||||
self.assertTrue(current.exists())
|
||||
|
||||
def test_pressure_retries_failed_upload_before_releasing_local_set(self):
|
||||
self.crypt_config()
|
||||
current = self.backup()
|
||||
(current/'UPLOADED').unlink()
|
||||
subprocess.run(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
|
||||
'purge', 'crypt:sets/'+current.name], check=True, capture_output=True)
|
||||
self.fake_pressure(current)
|
||||
result = self.space_call('ensure_space "$ARCHIVE_DIR" 1000')
|
||||
self.assertIn('Pending publication confirmed', result.stdout)
|
||||
self.assertFalse(current.exists())
|
||||
self.assertIn(current.name, self.remote_names())
|
||||
|
||||
def test_pending_complete_upload_reconciles_without_spare_remote_quota(self):
|
||||
self.crypt_config()
|
||||
current = self.backup()
|
||||
(current/'UPLOADED').unlink()
|
||||
self.fake_pressure(current)
|
||||
self.quota_wrapper('stuck')
|
||||
result = self.space_call('ensure_space "$ARCHIVE_DIR" 1000')
|
||||
self.assertIn('Pending publication already complete offsite', result.stdout)
|
||||
self.assertNotIn('Remote pressure cleanup: removing', result.stdout)
|
||||
self.assertFalse(current.exists())
|
||||
self.assertIn(current.name, self.remote_names())
|
||||
|
||||
def test_remote_quota_uses_compressed_archives_instead_of_snapshot_budget(self):
|
||||
self.crypt_config()
|
||||
self.quota_wrapper('small')
|
||||
result = self.run_script('app-backup.sh')
|
||||
self.assertIn('SUCCESS:', result.stdout)
|
||||
self.assertNotIn('Remote pressure cleanup: removing', result.stdout)
|
||||
self.assertTrue(any((p/'UPLOADED').exists() for p in (self.base/'work/archives').iterdir()))
|
||||
|
||||
def test_remote_pressure_deletes_oldest_only_and_keeps_newest_and_partial(self):
|
||||
current, copies = self.pressure_copies()
|
||||
partial = 'appbackup_2019-01-01_00-00-00_aaaaaaaa'
|
||||
subprocess.run(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
|
||||
'rcat', 'crypt:sets/'+partial+'/manifest.json'],
|
||||
input=b'not complete', check=True, capture_output=True)
|
||||
self.quota_wrapper()
|
||||
result = self.space_call('ensure_remote_space 1000')
|
||||
self.assertNotIn(copies[0].name, self.remote_names())
|
||||
self.assertIn(copies[1].name, self.remote_names())
|
||||
self.assertIn(current.name, self.remote_names())
|
||||
self.assertIn(partial, self.remote_names())
|
||||
self.assertTrue(all(p.exists() for p in [current]+copies))
|
||||
self.assertIn('preserves unverified/incomplete', result.stdout)
|
||||
self.assertNotIn('forbidden-archive-read', self.events.read_text())
|
||||
|
||||
def test_remote_pressure_stops_at_one_when_quota_does_not_recover(self):
|
||||
current, copies = self.pressure_copies()
|
||||
self.quota_wrapper('stuck')
|
||||
result = self.space_call('ensure_remote_space 1000', ok=False)
|
||||
self.assertIn(current.name, self.remote_names())
|
||||
self.assertTrue(all(p.name not in self.remote_names() for p in copies))
|
||||
self.assertIn('Insufficient remote quota', result.stdout)
|
||||
self.assertTrue(current.exists())
|
||||
|
||||
def test_remote_pressure_rejects_size_corruption_and_directory_identity_mismatch(self):
|
||||
current, copies = self.pressure_copies()
|
||||
# Latest must be a valid survivor. Invalid ones never count or get purged.
|
||||
subprocess.run(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
|
||||
'deletefile', 'crypt:sets/'+current.name+'/wordpress.tar.zst'],
|
||||
check=True, capture_output=True)
|
||||
subprocess.run(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
|
||||
'copyto', 'crypt:sets/'+copies[0].name+'/manifest.json',
|
||||
'crypt:sets/'+copies[1].name+'/manifest.json'], check=True, capture_output=True)
|
||||
subprocess.run(['/usr/bin/rclone', '--config', str(self.base/'rclone.conf'),
|
||||
'copyto', 'crypt:sets/'+copies[0].name+'/COMPLETE',
|
||||
'crypt:sets/'+copies[1].name+'/COMPLETE'], check=True, capture_output=True)
|
||||
self.quota_wrapper()
|
||||
self.space_call('ensure_remote_space 1000', ok=False)
|
||||
self.assertTrue(all(p.name in self.remote_names() for p in [current]+copies))
|
||||
|
||||
def test_remote_quota_unavailable_or_malformed_never_deletes(self):
|
||||
current, copies = self.pressure_copies()
|
||||
for mode in ('unavailable', 'malformed'):
|
||||
self.quota_wrapper(mode)
|
||||
result = self.space_call('ensure_remote_space 1000')
|
||||
self.assertIn('quota unavailable', result.stdout)
|
||||
self.assertTrue(all(p.name in self.remote_names() for p in [current]+copies))
|
||||
|
||||
def test_remote_quota_cancellation_is_propagated(self):
|
||||
current, copies = self.pressure_copies()
|
||||
self.quota_wrapper('cancel')
|
||||
result = self.space_call('ensure_remote_space 1000', ok=False)
|
||||
self.assertEqual(result.returncode, 143, result.stdout)
|
||||
self.assertTrue(all(p.name in self.remote_names() for p in [current]+copies))
|
||||
|
||||
def test_dump_and_copy_failures_do_not_publish(self):
|
||||
for tool in ['mariadb-dump','rsync','zstd']:
|
||||
with self.subTest(tool=tool):
|
||||
|
||||
Reference in new issue
Block a user