fix(selfhost): retire deprecated Matrix services during updates

This commit is contained in:
2026-09-23 17:43:12 +02:00
parent fdf1d42a3d
commit 847a616285
3 changed files with 311 additions and 8 deletions

View File

@@ -6,6 +6,7 @@ import os
import io
from pathlib import Path
import subprocess
import tarfile
import tempfile
import unittest
from unittest.mock import patch
@@ -35,6 +36,7 @@ class Http:
self.bad_health = False
self.release = {'id': 1, 'tag_name': 'v3.1.0', 'draft': False, 'prerelease': False}
self.fail_download = False
self.reported_version = '3.1.0'
def refresh(self):
self.files['SHA256SUMS'] = ''.join(hashlib.sha256(blob).hexdigest() + ' ' + name + '\n'
@@ -59,7 +61,7 @@ class Http:
if url.endswith('/tags/v3.1.0'):
return {'commit': {'sha': 'a' * 40}}
if url.endswith('/backend/version'):
return {'product': 'FEDEO', 'version': '3.1.0'}
return {'product': 'FEDEO', 'version': self.reported_version}
if url.endswith('/health'):
return {'status': 'ok', 'product': 'FEDEO', 'version': '3.0.0' if self.bad_health else '3.1.0', 'revision': 'a' * 40}
raise AssertionError(url)
@@ -74,6 +76,9 @@ class Runner:
self.fail = None
self.on_pull = None
self.interrupt = None
self.matrix_containers = {}
self.on_stop = None
self.real_tar = False
def run(self, args, output=None, input_file=None, **kwargs):
self.calls.append(args)
@@ -91,8 +96,13 @@ class Runner:
return 'image-' + args[3].rsplit('/', 1)[-1].split('@')[0]
if args[:2] == ['docker', 'inspect']:
name = args[2]
if name in self.matrix_containers:
return json.dumps([self.matrix_containers[name]])
return json.dumps([{'Image': 'image-' + name, 'Config': {'Labels': {'com.docker.compose.service': name}},
'State': {'Running': name != 'createbuckets', 'Health': {'Status': 'healthy'}}}])
if args[:3] == ['docker', 'update', '--restart=no']:
self.matrix_containers[args[3]]['HostConfig']['RestartPolicy'] = {'Name': 'no', 'MaximumRetryCount': 0}
return args[3]
if 'config' in args:
result = subprocess.run(args, env={'PATH': os.environ['PATH'], 'HOME': os.environ['HOME']},
capture_output=True, text=True)
@@ -102,15 +112,25 @@ class Runner:
if 'ps' in args:
if args[:2] == ['docker', 'ps']:
return ''
if args[-1] in ('backend', 'frontend', 'db', 'minio'):
if args[-1] in ('backend', 'frontend', 'db', 'minio') or args[-1] in self.matrix_containers:
return args[-1]
return 'backend\nfrontend\ndb\nminio\ntraefik\nnode-exporter\ncreatebuckets\n'
return 'backend\nfrontend\ndb\nminio\ntraefik\nnode-exporter\ncreatebuckets\n' + '\n'.join(self.matrix_containers)
if 'stop' in args or 'start' in args:
for name, container in self.matrix_containers.items():
if name in args:
container['State']['Running'] = 'start' in args
if 'stop' in args and self.on_stop:
self.on_stop()
if 'pull' in args and self.on_pull:
self.on_pull()
if any('pg_database_size' in part for part in args):
return '1000'
if args[0] == 'du':
return '1000\tminio'
if args[0] == 'tar' and self.real_tar:
with open(output, 'wb') if output else open(os.devnull, 'wb') as destination:
subprocess.run(args, stdout=destination, check=True)
return ''
if output:
Path(output).write_bytes(b'PGDMP fake dump')
if args[0] == 'tar' and '-cpf' in args:
@@ -153,6 +173,196 @@ class UpdateTests(unittest.TestCase):
self.assertEqual((self.root / 'docker-compose.yml').read_bytes(), self.original_compose)
self.assertFalse(self.called('stop'))
def add_legacy_matrix(self):
services = {}
for name in sorted(u.LEGACY_MATRIX_SERVICES):
services[name] = {'image': 'example.test/' + name + ':legacy', 'restart': 'unless-stopped',
'container_name': 'fedeo-' + name, 'networks': ['internal']}
self.runner.matrix_containers[name] = {
'Id': name, 'Image': 'image-' + name,
'Config': {'Labels': {'com.docker.compose.service': name}}, 'Mounts': [],
'HostConfig': {'RestartPolicy': {'Name': 'unless-stopped', 'MaximumRetryCount': 0}},
'State': {'Running': True, 'Health': {'Status': 'healthy'}}}
for name, directory, target in [('matrix-db', 'postgres', '/var/lib/postgresql/data'),
('matrix-synapse', 'synapse', '/data')]:
source = self.root / 'matrix' / directory
source.mkdir(parents=True)
(source / 'data').write_text('preserved ' + name)
services[name]['volumes'] = [{'type': 'bind', 'source': str(source), 'target': target}]
self.runner.matrix_containers[name]['Mounts'] = [{'Type': 'bind', 'Source': str(source), 'Destination': target}]
# Use the real Compose parser to model an older base file, including the removed dependency.
config = json.loads(subprocess.check_output(['docker', 'compose', '--project-directory', str(self.root),
'--env-file', str(self.root / '.env'), '-f', str(self.root / 'docker-compose.yml'),
'config', '--format', 'json'], env={'PATH': os.environ['PATH'], 'HOME': os.environ['HOME']}))
config['services'].update(services)
config['services']['backend']['depends_on']['matrix-synapse'] = {'condition': 'service_healthy'}
(self.root / 'docker-compose.yml').write_bytes(data(config))
self.original_compose = (self.root / 'docker-compose.yml').read_bytes()
def test_check_plans_matrix_retirement_without_stopping_or_disabling_it(self):
self.add_legacy_matrix()
result = self.updater.execute('check', '3.1.0')
self.assertEqual(set(result['plan']['retiredServices']), u.LEGACY_MATRIX_SERVICES)
self.assertEqual(result['plan']['retainedDataPaths'], ['matrix'])
self.assertEqual(result['plan']['estimatedBackupBytes'], 4000 + u.GIB)
self.assertFalse(self.called('--restart=no'))
self.assert_untouched()
def test_matrix_is_backed_up_then_retired_without_removing_containers_or_data(self):
self.add_legacy_matrix()
# An image-created Redis volume must remain attached to the retained container.
self.runner.matrix_containers['matrix-redis']['Mounts'] = [
{'Type': 'volume', 'Source': '/var/lib/docker/volumes/redis/_data', 'Destination': '/data', 'Name': 'redis'}]
result = self.run_update()
self.assertEqual(result['status'], 'succeeded')
self.assertNotIn(b'matrix-', (self.root / 'docker-compose.yml').read_bytes())
self.assertTrue((self.root / 'matrix/postgres/data').exists())
self.assertTrue((self.root / 'matrix/synapse/data').exists())
backup = Path(result['backup'])
self.assertEqual((backup / 'config/docker-compose.yml').read_bytes(), self.original_compose)
metadata = u.read_json(backup / 'complete.json')
self.assertEqual(metadata['matrixDataPaths'], ['matrix'])
self.assertEqual(set(metadata['retainedContainerIds']), u.LEGACY_MATRIX_SERVICES)
original = u.read_json(backup / 'containers.json')
self.assertEqual(next(c for c in original if c.get('Id') == 'matrix-db')['HostConfig']['RestartPolicy']['Name'], 'unless-stopped')
for container in self.runner.matrix_containers.values():
self.assertFalse(container['State']['Running'])
self.assertEqual(container['HostConfig']['RestartPolicy']['Name'], 'no')
calls = self.runner.calls
archive = next(i for i, a in enumerate(calls) if a[0] == 'tar' and '-cpf' in a)
retire = next(i for i, a in enumerate(calls) if '--restart=no' in a)
self.assertIn('matrix', calls[archive])
self.assertLess(archive, retire)
self.assertFalse(any('--remove-orphans' in a or a[:2] in (['docker', 'rm'], ['docker', 'volume']) for a in calls))
# Future updates see stopped Matrix orphans; they do not bring them back.
(self.root / '.env').write_bytes(self.original_env)
self.assertEqual(self.run_update()['status'], 'succeeded')
self.assertFalse(any(c['State']['Running'] for c in self.runner.matrix_containers.values()))
def test_matrix_archive_failure_preserves_base_and_restarts_original_stack(self):
self.add_legacy_matrix()
self.runner.fail = lambda a: a[0] == 'tar' and '-cpf' in a
with self.assertRaises(u.UpdateError):
self.run_update()
self.assertFalse(self.called('--restart=no'))
self.assertEqual((self.root / 'docker-compose.yml').read_bytes(), self.original_compose)
self.assertFalse(self.updater.status()['recoveryRequired'])
self.assertTrue(all(c['State']['Running'] for c in self.runner.matrix_containers.values()))
def test_real_matrix_archive_contains_database_media_and_configuration(self):
self.add_legacy_matrix()
self.runner.real_tar = True
(self.root / 'matrix/livekit.yaml').write_text('preserved config')
(self.root / 'matrix/postgres/pg_wal').mkdir()
(self.root / 'matrix/postgres/pg_wal/segment').write_bytes(b'preserved WAL')
expected = {str(path.relative_to(self.root)): path.read_bytes()
for path in (self.root / 'matrix').rglob('*') if path.is_file()}
result = self.run_update()
backup = Path(result['backup'])
with tarfile.open(backup / 'files.tar') as archive:
for name, contents in expected.items():
self.assertEqual(archive.extractfile(name).read(), contents)
self.assertEqual(u.sha((backup / 'files.tar').read_bytes()), u.read_json(backup / 'complete.json')['sha256']['files.tar'])
def test_matrix_must_be_stopped_before_a_cold_backup(self):
self.add_legacy_matrix()
self.runner.on_stop = lambda: self.runner.matrix_containers['matrix-db']['State'].update(Running=True)
with self.assertRaisesRegex(u.UpdateError, 'Matrix-Container läuft noch'):
self.run_update()
self.assertFalse(self.called('-cpf'))
self.assertFalse(self.called('--restart=no'))
def test_interrupted_retirement_requires_recovery_and_preserves_backup(self):
self.add_legacy_matrix()
self.runner.interrupt = lambda a: '--restart=no' in a and a[-1] == 'matrix-element'
with self.assertRaises(KeyboardInterrupt):
self.run_update()
state = self.updater.status()
self.assertEqual(state['phase'], 'retiring_matrix')
self.assertTrue(state['recoveryRequired'])
self.assertTrue((Path(state['backup']) / 'complete.json').exists())
self.assertEqual((self.root / 'docker-compose.yml').read_bytes(), self.original_compose)
self.assertFalse(any(c['State']['Running'] for c in self.runner.matrix_containers.values()))
def test_matrix_symlinks_and_nonstandard_mounts_are_rejected_before_stopping(self):
self.add_legacy_matrix()
link = self.root / 'matrix/postgres/pg_wal'
link.symlink_to(self.root / 'postgres', target_is_directory=True)
with self.assertRaisesRegex(u.UpdateError, 'Symbolische Links'):
self.run_update()
self.assert_untouched()
link.unlink()
self.runner.matrix_containers['matrix-db']['Mounts'][0]['Source'] = '/different/postgres'
with self.assertRaisesRegex(u.UpdateError, 'Tatsächliche Matrix-Datenpfade'):
self.run_update()
self.assert_untouched()
def test_unknown_additional_services_are_still_rejected(self):
self.add_legacy_matrix()
config = json.loads(self.original_compose)
config['services']['matrix-custom'] = {'image': 'example.test/custom:latest'}
(self.root / 'docker-compose.yml').write_bytes(data(config))
with self.assertRaisesRegex(u.UpdateError, 'matrix-custom'):
self.run_update()
self.assertFalse(self.called('stop'))
def test_matrix_cannot_be_reintroduced_by_an_override(self):
self.add_legacy_matrix()
(self.root / 'docker-compose.override.yml').write_text('services:\n matrix-element:\n image: example.test/element:legacy\n')
with self.assertRaisesRegex(u.UpdateError, 'Zielkonfiguration.*matrix-element'):
self.run_update()
self.assert_untouched()
def test_nonstandard_matrix_bind_and_named_volume_are_rejected(self):
self.add_legacy_matrix()
for volume in [{'type': 'bind', 'source': str(self.root / 'postgres'), 'target': '/data'},
{'type': 'volume', 'source': 'matrixdata', 'target': '/data'}]:
with self.subTest(volume=volume['type']):
config = json.loads(self.original_compose)
config['services']['matrix-synapse']['volumes'] = [volume]
config['volumes'] = {'matrixdata': {}}
(self.root / 'docker-compose.yml').write_bytes(data(config))
with self.assertRaisesRegex(u.UpdateError, 'Matrix-Daten müssen'):
self.run_update()
self.assertFalse(self.called('stop'))
def test_resolve_target_requires_matrix_to_remain_retired(self):
self.add_legacy_matrix()
self.runner.fail = lambda a: 'migrate' in a
with self.assertRaises(u.UpdateError):
self.run_update()
self.runner.fail = None
self.runner.matrix_containers['matrix-db']['HostConfig']['RestartPolicy']['Name'] = 'always'
with self.assertRaisesRegex(u.UpdateError, 'automatisch neu starten'):
self.updater.resolve('3.1.0')
self.assertTrue(self.updater.status()['recoveryRequired'])
self.runner.matrix_containers['matrix-db']['HostConfig']['RestartPolicy']['Name'] = 'no'
self.assertEqual(self.updater.resolve('3.1.0')['status'], 'recovered')
def test_resolve_rollback_requires_original_matrix_images_state_and_restart_policy(self):
self.add_legacy_matrix()
originals = copy.deepcopy(self.runner.matrix_containers)
self.runner.fail = lambda a: 'migrate' in a
with self.assertRaises(u.UpdateError):
self.run_update()
self.runner.fail = None
self.http.reported_version = '3.0.0'
(self.root / 'docker-compose.yml').write_bytes(self.original_compose)
(self.root / '.env').write_bytes(self.original_env)
with self.assertRaisesRegex(u.UpdateError, 'Matrix-Container und Neustartrichtlinien'):
self.updater.resolve('3.0.0')
self.assertTrue(self.updater.status()['recoveryRequired'])
self.runner.matrix_containers = copy.deepcopy(originals)
for key, value in [('Image', 'wrong-image'), ('HostConfig', {'RestartPolicy': {'Name': 'no'}}),
('State', {'Running': True, 'Health': {'Status': 'unhealthy'}})]:
with self.subTest(key=key):
self.runner.matrix_containers['matrix-db'][key] = value
with self.assertRaisesRegex(u.UpdateError, 'Matrix-Container und Neustartrichtlinien'):
self.updater.resolve('3.0.0')
self.runner.matrix_containers = copy.deepcopy(originals)
self.assertEqual(self.updater.resolve('3.0.0')['status'], 'recovered')
self.assertFalse(self.updater.status()['recoveryRequired'])
def test_success_pins_images_preserves_configuration_and_checks_both_public_services(self):
result = self.run_update()
self.assertEqual(result['status'], 'succeeded')