Verify complete maintenance backup hashes and restored database commitments

This commit is contained in:
archipelago
2026-10-07 13:50:11 -04:00
parent 2512a9f62b
commit 68082cec49
4 changed files with 164 additions and 1 deletions
@@ -54,3 +54,24 @@ Qualification required before integration/activation:
- Disk-capacity and recovery-image retention checks, backup integrity and a - Disk-capacity and recovery-image retention checks, backup integrity and a
documented recovery path for missing runtime artifacts. documented recovery path for missing runtime artifacts.
- Actual-node controlled deployment and acceptance, preserving persistent data. - Actual-node controlled deployment and acceptance, preserving persistent data.
## Resumed qualification — 2026-10-07
Backup verification now requires the full database/four-volume artifact set and
rechecks SHA256, including same-size corruption. Nineteen pure controller tests
pass. The owned, network-none PostgreSQL fixture passes unchanged/additive
commitments, rejects four data/schema/history mutations, restores a real custom
dump with matching original commitments, and rejects a truncated dump. It mounts
no live volume and removes only its own container. This does not qualify actual
application writer drain or the complete supervised systemd cutover.
The updater compiled and its full isolated suite ran: 1,958 passed, one failed,
five ignored. The failure is the old snake_case rental receipt JSON fixture;
`c2c4d915` already corrects that exact test on the release candidate branch.
Do not duplicate or suppress it here. Integrate and rerun the complete candidate
suite before claiming a green backend gate. The earlier interrupted compile
and PostgreSQL timeout remain failed/incomplete attempts, not acceptance.
Evidence: `/tmp/archy-resumed-20261007-updater-full-backend.log`,
`/tmp/archy-resumed-20261007-indeehub-controller-tests-final.log`, and
`/tmp/archy-resumed-20261007-indeehub-postgres-restore.log`.
+5 -1
View File
@@ -5,6 +5,7 @@ must already be durable. Never unlock ARCHY_UPDATE_LOCK_FD or release another ho
""" """
import datetime, hashlib, json, os, pathlib, re, shutil, subprocess, sys, time, uuid import datetime, hashlib, json, os, pathlib, re, shutil, subprocess, sys, time, uuid
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')
DATA = pathlib.Path('/var/lib/archipelago') DATA = pathlib.Path('/var/lib/archipelago')
QUEUE_SCRIPT = r'''const {Queue}=require('bullmq'); QUEUE_SCRIPT = r'''const {Queue}=require('bullmq');
(async()=>{const q=new Queue('transcode',{connection:{host:process.env.QUEUE_HOST,port:Number(process.env.QUEUE_PORT||6379),password:process.env.QUEUE_PASSWORD,maxRetriesPerRequest:1}}); (async()=>{const q=new Queue('transcode',{connection:{host:process.env.QUEUE_HOST,port:Number(process.env.QUEUE_PORT||6379),password:process.env.QUEUE_PASSWORD,maxRetriesPerRequest:1}});
@@ -192,7 +193,7 @@ class Controller:
classification=('idle-worker-terminated-after-queue-drain' if idle_worker else 'empty-business-store-legacy-api-terminated') if str(code)=='143' else 'clean-process-exit' classification=('idle-worker-terminated-after-queue-drain' if idle_worker else 'empty-business-store-legacy-api-terminated') if str(code)=='143' else 'clean-process-exit'
stopped[name].update(confirmed=True,exit_code=int(code),classification=classification,confirmed_at=time.time());self.save() stopped[name].update(confirmed=True,exit_code=int(code),classification=classification,confirmed_at=time.time());self.save()
def volume_sources(self): def volume_sources(self):
expected=['indeedhub-minio-data','indeedhub-postgres-data','indeedhub-redis-data','indeedhub-relay-data'] expected=VOLUMES
rows=json.loads(self.run(['podman','volume','inspect',*expected])) rows=json.loads(self.run(['podman','volume','inspect',*expected]))
require({row['Name'] for row in rows}==set(expected),'Persistent volume scope changed') require({row['Name'] for row in rows}==set(expected),'Persistent volume scope changed')
return {row['Name']:row['Mountpoint'] for row in rows} return {row['Name']:row['Mountpoint'] for row in rows}
@@ -282,8 +283,11 @@ class Controller:
# Verification remains possible when API/storage endpoints are stopped. # Verification remains possible when API/storage endpoints are stopped.
# The native adapter separately validates target/original runtime identity. # The native adapter separately validates target/original runtime identity.
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')
expected_artifacts={'database.dump',*(volume+'.tar' for volume in VOLUMES)}
require(set(self.record.get('artifacts',{}))==expected_artifacts,'Backup artifact inventory incomplete or unexpected')
for name,record in self.record['artifacts'].items(): for name,record in self.record['artifacts'].items():
path=self.root/'backup'/name;require(path.is_file() and not path.is_symlink() and path.stat().st_size==record['bytes'],'Backup artifact missing or changed') path=self.root/'backup'/name;require(path.is_file() and not path.is_symlink() and path.stat().st_size==record['bytes'],'Backup artifact missing or changed')
require(sha(path)==record['sha256'],'Backup artifact checksum changed')
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')
@@ -47,6 +47,30 @@ class MaintenanceTests(unittest.TestCase):
c.fence.parent.mkdir(parents=True);c.fence.write_text(self.operation) c.fence.parent.mkdir(parents=True);c.fence.write_text(self.operation)
(c.root/'backup').mkdir();(c.root/'backup'/'database.dump').write_bytes(b'not evidence') (c.root/'backup').mkdir();(c.root/'backup'/'database.dump').write_bytes(b'not evidence')
with self.assertRaisesRegex(RuntimeError,'Drain not complete'):c.verify() with self.assertRaisesRegex(RuntimeError,'Drain not complete'):c.verify()
def completed_backup(self):
c=self.controller
c.record={'operation_id':self.operation,'phase':'Drained','backup_complete':True,
'stopped':{name:{'confirmed':True} for name in module.NAMES},'artifacts':{}}
c.save();c.fence.parent.mkdir(parents=True);c.fence.write_text(self.operation)
(c.root/'backup').mkdir()
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.save()
return c
def test_complete_backup_checksums_allow_verification(self):
self.assertEqual(self.completed_backup().verify()['state'],'held')
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()
self.assertEqual(c.fence.read_text(),self.operation);self.assertEqual(self.calls,[])
def test_incomplete_or_unexpected_artifact_inventory_cannot_pass(self):
c=self.completed_backup();original=dict(c.record['artifacts'])
for artifacts in [{}, {k:v for k,v in original.items() if k!='database.dump'},
{**original,'../foreign':original['database.dump']}]:
c.record['artifacts']=artifacts
with self.assertRaisesRegex(RuntimeError,'inventory'):c.verify()
self.assertEqual(c.fence.read_text(),self.operation)
def test_forced_original_exit_never_marks_writer_completed(self): def test_forced_original_exit_never_marks_writer_completed(self):
c=self.controller;c.record={'operation_id':self.operation,'phase':'Prepared','original_members':module.validate_members(members())};c.save() c=self.controller;c.record={'operation_id':self.operation,'phase':'Prepared','original_members':module.validate_members(members())};c.save()
def command(argv,timeout,output): def command(argv,timeout,output):
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Exercise rollback commitments on an owned, network-isolated PostgreSQL.
Requires an already imported image: --image IMAGE. Never mounts node volumes,
publishes ports, or invokes the maintenance entrypoint against installed apps.
"""
import argparse
import importlib.util
import json
from pathlib import Path
import subprocess
import tempfile
import time
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():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--image', required=True)
args = parser.parse_args()
image = subprocess.check_output(
['podman', 'image', 'inspect', '--format', '{{.Id}}', args.image], text=True,
).strip()
name = 'archy-maintenance-sql-' + uuid.uuid4().hex
container = None
try:
container = subprocess.check_output([
'podman', 'run', '-d', '--pull=never', '--network=none', '--name', name,
'--tmpfs', '/var/lib/postgresql/data:rw',
'-e', 'POSTGRES_HOST_AUTH_METHOD=trust', '-e', 'POSTGRES_USER=indeedhub',
'-e', 'POSTGRES_DB=indeedhub', image,
], text=True).strip()
deadline = time.monotonic() + 60
while subprocess.run(['podman', 'exec', container, 'pg_isready', '-U', 'indeedhub'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode:
if time.monotonic() > deadline:
raise RuntimeError('Disposable PostgreSQL did not become ready')
time.sleep(0.5)
def sql(statement, database='indeedhub'):
return subprocess.check_output([
'podman', 'exec', '-i', container, 'psql', '-XqAt',
'--set=ON_ERROR_STOP=1', '-U', 'indeedhub', '-d', database,
], input=statement.encode(), timeout=60)
sql('CREATE TABLE migrations(id serial PRIMARY KEY, timestamp bigint NOT NULL, name text NOT NULL);'
"INSERT INTO migrations(timestamp,name) VALUES(1,'Original1');"
'CREATE TABLE contents(id int PRIMARY KEY, title text NOT NULL);'
"INSERT INTO contents VALUES(1,'retained original');")
with tempfile.TemporaryDirectory(prefix=name) as root:
controller = maintenance.Controller(root, str(uuid.uuid4()), 0)
database = 'indeedhub'
def fixture_run(argv, timeout=30, output=None, input_bytes=None):
assert argv[:4] == ['podman', 'exec', '-i', 'indeedhub-postgres']
assert output is None and input_bytes is not None
return sql(input_bytes.decode(), database)
controller.run = fixture_run
before = controller.database_commitments()
dump = subprocess.check_output([
'podman', 'exec', container, 'pg_dump', '-U', 'indeedhub',
'-d', 'indeedhub', '--format=custom', '--no-owner', '--no-acl',
], timeout=60)
sql('CREATE DATABASE restore_check')
restore_command = ['podman', 'exec', '-i', container, 'pg_restore',
'-U', 'indeedhub', '-d', 'restore_check',
'--exit-on-error', '--no-owner', '--no-acl']
subprocess.run(restore_command, input=dump, check=True, timeout=60)
database = 'restore_check'
maintenance.verify_database_compatibility(before, controller.database_commitments())
database = 'indeedhub'
rejected_dump = subprocess.run(restore_command, input=dump[:32], timeout=60,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
assert rejected_dump.returncode != 0, 'Truncated dump incorrectly accepted'
maintenance.verify_database_compatibility(before, controller.database_commitments())
for table in sorted(maintenance.ADDITIVE_TABLES):
sql(f'CREATE TABLE {table}(id int PRIMARY KEY);')
for migration, timestamp in maintenance.ADDITIVE_MIGRATIONS.items():
sql(f"INSERT INTO migrations(timestamp,name) VALUES({timestamp},'{migration}');")
proof = maintenance.verify_database_compatibility(before, controller.database_commitments())
assert len(proof['new_empty_tables']) == 5
rejected = 0
for mutation, undo in [
("UPDATE contents SET title='changed'", "UPDATE contents SET title='retained original'"),
('ALTER TABLE contents ADD COLUMN unexpected text', 'ALTER TABLE contents DROP COLUMN unexpected'),
('INSERT INTO archipelago_publications VALUES(1)', 'DELETE FROM archipelago_publications'),
("UPDATE migrations SET name='changed' WHERE id=1", "UPDATE migrations SET name='Original1' WHERE id=1"),
]:
sql(mutation)
try:
maintenance.verify_database_compatibility(before, controller.database_commitments())
except RuntimeError:
rejected += 1
else:
raise AssertionError('Changed database incorrectly accepted')
sql(undo)
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}))
finally:
if container:
subprocess.run(['podman', 'rm', '-f', container], check=True, stdout=subprocess.DEVNULL)
if __name__ == '__main__':
main()