#!/usr/bin/env python3 """Real migration -> adoption -> three updates, retention and restore on synthetic data. Requires local storage test images and registry:2; never contacts a FEDEO instance. Release HTTP/health responses are fixtures; Compose, registry, SDK, PostgreSQL, backup, restore and provider validation use real processes and containers. """ import copy import importlib.util import json from pathlib import Path import subprocess import tarfile import tempfile ROOT = Path(__file__).resolve().parents[3] spec = importlib.util.spec_from_file_location('storage_fixture', ROOT / 'scripts/storage/tests/host_integration.py') fixture = importlib.util.module_from_spec(spec) spec.loader.exec_module(fixture) m, docker = fixture.m, fixture.docker u = m.u class Http: version = '3.4.0' def json(self, url): return {'status': 'ok', 'product': 'FEDEO', 'version': self.version, 'revision': 'a' * 40} class Releases: def __init__(self, original, images): self.original, self.images = original, images def download(self, version, destination): config = copy.deepcopy(self.original) for name, image in self.images.items(): config['services'][name]['image'] = image['repository'] + ':fixture' config['services']['frontend']['entrypoint'] = ['node'] config['services']['frontend']['command'] = ['-e', 'setInterval(()=>{},1000)'] for name in ('traefik', 'node-exporter'): config['services'][name]['image'] = json.loads(docker('image', 'inspect', 'node:22-alpine'))[0]['Id'] config['services']['backend']['environment']['FEDEO_VERSION'] = version path = destination / 'fixture.json' u.write_json(path, m.literal(config)) rendered = docker('compose', '-f', path, 'config', '--format', 'yaml') (destination / 'docker-compose.selfhost.yml').write_text(rendered) for name in ('.env.example', 'selfhost-setup.sh', 'CHANGELOG.md'): (destination / name).write_text('# Synthetic update fixture\n') manifest = {'schemaVersion': 1, 'version': version, 'commit': 'a' * 40, 'images': list(self.images.values()), 'migrations': []} u.write_json(destination / 'release-manifest.json', manifest) return manifest def run(): root, project = fixture.fixture() registry = project + '-registry' registry_id = None work = Path(tempfile.mkdtemp(prefix='fedeo-storage-update-images-')) try: config = m.resolved(u.read_json(root / 'docker-compose.yml')) config['services']['backend']['environment'].update(ENCRYPTION_KEY='fixture-key', COOKIE_SECRET='fixture-$-cookie', JWT_SECRET='fixture-jwt') config['services']['frontend']['environment'] = {'NUXT_PUBLIC_API_BASE': 'https://fixture.example.test/backend'} u.write_json(root / 'docker-compose.yml', m.literal(config)) docker('compose', '-p', project, '-f', root / 'docker-compose.yml', 'up', '-d', '--pull', 'never', '--wait', '--wait-timeout', '90') fixture.seed(root, project) # Include the stopped Matrix orphan seen on the first real instance. (root / 'matrix/postgres').mkdir(parents=True) (root / 'matrix/postgres/retained.txt').write_text('retired Matrix') retained = docker('create', '--pull', 'never', '--restart=no', '--network', project + '-internal', '--label', 'com.docker.compose.project=' + project, '--label', 'com.docker.compose.service=matrix-db', '-v', str(root / 'matrix/postgres') + ':/var/lib/postgresql/data', 'node:22-alpine') migration = m.Migration(root, 'fedeo-storage-tools:pilot') with migration.engine.lock(): migration.plan() migration.migrate(True) migration.activate() print(json.dumps({'phase': 'migration_complete', 'evidence': str(root)}), flush=True) for name in u.CORE_SERVICES: migration.wait_ready(docker('compose', '-p', project, '-f', root / 'docker-compose.yml', 'ps', '-q', name)) http = Http() updater = u.Updater(root, http=http) assert updater.adopt_storage()['status'] == 'adopted' print(json.dumps({'phase': 'storage_adopted'}), flush=True) registry_id = docker('run', '-d', '--pull', 'never', '--name', registry, '-p', '127.0.0.1::5000', 'registry:2') port = json.loads(docker('inspect', registry_id))[0]['NetworkSettings']['Ports']['5000/tcp'][0]['HostPort'] repository = 'localhost:' + port + '/fedeo-update-fixture' (work / 'Dockerfile').write_text('FROM fedeo-storage-tools:pilot\n' 'RUN node -e "const fs=require(\'fs\');const p=JSON.parse(fs.readFileSync(\'package.json\'));' 'p.scripts={migrate:\'node --version\'};fs.writeFileSync(\'package.json\',JSON.stringify(p))"\n' 'ENTRYPOINT ["node"]\nCMD ["-e", "setInterval(()=>{},1000)"]\n') docker('build', '-t', repository + ':fixture', work) docker('push', repository + ':fixture') digest = json.loads(docker('image', 'inspect', repository + ':fixture'))[0]['RepoDigests'][0].split('@')[1] images = {name: {'name': name, 'repository': repository + '-' + name, 'digest': digest} for name in ('backend', 'frontend')} for image in images.values(): docker('tag', repository + ':fixture', image['repository'] + ':fixture') docker('push', image['repository'] + ':fixture') # Copy health, networking and SDK settings from the original fixture, but start the new image's migration script. updater.releases = Releases(config, images) backups = [] for version in ('3.4.0', '3.5.0', '3.6.0'): http.version = version checked = updater.execute('check', version) assert checked['plan']['storageProvider'] == 'seaweedfs' assert not checked['plan']['requiresComposeAdoption'] result = updater.execute('apply', version, expected_fingerprint=checked['plan']['approvalFingerprint']) assert result['status'] == 'succeeded' assert not migration.inspect(migration.state['containers']['minio']['id'])['State']['Running'] old_matrix = migration.inspect(retained) assert not old_matrix['State']['Running'] and old_matrix['HostConfig']['RestartPolicy']['Name'] == 'no' backup = Path(result['backup']) backups.append(backup) retention = result['backupRetention'] assert retention['status'] == 'complete', retention assert len(retention['retained']) == min(len(backups), 2) if len(backups) == 3: assert not backups[0].exists() assert u.read_json(backups[0].parent / 'backup-pruned.json')['status'] == 'removed' assert backups[1].is_dir() and backups[2].is_dir() assert (root / '.updates/storage-migration/backup/complete.json').is_file() with tarfile.open(backup / 'files.tar') as archive: assert archive.extractfile('matrix/postgres/retained.txt').read() == b'retired Matrix' assert 'storage/config/s3.json' in archive.getnames() assert 'minio' not in archive.getnames() metadata = u.read_json(backup / 'complete.json') for name, expected in metadata['sha256'].items(): assert u.file_sha(backup / name) == expected print(json.dumps({'phase': 'update_complete', 'version': version, 'backup': str(backup)}), flush=True) # Restore the *updater's* cold archive, not the original migration backup. restore = root / '.updates/update-restore-test' restore.mkdir() subprocess.run(['tar', '-xpf', str(backup / 'files.tar'), '-C', str(restore)], check=True) store = updater.storage['service'] restore_name = project + '-update-restore' try: docker('run', '-d', '--pull', 'never', '--name', restore_name, '--network', project + '-internal', '--label', 'com.docker.compose.project=' + project, '-v', str(restore / 'storage/seaweedfs') + ':/data', '-v', str(restore / 'storage/config') + ':/etc/fedeo:ro', '-e', 'FEDEO_S3_CONFIG_SHA256=' + updater.storage['configSha256']['s3.json'], '--entrypoint', store['entrypoint'][0], store['image'], *store['entrypoint'][1:], *store['command']) migration.helper('restore-verify', endpoint='http://' + restore_name + ':9000') finally: docker('rm', '-f', restore_name) print(json.dumps({'result': 'passed', 'retention': 'two_generations_verified', 'restoredUpdaterBackup': str(backup), 'evidence': str(root)}), flush=True) except BaseException: print('Failure evidence: ' + str(root), flush=True) for path in sorted((root / '.updates').glob('*/commands.log')): print(str(path) + '\n' + path.read_text()[-5000:], flush=True) raise finally: ids = docker('ps', '-aq', '--filter', 'label=com.docker.compose.project=' + project).split() if ids: docker('rm', '-f', '-v', *ids) if registry_id: docker('rm', '-f', '-v', registry_id) docker('network', 'rm', project + '-internal') if __name__ == '__main__': run()