Verify fresh IndeeHub backup restores and record remaining release tasks

This commit is contained in:
archipelago
2026-10-07 14:43:22 -04:00
parent 7fb7ee80f2
commit d4f3cceb52
8 changed files with 440 additions and 17 deletions
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Qualify real AutoRemove/Quadlet recovery primitives using owned fixtures only.
This does not replace full app-specific drain or production updater acceptance.
No app volume, port, wallet, catalog, or installed unit is used.
"""
import argparse
import hashlib
import json
from pathlib import Path
import subprocess
import tempfile
import time
import uuid
def run(*args, check=True, timeout=90):
result = subprocess.run(args, check=False, timeout=timeout, capture_output=True, text=True)
if check and result.returncode:
raise RuntimeError(f'{args[0]} failed ({result.returncode}): {result.stderr.strip()}')
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--image', required=True, help='Already imported local image containing /bin/sh')
args = parser.parse_args()
image = run('podman', 'image', 'inspect', '--format', '{{.Id}}', args.image).stdout.strip()
operation = str(uuid.uuid4())
name = 'archy-recovery-fixture-' + operation
tag = 'localhost/archy-update-recovery:' + operation + '-0'
root = Path(tempfile.mkdtemp(prefix=name + '-'))
data = root / 'data'
data.mkdir()
units = Path.home() / '.config/containers/systemd'
units.mkdir(parents=True, exist_ok=True)
unit = units / (name + '.container')
assert not unit.exists()
service = name + '.service'
snapshot = None
original = f'''[Container]
Image=sha256:{image.removeprefix('sha256:')}
ContainerName={name}
Network=none
Pull=never
Volume={data}:/state
Environment=MODE=original
Entrypoint=/bin/sh
Exec=-c "trap 'exit 0' TERM; while sleep 1; do :; done"
[Service]
Restart=no
TimeoutStartSec=60
TimeoutStopSec=15
'''
def write(body):
temporary = unit.with_suffix('.next')
temporary.write_text(body)
temporary.chmod(0o600)
temporary.replace(unit)
run('systemctl', '--user', 'daemon-reload')
def inspect():
rows = json.loads(run('podman', 'inspect', name).stdout)
assert len(rows) == 1 and rows[0]['Name'] == name
return rows[0]
try:
write(original)
run('systemctl', '--user', 'start', service)
old = inspect()
assert old['State']['Running'] and old['HostConfig']['AutoRemove']
run('podman', 'exec', name, '/bin/sh', '-c',
'printf original-layer > /original-layer; printf persistent-bytes > /state/value')
volume_hash = hashlib.sha256((data / 'value').read_bytes()).hexdigest()
run('podman', 'commit', '--pause=true', '--include-volumes=false',
'--change', 'LABEL io.archipelago.recovery.operation=' + operation,
'--change', 'LABEL io.archipelago.recovery.container=' + old['Id'], old['Id'], tag)
captured = json.loads(run('podman', 'image', 'inspect', tag).stdout)[0]
snapshot = captured['Id']
assert captured['Config']['Labels']['io.archipelago.recovery.operation'] == operation
assert captured['Config']['Labels']['io.archipelago.recovery.container'] == old['Id']
run('podman', 'run', '--rm', '--network=none', '--pull=never', '--entrypoint=/bin/sh', snapshot,
'-c', 'test "$(cat /original-layer)" = original-layer; test ! -f /state/value')
run('systemctl', '--user', 'stop', service)
assert run('podman', 'container', 'exists', old['Id'], check=False).returncode == 1
failed = original.replace('Environment=MODE=original', 'Environment=MODE=candidate').replace(
'Exec=-c "trap \'exit 0\' TERM; while sleep 1; do :; done"', 'Exec=-c "exit 77"')
assert failed != original
write(failed)
run('systemctl', '--user', 'start', service, check=False)
deadline = time.monotonic() + 15
while run('systemctl', '--user', 'is-active', service, check=False).returncode == 0:
assert time.monotonic() < deadline, 'Fault injection unexpectedly remained active'
time.sleep(0.2)
assert run('systemctl', '--user', 'show', service, '--property=Result', '--value').stdout.strip() != 'success'
run('systemctl', '--user', 'stop', service, check=False)
restored = original.replace('Image=sha256:' + image.removeprefix('sha256:'),
'Image=sha256:' + snapshot.removeprefix('sha256:'))
write(restored)
run('systemctl', '--user', 'reset-failed', service, check=False)
run('systemctl', '--user', 'start', service)
current = inspect()
assert current['State']['Running'] and current['Id'] != old['Id']
assert current['Image'].removeprefix('sha256:') == snapshot.removeprefix('sha256:')
assert 'MODE=original' in current['Config']['Env']
assert unit.read_text() == restored and unit.stat().st_mode & 0o777 == 0o600
assert run('podman', 'exec', name, 'cat', '/original-layer').stdout == 'original-layer'
assert hashlib.sha256((data / 'value').read_bytes()).hexdigest() == volume_hash
print(json.dumps({'auto_remove_recovery': 'passed', 'writable_layer_preserved': True,
'volume_bytes_preserved': True, 'original_configuration_restored': True,
'injected_target_failure': True, 'network': 'none',
'full_supervised_application_cutover': 'not tested'}))
finally:
run('systemctl', '--user', 'stop', service, check=False)
unit.unlink(missing_ok=True)
unit.with_suffix('.next').unlink(missing_ok=True)
run('systemctl', '--user', 'daemon-reload')
run('systemctl', '--user', 'reset-failed', service, check=False)
run('podman', 'rm', '-f', name, check=False)
if snapshot:
run('podman', 'rmi', tag)
assert root.parent == Path('/tmp') and root.name.startswith(name + '-')
run('podman', 'unshare', 'rm', '-rf', str(root))
if __name__ == '__main__':
main()
@@ -56,10 +56,31 @@ class MaintenanceTests(unittest.TestCase):
for name in ['database.dump',*(v+'.tar' for v in module.VOLUMES)]:
path=c.root/'backup'/name;path.write_bytes(b'original')
c.record['artifacts'][name]={'bytes':path.stat().st_size,'sha256':module.sha(path)}
c.record['original_members']=members()
c.record['database_before']={'operation_id':self.operation,'tables':{},'migrations':[]}
c.record['backup_restore_verified']=c.backup_restore_terms()
c.save()
return c
def test_complete_backup_checksums_allow_verification(self):
self.assertEqual(self.completed_backup().verify()['state'],'held')
def test_restore_proof_must_match_fresh_dump_baseline_image_and_operation(self):
c=self.completed_backup();proof=dict(c.record['backup_restore_verified'])
for field in proof:
c.record['backup_restore_verified']={**proof,field:'changed'}
with self.assertRaisesRegex(RuntimeError,'restore is not verified'):c.verify()
self.assertEqual(c.fence.read_text(),self.operation)
c.record.pop('backup_restore_verified')
with self.assertRaisesRegex(RuntimeError,'restore is not verified'):c.verify()
def test_foreign_restore_fixture_is_never_removed(self):
c=self.completed_backup();name='archy-backup-restore-'+'a'*32
c.record['restore_fixture']={'name':name,'image_id':'a'*64}
def command(argv,timeout,output):
if argv[:2]==['podman','ps']:return b'container-id'
if argv[:2]==['podman','inspect']:return json.dumps([{'Name':name,'Image':'a'*64,'Config':{'Labels':{'io.archipelago.backup.operation':str(uuid.uuid4())}}}]).encode()
raise AssertionError('Unexpected mutation '+str(argv))
c.runner=command
with self.assertRaisesRegex(RuntimeError,'ownership changed'):c.cleanup_restore_fixture()
self.assertIn('restore_fixture',c.record)
def test_same_size_corruption_keeps_admission_closed(self):
c=self.completed_backup();(c.root/'backup'/'database.dump').write_bytes(b'corrupt!')
with self.assertRaisesRegex(RuntimeError,'checksum changed'):c.verify()
@@ -5,6 +5,7 @@ Requires an already imported image: --image IMAGE. Never mounts node volumes,
publishes ports, or invokes the maintenance entrypoint against installed apps.
"""
import argparse
import copy
import importlib.util
import json
from pathlib import Path
@@ -36,7 +37,7 @@ def main():
'-e', 'POSTGRES_DB=indeedhub', image,
], text=True).strip()
deadline = time.monotonic() + 60
while subprocess.run(['podman', 'exec', container, 'pg_isready', '-U', 'indeedhub'],
while subprocess.run(['podman', 'exec', container, 'pg_isready', '-h', '127.0.0.1', '-U', 'indeedhub'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode:
if time.monotonic() > deadline:
raise RuntimeError('Disposable PostgreSQL did not become ready')
@@ -68,6 +69,59 @@ def main():
'podman', 'exec', container, 'pg_dump', '-U', 'indeedhub',
'-d', 'indeedhub', '--format=custom', '--no-owner', '--no-acl',
], timeout=60)
# Exercise the production fresh-backup restore barrier, not just
# hand-written pg_restore commands. All containers are owned fixtures.
fresh = maintenance.Controller(root, str(uuid.uuid4()), 0)
fresh.record = {'operation_id': fresh.operation,
'original_members': [{'name': member, 'container_id': 'a'*64,
'image_id': image.removeprefix('sha256:'), 'unit_sha256': 'b'*64,
'config_sha256': 'c'*64, 'running': True} for member in maintenance.NAMES],
'database_before': {**copy.deepcopy(before), 'operation_id': fresh.operation}, 'artifacts': {}}
holds = fresh.data/'update-transactions'/'holds'
holds.mkdir(parents=True)
for member in maintenance.NAMES: (holds/member).write_text(fresh.operation)
fresh.fence.parent.mkdir(parents=True)
fresh.fence.write_text(fresh.operation)
backup = fresh.root/'backup'
backup.mkdir(parents=True)
for artifact in ['database.dump', *(v+'.tar' for v in maintenance.VOLUMES)]:
path = backup/artifact
path.write_bytes(dump if artifact == 'database.dump' else b'volume-fixture')
fresh.record['artifacts'][artifact] = {'bytes': path.stat().st_size, 'sha256': maintenance.sha(path)}
fresh.save()
try:
fresh.verify_database_backup()
except Exception:
# Fixture-only SQL diagnostics; this test never opens live data.
diagnostic = fresh.root/'commands.private.log'
if diagnostic.exists():
Path('/tmp/archy-backup-fixture-failure.log').write_bytes(diagnostic.read_bytes())
raise
assert fresh.record['backup_restore_verified'] == fresh.backup_restore_terms()
assert 'restore_fixture' not in fresh.record
# A readable dump from the wrong database must also fail the barrier.
fresh.record.pop('backup_restore_verified')
fresh.record['database_before']['tables']['contents']['rows_sha256'] = '0'*64
try:
fresh.verify_database_backup()
except RuntimeError as error:
assert 'differs' in str(error)
else:
raise AssertionError('Wrong database backup incorrectly accepted')
assert 'restore_fixture' not in fresh.record
assert 'backup_restore_verified' not in fresh.record
path = backup/'database.dump'
path.write_bytes(dump[:32])
fresh.record['artifacts']['database.dump'] = {'bytes': path.stat().st_size, 'sha256': maintenance.sha(path)}
try:
fresh.verify_database_backup()
except subprocess.CalledProcessError:
pass
else:
raise AssertionError('Truncated fresh backup incorrectly accepted')
assert 'restore_fixture' not in fresh.record
assert 'backup_restore_verified' not in fresh.record
assert fresh.fence.read_text() == fresh.operation
sql('CREATE DATABASE restore_check')
restore_command = ['podman', 'exec', '-i', container, 'pg_restore',
'-U', 'indeedhub', '-d', 'restore_check',
@@ -104,7 +158,8 @@ def main():
maintenance.verify_database_compatibility(before, controller.database_commitments())
print(json.dumps({'postgres_commitments': 'passed', 'rejected_mutations': rejected,
'network': 'none', 'live_volumes_mounted': False,
'custom_dump_restored': True, 'truncated_dump_rejected': True}))
'custom_dump_restored': True, 'truncated_dump_rejected': True,
'production_restore_barrier': 'passed', 'wrong_backup_rejected': True}))
finally:
if container:
subprocess.run(['podman', 'rm', '-f', container], check=True, stdout=subprocess.DEVNULL)