From e9b2e3e229ea4d74a88bdc4618c78386eb43894d Mon Sep 17 00:00:00 2001 From: flfeders Date: Mon, 28 Sep 2026 14:00:27 +0200 Subject: [PATCH] fix(storage): preserve retired Matrix containers during migration --- docs/minio-umstellung-pilot.md | 8 ++++ scripts/selfhost-storage-migrate.py | 54 +++++++++++++++++------ scripts/storage/tests/host_integration.py | 26 +++++++++-- scripts/storage/tests/test_host.py | 39 ++++++++++++++++ 4 files changed, 111 insertions(+), 16 deletions(-) diff --git a/docs/minio-umstellung-pilot.md b/docs/minio-umstellung-pilot.md index 834a6642..f0dea093 100644 --- a/docs/minio-umstellung-pilot.md +++ b/docs/minio-umstellung-pilot.md @@ -51,6 +51,14 @@ Bekannte lokale Matrix-Dienste werden erhalten, während der Kopie angehalten un mit ihren Daten kalt gesichert. Andere Zusatzdienste/Compose-Overrides blockieren den Pilot und benötigen einen angepassten Plan. +Bereits durch ein früheres Update stillgelegte Matrix-Container dürfen weiterhin +zum Compose-Projekt gehören, obwohl sie nicht mehr in dessen Konfiguration stehen. +Der Pilot akzeptiert sie nur gestoppt und mit Neustartrichtlinie `no`, erhält ihre +Container und Images und nimmt sie nicht wieder in Compose auf. Ihre lokalen +Matrix-Bind-Verzeichnisse werden ebenfalls kalt gesichert. Die alten anonymen +Redis-/Coturn-Volumes bleiben unverändert an ihren stillgelegten Containern; +sie gehören nicht zum neuen Speicherdienst oder dessen Restore-Test. + Der S3-Prüfer blockiert zusätzliche Buckets, Versionierung einschließlich alter Versionen/Delete-Marker, Object Lock, konfigurierte Serververschlüsselung, Objekt-/Bucket-Tags, eigene Policies/ACLs, Lifecycle, Replikation, Benachrichtigungen, diff --git a/scripts/selfhost-storage-migrate.py b/scripts/selfhost-storage-migrate.py index 14b4a80b..c529db7e 100644 --- a/scripts/selfhost-storage-migrate.py +++ b/scripts/selfhost-storage-migrate.py @@ -121,6 +121,35 @@ def validate_config(config, root): return env +def index_containers(config, containers): + """Keep previously retired Matrix containers without reactivating their services.""" + indexed, retained = {}, {} + for container in containers: + labels = container["Config"].get("Labels", {}) + name = labels.get("com.docker.compose.service") + require(name and name not in indexed and labels.get("com.docker.compose.oneoff", "False").lower() != "true", + "Unbekannte oder doppelte Projektcontainer vorhanden.") + if name not in config["services"]: + require(name in u.LEGACY_MATRIX_SERVICES, "Unbekannter Projektcontainer: " + name) + state, host = container["State"], container["HostConfig"] + require(not any(state.get(k) for k in ("Running", "Restarting", "Paused")) and + host.get("RestartPolicy", {}).get("Name") == "no" and not host.get("AutoRemove"), + "Alter Matrix-Container ist nicht dauerhaft stillgelegt: " + name) + volumes = [] + for mount in container.get("Mounts", []): + if mount["Type"] == "bind": + volumes.append({"type": "bind", "source": mount["Source"], "target": mount["Destination"]}) + else: + # These image-declared volumes belong to already retired services. + # Preserve their containers/volumes; never remove or restart them. + require(mount["Type"] == "volume" and (name, mount["Destination"]) in { + ("matrix-redis", "/data"), ("matrix-turn", "/var/lib/coturn")}, + "Abweichender Datenmount eines alten Matrix-Containers: " + name) + retained[name] = {"volumes": volumes} + indexed[name] = container + return indexed, retained + + class Migration: def __init__(self, root, tools_image=None): os.umask(0o077) @@ -241,21 +270,17 @@ class Migration: config = resolved(json.loads(self.docker("compose", "--project-directory", str(self.root), "--env-file", str(self.root / ".env"), "-f", str(self.root / "docker-compose.yml"), "config", "--format", "json"))) env = validate_config(config, self.root) - # Reuse the updater's strict legacy Matrix path validation; the services remain intact. - self.engine.current = config - self.engine.retired_services = sorted(set(config["services"]) & u.LEGACY_MATRIX_SERVICES) - self.engine.matrix_backup_paths = [] - self.engine.validate_legacy_matrix() project = config["name"] ids = self.docker("ps", "-aq", "--filter", "label=com.docker.compose.project=" + project).split() containers = [self.inspect(i) for i in ids] - by_service = {} - for c in containers: - labels = c["Config"].get("Labels", {}) - name = labels.get("com.docker.compose.service") - require(name in config["services"] and name not in by_service and labels.get("com.docker.compose.oneoff", "False").lower() != "true", - "Unbekannte oder doppelte Projektcontainer vorhanden.") - by_service[name] = c + by_service, retained = index_containers(config, containers) + # Validate retained bind mounts with the same strict path rules as configured + # Matrix services. Do not put retired services back into either Compose file. + self.engine.current = copy.deepcopy(config) + self.engine.current["services"].update(retained) + self.engine.retired_services = sorted(set(self.engine.current["services"]) & u.LEGACY_MATRIX_SERVICES) + self.engine.matrix_backup_paths = [] + self.engine.validate_legacy_matrix() require(all(by_service.get(n, {}).get("State", {}).get("Running") for n in ("db", "minio", "backend", "frontend", "traefik")), "Die bestehende Instanz muss vor der Planung laufen.") for name in ("minio", "backend", "db"): @@ -286,7 +311,8 @@ class Migration: "toolsRetainTag": tools_tag, "version": env.get("FEDEO_VERSION"), "fingerprint": self.fingerprint(), "writesReleased": False, "backendWorkingDir": by_service["backend"]["Config"].get("WorkingDir") or "/", - "matrixPaths": self.engine.matrix_backup_paths, "containers": {name: {"id": c["Id"], "image": c["Image"], + "matrixPaths": self.engine.matrix_backup_paths, "retainedServices": sorted(retained), + "containers": {name: {"id": c["Id"], "image": c["Image"], "running": c["State"]["Running"], "restart": c["HostConfig"]["RestartPolicy"], "networks": c["NetworkSettings"]["Networks"]} for name, c in by_service.items()}} # Planning errors must not leave a usable partial state file. @@ -363,6 +389,8 @@ class Migration: for name, c in self.state["containers"].items(): actual = self.inspect(c["id"]) require(actual["Image"] == c["image"], "Containeridentität verändert: " + name) + if name in self.state.get("retainedServices", []): + index_containers({"services": {}}, [actual]) def freeze(self): self.save("freezing") diff --git a/scripts/storage/tests/host_integration.py b/scripts/storage/tests/host_integration.py index 93db013c..07aaf45d 100644 --- a/scripts/storage/tests/host_integration.py +++ b/scripts/storage/tests/host_integration.py @@ -6,6 +6,7 @@ import os from pathlib import Path import subprocess import tempfile +import tarfile import uuid import sys @@ -72,6 +73,15 @@ def run(case): try: docker(*base, "up", "-d", "--pull", "never", "--wait", "--wait-timeout", "90") seed(root, project, hidden=case == "hidden-prefix") + retained = [] + if case.startswith("retired-"): + (root / "matrix/postgres").mkdir(parents=True) + (root / "matrix/postgres/retained.txt").write_text("retired matrix data") + for service, mount in (("matrix-db", str(root / "matrix/postgres") + ":/var/lib/postgresql/data"), + ("matrix-redis", "/data")): + retained.append(docker("create", "--pull", "never", "--restart=no", "--network", project + "-internal", + "--label", "com.docker.compose.project=" + project, "--label", "com.docker.compose.service=" + service, + "-v", mount, "node:22-alpine", "node", "-e", "process.exit(0)")) migration = m.Migration(root, "fedeo-storage-tools:pilot") with migration.engine.lock(): if case == "hidden-prefix": @@ -85,12 +95,16 @@ def run(case): return migration.plan() assert migration.state["count"] == 1 + if retained: + assert migration.state["retainedServices"] == ["matrix-db", "matrix-redis"] + assert migration.state["matrixPaths"] == ["matrix"] + assert not set(migration.state["retainedServices"]) & m.u.read_json(migration.home / "target.json")["services"].keys() migration.migrate(True) assert migration.state["phase"] == "verified" assert m.u.read_json(root / ".updates/status.json")["recoveryRequired"] # Simulate restart by reconstructing the process and persisted state. migration = m.Migration(root) - if case == "rollback": + if case.endswith("rollback"): migration.rollback() assert migration.state["phase"] == "rolled_back" assert not m.u.read_json(root / ".updates/status.json").get("recoveryRequired") @@ -107,6 +121,12 @@ def run(case): raise AssertionError("rollback after writes must fail") except m.Error: pass + for cid in retained: + old = migration.inspect(cid) + assert not old["State"]["Running"] and old["HostConfig"]["RestartPolicy"]["Name"] == "no" + if retained: + with tarfile.open(migration.home / "backup/files.tar") as backup: + assert backup.extractfile("matrix/postgres/retained.txt").read() == b"retired matrix data" print(json.dumps({"case": case, "result": "passed", "evidence": str(root)}), flush=True) except BaseException: print("Failure evidence: " + str(root), flush=True) @@ -122,7 +142,7 @@ def run(case): finally: ids = docker("ps", "-aq", "--filter", "label=com.docker.compose.project=" + project).split() if ids: - docker("rm", "-f", *ids) + docker("rm", "-f", "-v", *ids) remaining = docker("ps", "-aq", "--filter", "label=io.fedeo.storage-job=" + str(root / ".updates/storage-migration")).split() if remaining: docker("rm", "-f", *remaining) @@ -131,5 +151,5 @@ def run(case): if __name__ == "__main__": for case in (sys.argv[1:] or ["hidden-prefix", "rollback", "activate"]): - assert case in ("hidden-prefix", "rollback", "activate") + assert case in ("hidden-prefix", "rollback", "activate", "retired-rollback", "retired-activate") run(case) diff --git a/scripts/storage/tests/test_host.py b/scripts/storage/tests/test_host.py index 276d9e61..232241e0 100644 --- a/scripts/storage/tests/test_host.py +++ b/scripts/storage/tests/test_host.py @@ -46,6 +46,45 @@ class HostSafetyTests(unittest.TestCase): with self.assertRaises(m.Error): m.validate_config(self.config, self.root) + def test_retired_matrix_remains_stopped_and_outside_compose(self): + container = {"Config": {"Labels": {"com.docker.compose.service": "matrix-redis"}}, + "State": {"Running": False}, "HostConfig": {"RestartPolicy": {"Name": "no"}}, + "Mounts": [{"Type": "volume", "Source": "/var/lib/docker/volumes/old/_data", "Destination": "/data"}]} + indexed, retained = m.index_containers(self.config, [container]) + self.assertEqual(set(indexed), {"matrix-redis"}) + self.assertEqual(retained, {"matrix-redis": {"volumes": []}}) + self.assertNotIn("matrix-redis", self.config["services"]) + mutations = [lambda c: c["State"].update(Running=True), + lambda c: c["State"].update(Restarting=True), + lambda c: c["State"].update(Paused=True), + lambda c: c["HostConfig"]["RestartPolicy"].update(Name="unless-stopped"), + lambda c: c["HostConfig"].update(AutoRemove=True), + lambda c: c["Config"]["Labels"].update({"com.docker.compose.service": "unknown"}), + lambda c: c["Config"]["Labels"].update({"com.docker.compose.oneoff": "True"}), + lambda c: c["Mounts"][0].update(Destination="/unexpected")] + for mutate in mutations: + changed = copy.deepcopy(container) + mutate(changed) + with self.assertRaises(m.Error): + m.index_containers(self.config, [changed]) + with self.assertRaises(m.Error): + m.index_containers(self.config, [container, container]) + + def test_retired_matrix_bind_mounts_keep_strict_backup_validation(self): + container = {"Config": {"Labels": {"com.docker.compose.service": "matrix-db"}}, + "State": {"Running": False}, "HostConfig": {"RestartPolicy": {"Name": "no"}}, + "Mounts": [{"Type": "bind", "Source": "/outside/postgres", "Destination": "/var/lib/postgresql/data"}]} + migration = m.Migration(self.root) + _, retained = m.index_containers(self.config, [container]) + migration.engine.current = {"services": retained} + migration.engine.retired_services = list(retained) + with self.assertRaises(m.Error): + migration.engine.validate_legacy_matrix() + (self.root / "matrix/postgres").mkdir(parents=True) + retained["matrix-db"]["volumes"][0]["source"] = str(self.root / "matrix/postgres") + migration.engine.validate_legacy_matrix() + self.assertEqual(migration.engine.matrix_backup_paths, ["matrix"]) + def test_cold_index_detects_objects_hidden_by_minio_listing(self): source = self.root / "minio" (source / ".minio.sys").mkdir()