dev #227

Merged
flfeders merged 4 commits from dev into main 2026-09-23 16:15:45 +00:00
3 changed files with 311 additions and 8 deletions
Showing only changes of commit 847a616285 - Show all commits

View File

@@ -13,7 +13,11 @@
Der Updater verweigert externe Datenbanken, externen S3-/Seafile-Speicher, zusätzliche Dienste, Compose-Profile, geänderte Datenpfade und PostgreSQL-Imagewechsel. Dafür braucht es zunächst passende Backup-/Migrationsadapter. Eigene Einstellungen innerhalb der unterstützten Dienste können in `docker-compose.override.yml` **oder** `docker-compose.override.yaml` bleiben; der Updater verwendet diese Datei bei jedem Compose-Aufruf. Änderungen an dieser Override-Datei werden nicht überschrieben.
Das betrifft auch bestehende Installationen mit zusätzlichen `matrix-*`-Diensten. Das Standard-Backup enthält weder deren Datenbank noch deren Dateispeicher. Diese Dienste nur in eine Override-Datei zu verschieben erweitert die Unterstützung nicht: Der Updater prüft die zusammengeführte Konfiguration. Vor einem Update muss ihr Erhalt einschließlich Daten, Abhängigkeiten und Wiederherstellung unterstützt werden. Die Fehlermeldung nennt fehlende und zusätzliche Dienste jeweils für die bestehende und die Zielkonfiguration. Ein Abbruch mit `mutationStarted: false` und `recoveryRequired: false` benötigt keine Wiederherstellung und keinen `resolve`-Aufruf.
Eine gezielte Ausnahme ist die Stilllegung des **entfernten alten Matrix-Stacks**: `matrix-db`, `matrix-synapse`, `matrix-redis`, `matrix-element`, `matrix-livekit`, `matrix-rtc-jwt`, `matrix-turn` und `matrix-well-known` dürfen in der bestehenden Konfiguration vorkommen. Die Zielkonfiguration muss ausschließlich die sieben FEDEO-Dienste enthalten, auch nach Zusammenführung mit einer Override-Datei. Matrix wird nicht in den neuen Stack übernommen. Andere Zusatzdienste bleiben gesperrt; die Fehlermeldung nennt die konkreten Abweichungen. Ein Abbruch mit `mutationStarted: false` und `recoveryRequired: false` benötigt keine Wiederherstellung und keinen `resolve`-Aufruf.
Für diesen Übergang müssen die Matrix-Daten lokal unter `matrix/` liegen, insbesondere `matrix/postgres` und `matrix/synapse`; symbolische Links, andere konfigurierte Datenpfade, benannte Volumes und spezielle Compose-Konfigurationen benötigen eine manuelle Stilllegung. Die tatsächlichen Bind-Mounts der alten Container müssen zur Compose-Datei passen. Der Plan nennt `retiredServices` und `retainedDataPaths` und berücksichtigt den zusätzlichen Backup-Platz. Alle Matrix-Container werden vor dem Backup gestoppt. Das komplette lokale `matrix/`-Verzeichnis wird bei gestoppter Matrix-Datenbank in `files.tar` gesichert. Erst nach erfolgreichem Backup setzt der Updater die Neustartrichtlinie der alten Matrix-Container auf `no`, damit sie auch nach einem Host-Neustart stillgelegt bleiben. Er löscht weder diese Container noch ihre Daten oder Volumes. Image-seitig angelegte anonyme Volumes und schreibbare Container-Schichten bleiben an den alten Containern erhalten; sie sind nicht Bestandteil von `files.tar`. Die alten Container daher für die Wiederherstellung behalten und nicht mit `--remove-orphans` oder einem Docker-Prune entfernen.
Die Stilllegung vor der Dateisicherung entspricht den Anforderungen an ein [PostgreSQL-Dateisystem-Backup](https://www.postgresql.org/docs/16/backup-file.html); die Container-Richtlinie wird mit [Docker `update --restart`](https://docs.docker.com/reference/cli/docker/container/update/) geändert.
Die Zielversion muss bereits die festen MinIO-Digests und die neuen Backend-/Frontend-Healthchecks enthalten. Ältere Releases bleiben über den in `docs/releases.md` beschriebenen manuellen Ablauf installierbar. Ein Versionswechsel des Betriebssystems oder der Docker Engine gehört nicht zu diesem Ablauf.
@@ -21,6 +25,8 @@ Die Zielversion muss bereits die festen MinIO-Digests und die neuen Backend-/Fro
Neue versionierte Installer laden das Skript und diese Anleitung bereits ins Betriebsverzeichnis und berücksichtigen Python 3 bei den Basispaketen. Bestehende Installationen können `selfhost-update.py` und `SELFHOST-UPDATES.md` aus den Assets eines entsprechenden veröffentlichten Releases übernehmen. Zuerst mit dessen `SHA256SUMS` prüfen, anschließend das Skript unter `/opt/fedeo/scripts/selfhost-update.py` ablegen. Für diese erstmalige Bereitstellung ist noch ein Serverzugriff erforderlich.
Ein bereits heruntergeladener Updater aus Release `3.1.0` enthält die Matrix-Stilllegung noch nicht. Dafür das Hostskript durch den korrigierten Stand aus einem neueren Release oder dem geprüften Repository-Commit ersetzen. Dieser kann weiterhin `3.1.0` als Zielrelease verwenden; dessen veröffentlichte Assets werden nicht verändert. Ein erneuter Download des alten `3.1.0`-Updater-Assets liefert weiterhin den alten Stand. Der Updater ersetzt sich beim Anwenden nicht selbst.
Downloads erfolgen ausschließlich vom konfigurierten FEDEO-Repository über HTTPS. Die Dateiprüfsummen werden gegen `SHA256SUMS`, das Manifest gegen den Git-Tag geprüft. Die Prüfsummen sind **keine unabhängige Signatur**; das Vertrauen liegt beim Gitea-Server und dessen Release-Veröffentlichung. Falls das Repository privat ist, `FEDEO_RELEASE_TOKEN` mit Leserechten nur für den Aufruf setzen. Registry-Anmeldung bei Bedarf vorher über `docker login git.federspiel.tech` im Konto des Updaters durchführen. Der Updater speichert den Release-Token nicht in Statusdateien und gibt ihn bei Weiterleitungen an andere Hosts nicht weiter.
## Prüfen, anwenden und Status lesen
@@ -61,6 +67,7 @@ Während des ersten Ausbaus den Befehl in einer bestehenden persistenten Termina
4. Speicherplatz prüfen: mindestens 5 GiB frei am Docker-Datenverzeichnis vor dem Pull sowie geschätzte Backup-Größe plus Reserve im Betriebsverzeichnis. Images laden, Backup-Platz erneut prüfen.
5. Schreibende Dienste und Proxy anhalten. PostgreSQL bleibt für `pg_dump` aktiv.
6. Konfiguration, Container-Metadaten, PostgreSQL-Dump im Custom-Format, globale PostgreSQL-Rollen, gestoppten MinIO-Dateispeicher und Zertifikate sichern. Dump und TAR auf Lesbarkeit prüfen und SHA-256-Prüfsummen schreiben. `backup/complete.json` markiert ein vollständig geschriebenes Backup; ein Restore-Probelauf auf einer getrennten Instanz wird dadurch nicht ersetzt.
Beim Übergang vom alten Matrix-Stack enthält das TAR zusätzlich `matrix/`. Container-Metadaten enthalten die ursprünglichen Image-IDs, Mounts und Neustartrichtlinien; `complete.json` nennt die stillgelegten Dienste und erhaltenen Container. Vor dem Dateiaustausch wird deren automatischer Neustart deaktiviert. Das Zielrelease enthält keine Matrix-Dienste und keine Backend-Abhängigkeit auf Synapse mehr.
7. Compose-Datei und `.env` einzeln atomar ersetzen. In `.env` wird ausschließlich `FEDEO_VERSION` verändert; `.env.example` und Setup-Skript werden erneuert. Den laufenden Updater ersetzt dieser Vorgang nicht.
8. Infrastruktur starten, Bucket-Einrichtung ausführen und Migrationen explizit in einem benannten Einmalcontainer starten. Beim anschließenden regulären Backend-Start läuft dessen bestehende Migrationsprüfung nochmals; bereits angewendete Migrationen werden dabei übersprungen.
9. Stack mit `up --wait` starten. Backend-/Frontend-Image-IDs und Healthchecks sowie öffentlich gemeldete Version und Commit prüfen. Erst dann den Release als installiert speichern.
@@ -88,6 +95,8 @@ Für die Rückkehr zum alten Stand in einem geplanten Wartungsfenster:
sudo python3 /opt/fedeo/scripts/selfhost-update.py resolve --version 3.0.0
```
Bei Rückkehr zum alten Matrix-Stack dessen ursprüngliche Compose-Datei aus demselben Backup verwenden. Falls nötig, das vollständige `matrix/`-Verzeichnis aus `files.tar` bei gestoppten Matrix-Diensten wiederherstellen, insbesondere den gesamten PostgreSQL-Cluster mit dem ursprünglichen Datenbank-Image. Vorhandene Container-Schichten und anonyme Volumes bleiben an den behaltenen Containern erhalten. Für jeden betroffenen Container die ursprüngliche `HostConfig.RestartPolicy` aus `backup/containers.json` wieder setzen, beispielsweise `docker update --restart=unless-stopped CONTAINER_ID`, wenn genau diese Richtlinie gespeichert wurde. Danach die zuvor laufenden Matrix-Dienste mit der ursprünglichen Compose-Konfiguration starten und prüfen. `resolve` für den alten Stand prüft auch deren Image, Laufzustand und Neustartrichtlinie. `resolve` für den Zielrelease verlangt weiterhin gestoppte Matrix-Container mit deaktiviertem automatischem Neustart. Es führt diese Schritte nicht selbst aus.
`resolve` prüft die angegebene Backend-Version, Backend-Erreichbarkeit, Frontend-Erreichbarkeit, laufende Kerndienste, passende Anwendungs-Images und das Fehlen eines zurückgebliebenen Migrationscontainers. Es stellt selbst **keine Daten wieder her**. Bei alten Entwicklungsinstallationen die tatsächlich von `/backend/version` gemeldete Entwicklungsversion angeben. Ein späteres Update kann nach einer manuellen Compose-Wiederherstellung erneut `--accept-compose-replacement` erfordern.
## Entwicklung und Tests
@@ -97,4 +106,4 @@ npm test
npm run commits:check
```
Die Update-Tests verwenden den echten Compose-Parser und einen simulierten Docker-Daemon. Sie prüfen u. a. Erfolg, Prüfsummen, Release-Auswahl, unveränderte Konfiguration, Speicherplatz, parallele Aufträge, Backupfehler, Migrationsfehler, falsche laufende Versionen und unterbrochene Aufträge. Ein vollständiger Test mit realem PostgreSQL-/MinIO-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich.
Die Update-Tests verwenden den echten Compose-Parser und einen simulierten Docker-Daemon. Sie prüfen u. a. Erfolg, Prüfsummen, Release-Auswahl, unveränderte Konfiguration, Speicherplatz, parallele Aufträge, Backupfehler, Migrationsfehler, falsche laufende Versionen und unterbrochene Aufträge. Die Matrix-Tests prüfen Stilllegung, Daten-/Container-Erhalt, Backup vor Änderung der Neustartrichtlinien, Fehler und Unterbrechungen sowie die weitere Ablehnung unbekannter Dienste. Ein vollständiger Test mit realem PostgreSQL-/MinIO-/Matrix-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich.

View File

@@ -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)

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')