fix(selfhost): retire deprecated Matrix services during updates
This commit is contained in:
@@ -29,6 +29,8 @@ REGISTRY = "git.federspiel.tech/flfeders/fedeo"
|
||||
VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z")
|
||||
FILES = ("release-manifest.json", "docker-compose.selfhost.yml", ".env.example", "selfhost-setup.sh", "CHANGELOG.md")
|
||||
GIB = 1024 ** 3
|
||||
LEGACY_MATRIX_SERVICES = {"matrix-db", "matrix-synapse", "matrix-redis", "matrix-element",
|
||||
"matrix-livekit", "matrix-rtc-jwt", "matrix-turn", "matrix-well-known"}
|
||||
|
||||
|
||||
class UpdateError(Exception):
|
||||
@@ -346,6 +348,18 @@ class Updater:
|
||||
ids = self.compose(False, "ps", "--all", "--quiet").split()
|
||||
self.containers = [self.inspect(container) for container in ids]
|
||||
write_json(self.job / "original-containers.json", self.containers)
|
||||
self.retired_containers = [c for c in self.containers
|
||||
if c["Config"]["Labels"].get("com.docker.compose.service") in self.retired_services]
|
||||
if any(c.get("HostConfig", {}).get("AutoRemove") for c in self.retired_containers):
|
||||
raise UpdateError("Matrix-Container mit automatischer Löschung können nicht zur Wiederherstellung erhalten werden.")
|
||||
for container in self.retired_containers:
|
||||
name = container["Config"]["Labels"]["com.docker.compose.service"]
|
||||
expected = {v["target"]: v["source"] for v in self.current["services"][name].get("volumes", [])}
|
||||
actual = {v["Destination"]: v["Source"] for v in container.get("Mounts", []) if v["Type"] == "bind"}
|
||||
if actual != expected:
|
||||
raise UpdateError(f"{name}: Tatsächliche Matrix-Datenpfade weichen von der Compose-Konfiguration ab.")
|
||||
self.save(retiredServices=self.retired_services,
|
||||
retiredContainerIds=[c["Id"] for c in self.retired_containers])
|
||||
if any(c["State"]["Running"] and c["Config"]["Labels"].get("com.docker.compose.oneoff", "False").lower() == "true" for c in self.containers):
|
||||
raise UpdateError("Ein Einmalcontainer läuft noch; dessen Arbeit zuerst abschließen.")
|
||||
self.running = [c["Config"]["Labels"]["com.docker.compose.service"] for c in self.containers
|
||||
@@ -360,11 +374,14 @@ class Updater:
|
||||
'exec psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT pg_database_size(current_database())"').strip())
|
||||
minio_size = int(self.runner.run(["du", "-sb", str(self.root / "minio")]).split()[0])
|
||||
self.backup_size = database_size * 2 + minio_size + GIB
|
||||
if self.matrix_backup_paths:
|
||||
self.backup_size += int(self.runner.run(["du", "-sb", str(self.root / "matrix")]).split()[0])
|
||||
self.check_space(self.backup_size, self.home)
|
||||
plan = {"version": self.target, "revision": self.manifest["commit"], "previousVersion": old_version,
|
||||
"images": self.images, "composeDiff": str(self.job / "compose.diff"),
|
||||
"releaseNotes": str(self.job / "CHANGELOG.md"), "requiresComposeAdoption": not self.managed,
|
||||
"estimatedBackupBytes": self.backup_size, "services": list(self.candidate["services"])}
|
||||
"estimatedBackupBytes": self.backup_size, "services": list(self.candidate["services"]),
|
||||
"retiredServices": self.retired_services, "retainedDataPaths": self.matrix_backup_paths}
|
||||
write_json(self.job / "plan.json", plan)
|
||||
return plan
|
||||
|
||||
@@ -374,6 +391,8 @@ class Updater:
|
||||
differences = []
|
||||
for label, services in (("Bestehende Konfiguration", old), ("Zielkonfiguration", new)):
|
||||
missing, additional = required - set(services), set(services) - required
|
||||
if services is old:
|
||||
additional -= LEGACY_MATRIX_SERVICES
|
||||
details = []
|
||||
if missing:
|
||||
details.append("fehlend: " + ", ".join(sorted(missing)))
|
||||
@@ -384,6 +403,9 @@ class Updater:
|
||||
if differences:
|
||||
raise UpdateError("Dieser Updater unterstützt den Standard-Selfhost-Stack mit lokalen Daten und MinIO. "
|
||||
+ ". ".join(differences) + ". Zusätzliche Dienste benötigen Unterstützung für Erhalt, Backup und Wiederherstellung.")
|
||||
self.retired_services = sorted(set(old) & LEGACY_MATRIX_SERVICES)
|
||||
self.matrix_backup_paths = []
|
||||
self.validate_legacy_matrix()
|
||||
for name in required:
|
||||
if old[name].get("profiles") or new[name].get("profiles") or new[name].get("build"):
|
||||
raise UpdateError("Build-Kontexte und Compose-Profile werden nicht unterstützt.")
|
||||
@@ -437,7 +459,46 @@ class Updater:
|
||||
if value is None or (value == "" and key in ("DOMAIN", "POSTGRES_PASSWORD", "COOKIE_SECRET", "JWT_SECRET", "ENCRYPTION_KEY")):
|
||||
raise UpdateError(f"Fehlende Umgebungsvariable: {name}.{key}.")
|
||||
|
||||
def validate_legacy_matrix(self):
|
||||
"""Only retire the known, local legacy stack; never silently drop arbitrary services."""
|
||||
if not self.retired_services:
|
||||
return
|
||||
matrix_root = self.root / "matrix"
|
||||
for name in self.retired_services:
|
||||
service = self.current["services"][name]
|
||||
if any(service.get(key) for key in ("profiles", "build", "volumes_from", "configs", "secrets")):
|
||||
raise UpdateError(f"{name}: Abweichende Matrix-Konfiguration benötigt eine manuelle Stilllegung.")
|
||||
volumes = service.get("volumes", [])
|
||||
for volume in volumes:
|
||||
source = Path(volume.get("source", ""))
|
||||
if (volume.get("type") != "bind" or not source.is_relative_to(matrix_root)
|
||||
or source.resolve() != source or not source.exists()):
|
||||
raise UpdateError(f"{name}: Matrix-Daten müssen ohne symbolische Links unter {matrix_root} liegen.")
|
||||
expected = {"matrix-db": ("postgres", "/var/lib/postgresql/data"),
|
||||
"matrix-synapse": ("synapse", "/data")}.get(name)
|
||||
if expected and (not (matrix_root / expected[0]).is_dir() or not any(
|
||||
v.get("source") == str(matrix_root / expected[0]) and v.get("target") == expected[1] for v in volumes)):
|
||||
raise UpdateError(f"{name}: Das lokale Standard-Matrix-Datenverzeichnis fehlt.")
|
||||
if matrix_root.exists() or matrix_root.is_symlink():
|
||||
if matrix_root.is_symlink() or not matrix_root.is_dir():
|
||||
raise UpdateError("Das Matrix-Datenverzeichnis muss ein reguläres lokales Verzeichnis sein.")
|
||||
# A cold PostgreSQL backup must include WAL and tablespaces, not external symlink targets.
|
||||
if any(path.is_symlink() for path in matrix_root.rglob("*")):
|
||||
raise UpdateError("Symbolische Links in Matrix-Daten benötigen eine manuelle Sicherung/Stilllegung.")
|
||||
self.matrix_backup_paths = ["matrix"]
|
||||
|
||||
def verify_matrix_stopped(self, *, retired=False):
|
||||
for container_id in self.state.get("retiredContainerIds", []):
|
||||
container = self.inspect(container_id)
|
||||
state = container.get("State", {})
|
||||
if state.get("Running") or state.get("Restarting") or state.get("Paused"):
|
||||
raise UpdateError("Ein alter Matrix-Container läuft noch; Stilllegung nicht abgeschlossen.")
|
||||
if retired and container.get("HostConfig", {}).get("RestartPolicy", {}).get("Name") != "no":
|
||||
raise UpdateError("Ein alter Matrix-Container könnte automatisch neu starten; Stilllegung nicht abgeschlossen.")
|
||||
|
||||
def backup(self):
|
||||
self.verify_matrix_stopped()
|
||||
self.validate_legacy_matrix()
|
||||
directory = self.job / "backup"
|
||||
directory.mkdir(mode=0o700)
|
||||
for name in (".env", "docker-compose.yml", ".env.example", "scripts/selfhost-setup.sh"):
|
||||
@@ -451,7 +512,7 @@ class Updater:
|
||||
self.compose(False, "exec", "-T", "db", "pg_restore", "--list", input_file=directory / "database.dump")
|
||||
self.compose(False, "exec", "-T", "db", "sh", "-ec",
|
||||
'exec pg_dumpall -U "$POSTGRES_USER" --globals-only', output=directory / "globals.sql")
|
||||
paths = ["minio"]
|
||||
paths = ["minio", *self.matrix_backup_paths]
|
||||
if (self.root / "traefik/letsencrypt").is_dir():
|
||||
paths.append("traefik/letsencrypt")
|
||||
self.runner.run(["tar", "-cpf", str(directory / "files.tar"), "-C", str(self.root), *paths], timeout=7200)
|
||||
@@ -465,7 +526,10 @@ class Updater:
|
||||
digest.update(chunk)
|
||||
os.fsync(stream.fileno())
|
||||
checksums[str(path.relative_to(directory))] = digest.hexdigest()
|
||||
write_json(directory / "complete.json", {"createdAt": now(), "database": "database.dump", "files": "files.tar", "sha256": checksums})
|
||||
self.verify_matrix_stopped()
|
||||
write_json(directory / "complete.json", {"createdAt": now(), "database": "database.dump", "files": "files.tar",
|
||||
"retiredServices": self.retired_services, "retainedContainerIds": self.state["retiredContainerIds"],
|
||||
"matrixDataPaths": self.matrix_backup_paths, "sha256": checksums})
|
||||
self.save("backed_up", backup=str(directory))
|
||||
|
||||
def verify(self, version, revision=None):
|
||||
@@ -498,6 +562,13 @@ class Updater:
|
||||
raise UpdateError("Konfiguration wurde während des Backups verändert; erneut prüfen.")
|
||||
# From here recovery must be explicit: migrations or infrastructure can alter persisted data.
|
||||
self.save("applying", mutationStarted=True)
|
||||
if self.retired_containers:
|
||||
self.save("retiring_matrix")
|
||||
self.verify_matrix_stopped()
|
||||
# Keep containers, writable layers and volumes for recovery; prevent restart on host reboot.
|
||||
for container in self.retired_containers:
|
||||
self.runner.run(["docker", "update", "--restart=no", container["Id"]])
|
||||
self.verify_matrix_stopped(retired=True)
|
||||
atomic(self.root / "docker-compose.yml", (self.job / "target.compose.yml").read_bytes())
|
||||
atomic(self.root / ".env", (self.job / "target.env").read_bytes())
|
||||
atomic(self.root / ".env.example", (self.job / ".env.example").read_bytes())
|
||||
@@ -511,6 +582,7 @@ class Updater:
|
||||
self.save("starting")
|
||||
self.compose(True, "up", "-d", "--wait", "--wait-timeout", "600", timeout=900)
|
||||
self.save("verifying")
|
||||
self.verify_matrix_stopped(retired=True)
|
||||
for name, image in self.images.items():
|
||||
container_id = self.compose(True, "ps", "--quiet", name).strip()
|
||||
container = self.inspect(container_id)
|
||||
@@ -596,10 +668,22 @@ class Updater:
|
||||
if not expected or container.get("Image") != expected:
|
||||
raise UpdateError(f"{name} entspricht weder dem gewählten Zielrelease noch dem gesicherten Originalstand.")
|
||||
if version == manifest["version"]:
|
||||
self.verify_matrix_stopped(retired=True)
|
||||
self.verify(version, manifest["commit"])
|
||||
write_json(self.home / "installed.json", {"manifest": manifest,
|
||||
"composeSha256": sha((self.job / "target.compose.yml").read_bytes()), "installedAt": now()})
|
||||
else:
|
||||
for original in read_json(self.job / "original-containers.json", []):
|
||||
if original.get("Id") not in state.get("retiredContainerIds", []):
|
||||
continue
|
||||
name = original["Config"]["Labels"]["com.docker.compose.service"]
|
||||
container_id = self.compose(False, "ps", "--all", "--quiet", name).strip()
|
||||
restored = self.inspect(container_id) if container_id else {}
|
||||
if (restored.get("Image") != original["Image"]
|
||||
or restored.get("State", {}).get("Running") != original["State"]["Running"]
|
||||
or (original["State"]["Running"] and restored.get("State", {}).get("Health", {}).get("Status") not in (None, "healthy"))
|
||||
or restored.get("HostConfig", {}).get("RestartPolicy") != original["HostConfig"]["RestartPolicy"]):
|
||||
raise UpdateError("Die ursprünglichen Matrix-Container und Neustartrichtlinien müssen zuerst wiederhergestellt werden.")
|
||||
previous = read_json(self.job / "original-installed.json", {})
|
||||
if previous:
|
||||
write_json(self.home / "installed.json", previous)
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user