Verify fresh IndeeHub volume restores before supervised cutover

This commit is contained in:
archipelago
2026-10-07 14:51:40 -04:00
parent 573a58622f
commit f81cc4ecdb
4 changed files with 253 additions and 2 deletions
@@ -110,3 +110,21 @@ command stderr was removed by fixture cleanup, so no exact cause is claimed.
The stale backend compile was interrupted after the readiness edit; a fresh The stale backend compile was interrupted after the readiness edit; a fresh
backend build/suite remains required for the final embedded controller. backend build/suite remains required for the final embedded controller.
Volume-archive restore and the full supervised app cutover remain open gates. Volume-archive restore and the full supervised app cutover remain open gates.
## Four-volume restore barrier — 2026-10-07
The controller now restores each fresh volume archive into separate owned storage
before target startup. It rejects unsafe paths/links and unsupported special files,
checks restored bytes, links, ownership and modes, and rearchives the restored tree
to compare ACLs and extended attributes explicitly (GNU tar compare alone does not
check xattrs). Durable proof binds all four archive hashes to the operation. Failure
retains ingress and lifecycle holds; retry removes only the operation-owned fixture.
No production volume is mounted or modified by restore verification.
All24 pure controller tests pass. The real rootless fixture passes four archives
containing hidden files, hardlinks, symlinks, mapped numeric ownership, ACLs and
xattrs; changed restored bytes/xattrs and unreadable archives are rejected. Evidence:
`/tmp/archy-20261007-volume-controller-tests.log` and
`/tmp/archy-20261007-volume-restore.log`. Repeatable fixture:
`tests/regression/test_indeehub_maintenance_volumes.py`.
Full seven-member application cutover and final backend build remain separate gates.
+96 -2
View File
@@ -3,7 +3,7 @@
No live execution is part of source qualification. Original writable-layer images No live execution is part of source qualification. Original writable-layer images
must already be durable. Never unlock ARCHY_UPDATE_LOCK_FD or release another hold. must already be durable. Never unlock ARCHY_UPDATE_LOCK_FD or release another hold.
""" """
import datetime, hashlib, json, os, pathlib, re, shutil, subprocess, sys, time, uuid import datetime, hashlib, json, os, pathlib, re, shutil, subprocess, sys, time, uuid, tarfile
NAMES = ('indeedhub','indeedhub-api','indeedhub-ffmpeg','indeedhub-minio','indeedhub-postgres','indeedhub-redis','indeedhub-relay') NAMES = ('indeedhub','indeedhub-api','indeedhub-ffmpeg','indeedhub-minio','indeedhub-postgres','indeedhub-redis','indeedhub-relay')
VOLUMES = ('indeedhub-minio-data','indeedhub-postgres-data','indeedhub-redis-data','indeedhub-relay-data') VOLUMES = ('indeedhub-minio-data','indeedhub-postgres-data','indeedhub-redis-data','indeedhub-relay-data')
DATA = pathlib.Path('/var/lib/archipelago') DATA = pathlib.Path('/var/lib/archipelago')
@@ -99,6 +99,46 @@ def validate_nginx_guards(config):
matched+=1 matched+=1
require(matched>=3,'Expected complete legacy IndeeHub route guards') require(matched>=3,'Expected complete legacy IndeeHub route guards')
return matched return matched
def validate_volume_archive(path):
"""Validate the complete inventory before extracting into private storage."""
entries={};size=0
with tarfile.open(path, 'r:') as archive:
for member in archive:
name=pathlib.PurePosixPath(member.name)
require(not name.is_absolute() and '..' not in name.parts,'Unsafe volume archive path')
key=str(name)
require(key not in entries and len(entries)<1000000,'Duplicate or oversized volume archive inventory')
require(member.isfile() or member.isdir() or member.issym() or member.islnk(),'Unsupported volume archive entry')
entries[key]=member;size+=member.size
require(entries and entries.get('.') and entries['.'].isdir(),'Volume archive root is missing')
for key,member in entries.items():
for parent in pathlib.PurePosixPath(key).parents:
ancestor=entries.get(str(parent))
require(ancestor is None or ancestor.isdir(),'Volume archive writes through a link')
if member.issym() or member.islnk():
target=pathlib.PurePosixPath(member.linkname)
require(not target.is_absolute(),'Volume archive link escapes restore storage')
parts=list(pathlib.PurePosixPath(key).parent.parts) if member.issym() else []
for part in target.parts:
if part=='..':
require(parts,'Volume archive link escapes restore storage');parts.pop()
elif part!='.':parts.append(part)
if member.islnk():
linked=entries.get(str(pathlib.PurePosixPath(*parts)))
require(linked is not None and linked.isfile(),'Volume archive hardlink target is not a regular file')
return size
def volume_archive_metadata(path):
entries={};links={}
with tarfile.open(path,'r:') as archive:
for member in archive:
name=str(pathlib.PurePosixPath(member.name))
entries[name]={'mode':member.mode,'uid':member.uid,'gid':member.gid,
'attributes':{key:value for key,value in member.pax_headers.items() if key.startswith('SCHILY.')}}
if member.islnk():links[name]=str(pathlib.PurePosixPath(member.linkname))
for name,target in links.items():entries[name]=entries[target]
return entries
class Controller: class Controller:
def __init__(self, data, operation, lock_fd, runner=None): def __init__(self, data, operation, lock_fd, runner=None):
self.data=pathlib.Path(data);self.operation=operation;self.lock_fd=lock_fd;self.runner=runner self.data=pathlib.Path(data);self.operation=operation;self.lock_fd=lock_fd;self.runner=runner
@@ -271,7 +311,7 @@ class Controller:
require(time.monotonic()<deadline,'Transcodes still active; retained job state, no forced completion') require(time.monotonic()<deadline,'Transcodes still active; retained job state, no forced completion')
time.sleep(1) time.sleep(1)
self.graceful_stop('indeedhub-ffmpeg');self.legacy_api_idle();self.graceful_stop('indeedhub-api') self.graceful_stop('indeedhub-ffmpeg');self.legacy_api_idle();self.graceful_stop('indeedhub-api')
self.backup();self.verify_database_backup();self.verify();return {'operation_id':self.operation,'state':'drained'} self.backup();self.verify_database_backup();self.verify_volume_backups();self.verify();return {'operation_id':self.operation,'state':'drained'}
def backup_restore_terms(self): def backup_restore_terms(self):
baseline=self.record.get('database_before') baseline=self.record.get('database_before')
require(baseline and baseline.get('operation_id')==self.operation,'Backup database baseline missing') require(baseline and baseline.get('operation_id')==self.operation,'Backup database baseline missing')
@@ -335,6 +375,59 @@ class Controller:
finally: finally:
self.cleanup_restore_fixture() self.cleanup_restore_fixture()
self.record['backup_restore_verified']=terms;self.save() self.record['backup_restore_verified']=terms;self.save()
def volume_restore_terms(self):
return {'operation_id':self.operation,'archives':{
name:self.record['artifacts'][name]['sha256'] for name in (v+'.tar' for v in VOLUMES)}}
def cleanup_volume_fixture(self):
name=self.record.get('volume_restore_fixture')
if name is None:return
require(bool(re.fullmatch('volume-restore-[0-9a-f]{32}',name)),'Invalid volume restore fixture')
path=self.root/name
if path.exists() or path.is_symlink():
require(path.is_dir() and not path.is_symlink() and path.stat().st_uid==os.getuid(),'Volume restore ownership changed')
owner=path/'owner'
require(owner.is_file() and not owner.is_symlink() and owner.read_text()==self.operation,'Volume restore ownership changed')
self.run(['podman','unshare','rm','-rf','--',str(path/'payload')],timeout=1800)
roundtrip=path/'roundtrip.tar'
if roundtrip.exists():
require(roundtrip.is_file() and not roundtrip.is_symlink(),'Volume restore output changed')
roundtrip.unlink()
owner.unlink();path.rmdir()
del self.record['volume_restore_fixture'];self.save()
def verify_volume_backups(self):
# Restore each entire volume to fresh owned storage, then have GNU tar
# compare its bytes, links, ownership, modes and metadata to the archive.
# Live volumes are neither mounted nor written by this verification.
self.holds();self.fence_matches();self.verify_artifacts()
terms=self.volume_restore_terms();self.cleanup_volume_fixture()
if self.record.get('volume_restore_verified'):
require(self.record['volume_restore_verified']==terms,'Volume restore proof changed');return
for volume in VOLUMES:
archive=self.root/'backup'/(volume+'.tar')
measured=validate_volume_archive(archive)
require(shutil.disk_usage(self.root).free>measured*2+512*1024*1024,'Insufficient volume restore space')
name='volume-restore-'+uuid.uuid4().hex;path=self.root/name
path.mkdir(mode=0o700)
with (path/'owner').open('x') as owner:
owner.write(self.operation);owner.flush();os.fsync(owner.fileno())
self.record['volume_restore_fixture']=name;self.save()
try:
(path/'payload').mkdir(mode=0o700)
self.run(['podman','unshare','tar','--xattrs','--acls','--numeric-owner',
'--same-owner','--same-permissions','-C',str(path/'payload'),'-xpf',str(archive)],timeout=1800)
self.run(['podman','unshare','tar','--xattrs','--acls','--numeric-owner',
'-C',str(path/'payload'),'-df',str(archive)],timeout=1800)
# GNU tar --compare omits extended attributes. Re-archive the
# restored tree and compare numeric ownership, modes, ACLs and
# xattrs explicitly (normalizing hardlink traversal order).
roundtrip=path/'roundtrip.tar'
with roundtrip.open('xb') as output:
self.run(['podman','unshare','tar','--xattrs','--acls','--numeric-owner',
'-C',str(path/'payload'),'-cpf','-','.'],timeout=1800,output=output)
require(volume_archive_metadata(archive)==volume_archive_metadata(roundtrip),'Restored volume metadata differs from backup')
finally:self.cleanup_volume_fixture()
self.verify_artifacts()
self.record['volume_restore_verified']=terms;self.save()
def verify_artifacts(self): def verify_artifacts(self):
expected_artifacts={'database.dump',*(volume+'.tar' for volume in VOLUMES)} expected_artifacts={'database.dump',*(volume+'.tar' for volume in VOLUMES)}
require(set(self.record.get('artifacts',{}))==expected_artifacts,'Backup artifact inventory incomplete or unexpected') require(set(self.record.get('artifacts',{}))==expected_artifacts,'Backup artifact inventory incomplete or unexpected')
@@ -354,6 +447,7 @@ class Controller:
for name in NAMES:require(self.record.get('stopped',{}).get(name,{}).get('confirmed'),'Original writer stop evidence missing') for name in NAMES:require(self.record.get('stopped',{}).get(name,{}).get('confirmed'),'Original writer stop evidence missing')
self.verify_artifacts() self.verify_artifacts()
require(self.record.get('backup_restore_verified')==self.backup_restore_terms(),'Fresh database backup restore is not verified') require(self.record.get('backup_restore_verified')==self.backup_restore_terms(),'Fresh database backup restore is not verified')
require(self.record.get('volume_restore_verified')==self.volume_restore_terms(),'Fresh volume backup restore is not verified')
return {'operation_id':self.operation,'state':'held'} return {'operation_id':self.operation,'state':'held'}
def release(self, outcome): def release(self, outcome):
require(outcome in ('committed','restored','aborted'),'Invalid release outcome') require(outcome in ('committed','restored','aborted'),'Invalid release outcome')
@@ -59,6 +59,7 @@ class MaintenanceTests(unittest.TestCase):
c.record['original_members']=members() c.record['original_members']=members()
c.record['database_before']={'operation_id':self.operation,'tables':{},'migrations':[]} c.record['database_before']={'operation_id':self.operation,'tables':{},'migrations':[]}
c.record['backup_restore_verified']=c.backup_restore_terms() c.record['backup_restore_verified']=c.backup_restore_terms()
c.record['volume_restore_verified']=c.volume_restore_terms()
c.save() c.save()
return c return c
def test_complete_backup_checksums_allow_verification(self): def test_complete_backup_checksums_allow_verification(self):
@@ -71,6 +72,39 @@ class MaintenanceTests(unittest.TestCase):
self.assertEqual(c.fence.read_text(),self.operation) self.assertEqual(c.fence.read_text(),self.operation)
c.record.pop('backup_restore_verified') c.record.pop('backup_restore_verified')
with self.assertRaisesRegex(RuntimeError,'restore is not verified'):c.verify() with self.assertRaisesRegex(RuntimeError,'restore is not verified'):c.verify()
def test_volume_restore_proof_must_match_all_archives_and_operation(self):
import copy
c=self.completed_backup();proof=copy.deepcopy(c.record['volume_restore_verified'])
for field in ('operation_id',*module.VOLUMES):
changed=copy.deepcopy(proof)
if field=='operation_id':changed[field]='changed'
else:changed['archives'][field+'.tar']='changed'
c.record['volume_restore_verified']=changed
with self.assertRaisesRegex(RuntimeError,'volume backup restore is not verified'):c.verify()
c.record.pop('volume_restore_verified')
with self.assertRaisesRegex(RuntimeError,'volume backup restore is not verified'):c.verify()
self.assertEqual(c.fence.read_text(),self.operation)
def test_foreign_volume_fixture_is_never_removed(self):
c=self.completed_backup();name='volume-restore-'+'a'*32
path=c.root/name;path.mkdir();(path/'owner').write_text(str(uuid.uuid4()))
c.record['volume_restore_fixture']=name
with self.assertRaisesRegex(RuntimeError,'ownership changed'):c.cleanup_volume_fixture()
self.assertEqual(self.calls,[]);self.assertTrue(path.exists())
def test_unsafe_archive_paths_and_links_are_rejected_before_extraction(self):
import tarfile,io
c=self.completed_backup();path=c.root/'unsafe.tar'
cases=[('../escape',tarfile.REGTYPE,''),('/escape',tarfile.REGTYPE,''),
('link',tarfile.SYMTYPE,'../../escape'),('link',tarfile.LNKTYPE,'../escape'),
('device',tarfile.CHRTYPE,''),('hard',tarfile.LNKTYPE,'missing')]
for name,kind,target in cases:
with tarfile.open(path,'w') as archive:
root=tarfile.TarInfo('.');root.type=tarfile.DIRTYPE;archive.addfile(root)
entry=tarfile.TarInfo(name);entry.type=kind;entry.linkname=target;archive.addfile(entry)
with self.assertRaises(RuntimeError):module.validate_volume_archive(path)
with tarfile.open(path,'w') as archive:
for name,kind,target in [('.',tarfile.DIRTYPE,''),('dir',tarfile.SYMTYPE,'safe'),('dir/file',tarfile.REGTYPE,'')]:
entry=tarfile.TarInfo(name);entry.type=kind;entry.linkname=target;archive.addfile(entry)
with self.assertRaisesRegex(RuntimeError,'writes through a link'):module.validate_volume_archive(path)
def test_foreign_restore_fixture_is_never_removed(self): def test_foreign_restore_fixture_is_never_removed(self):
c=self.completed_backup();name='archy-backup-restore-'+'a'*32 c=self.completed_backup();name='archy-backup-restore-'+'a'*32
c.record['restore_fixture']={'name':name,'image_id':'a'*64} c.record['restore_fixture']={'name':name,'image_id':'a'*64}
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Restore fixture-only volume archives with the production maintenance gate.
Requires rootless Podman. Creates no containers and never opens live app volumes.
"""
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import tempfile
import uuid
MODULE = Path(__file__).resolve().parents[2] / 'scripts/indeehub-maintenance-controller.py'
spec = importlib.util.spec_from_file_location('maintenance', MODULE)
maintenance = importlib.util.module_from_spec(spec)
spec.loader.exec_module(maintenance)
def main():
with tempfile.TemporaryDirectory(prefix='archy-volume-restore-') as temporary:
root = Path(temporary)
source = root/'source'
source.mkdir()
(source/'.hidden').write_bytes(b'retained hidden object\x00')
(source/'nested').mkdir()
original = source/'nested'/'media'
original.write_bytes(bytes(range(256))*4096)
original.chmod(0o640)
os.link(original, source/'hardlink')
(source/'symlink').symlink_to('nested/media')
os.setxattr(original, 'user.archy-fixture', b'retained metadata')
# Exercise numeric ownership which the calling host user cannot reproduce
# without the rootless user namespace used by production backup/restore.
subprocess.run(['podman','unshare','chown','101:102',str(original)],check=True)
subprocess.run(['podman','unshare','setfacl','-m','u:103:r--',str(original)],check=True)
operation = str(uuid.uuid4())
controller = maintenance.Controller(root/'data',operation,0)
controller.record = {'operation_id':operation,'artifacts':{}}
holds = controller.data/'update-transactions'/'holds'
holds.mkdir(parents=True)
for name in maintenance.NAMES:(holds/name).write_text(operation)
controller.fence.parent.mkdir(parents=True)
controller.fence.write_text(operation)
backup = controller.root/'backup'
backup.mkdir(parents=True)
for name in ('database.dump',*(v+'.tar' for v in maintenance.VOLUMES)):
path = backup/name
if name=='database.dump':path.write_bytes(b'database checked by separate fixture')
else:
subprocess.run(['podman','unshare','tar','--xattrs','--acls','--numeric-owner',
'-C',str(source),'-cpf',str(path),'.'],check=True)
controller.record['artifacts'][name]={'bytes':path.stat().st_size,'sha256':maintenance.sha(path)}
controller.save()
try:
controller.verify_volume_backups()
assert controller.record['volume_restore_verified']==controller.volume_restore_terms()
assert 'volume_restore_fixture' not in controller.record
controller.verify_volume_backups() # durable proof is reusable
controller.record.pop('volume_restore_verified')
actual_run = controller.run
def corrupt_restored(argv,**kwargs):
if '-df' in argv:
payload = Path(argv[argv.index('-C')+1])
subprocess.run(['podman','unshare','sh','-c','printf changed > "$1/.hidden"','fixture',str(payload)],check=True)
return actual_run(argv,**kwargs)
controller.run=corrupt_restored
try:controller.verify_volume_backups()
except subprocess.CalledProcessError:pass
else:raise AssertionError('Changed restoration accepted')
assert 'volume_restore_verified' not in controller.record
assert 'volume_restore_fixture' not in controller.record
assert controller.fence.read_text()==operation
controller.run=actual_run
def corrupt_metadata(argv,**kwargs):
result=actual_run(argv,**kwargs)
if '-xpf' in argv:
payload=Path(argv[argv.index('-C')+1])
subprocess.run(['podman','unshare','python3','-c',
"import os,sys;os.setxattr(sys.argv[1],'user.archy-fixture',b'changed')",
str(payload/'nested'/'media')],check=True)
return result
controller.run=corrupt_metadata
try:controller.verify_volume_backups()
except RuntimeError as error:assert 'metadata differs' in str(error)
else:raise AssertionError('Changed xattr restoration accepted')
assert 'volume_restore_verified' not in controller.record
assert 'volume_restore_fixture' not in controller.record
controller.run=actual_run
path=backup/(maintenance.VOLUMES[0]+'.tar')
path.write_bytes(b'not a tar archive')
controller.record['artifacts'][path.name]={'bytes':path.stat().st_size,'sha256':maintenance.sha(path)}
try:controller.verify_volume_backups()
except maintenance.tarfile.ReadError:pass
else:raise AssertionError('Unreadable archive accepted')
assert 'volume_restore_verified' not in controller.record
assert controller.fence.read_text()==operation
print(json.dumps({'production_volume_restore_barrier':'passed','volumes':4,
'hidden_files':True,'hardlinks':True,'symlinks':True,'numeric_ownership':True,
'xattrs':True,'acls':True,'corrupt_xattr_rejected':True,'corrupt_restore_rejected':True,'unreadable_archive_rejected':True,
'live_volumes_opened':False}))
finally:
subprocess.run(['podman','unshare','rm','-rf','--',str(source)],check=True)
if __name__=='__main__':main()