fix(storage): preserve retired Matrix containers during migration
This commit is contained in:
@@ -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
|
mit ihren Daten kalt gesichert. Andere Zusatzdienste/Compose-Overrides blockieren
|
||||||
den Pilot und benötigen einen angepassten Plan.
|
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
|
Der S3-Prüfer blockiert zusätzliche Buckets, Versionierung einschließlich alter
|
||||||
Versionen/Delete-Marker, Object Lock, konfigurierte Serververschlüsselung,
|
Versionen/Delete-Marker, Object Lock, konfigurierte Serververschlüsselung,
|
||||||
Objekt-/Bucket-Tags, eigene Policies/ACLs, Lifecycle, Replikation, Benachrichtigungen,
|
Objekt-/Bucket-Tags, eigene Policies/ACLs, Lifecycle, Replikation, Benachrichtigungen,
|
||||||
|
|||||||
@@ -121,6 +121,35 @@ def validate_config(config, root):
|
|||||||
return env
|
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:
|
class Migration:
|
||||||
def __init__(self, root, tools_image=None):
|
def __init__(self, root, tools_image=None):
|
||||||
os.umask(0o077)
|
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"),
|
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")))
|
"-f", str(self.root / "docker-compose.yml"), "config", "--format", "json")))
|
||||||
env = validate_config(config, self.root)
|
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"]
|
project = config["name"]
|
||||||
ids = self.docker("ps", "-aq", "--filter", "label=com.docker.compose.project=" + project).split()
|
ids = self.docker("ps", "-aq", "--filter", "label=com.docker.compose.project=" + project).split()
|
||||||
containers = [self.inspect(i) for i in ids]
|
containers = [self.inspect(i) for i in ids]
|
||||||
by_service = {}
|
by_service, retained = index_containers(config, containers)
|
||||||
for c in containers:
|
# Validate retained bind mounts with the same strict path rules as configured
|
||||||
labels = c["Config"].get("Labels", {})
|
# Matrix services. Do not put retired services back into either Compose file.
|
||||||
name = labels.get("com.docker.compose.service")
|
self.engine.current = copy.deepcopy(config)
|
||||||
require(name in config["services"] and name not in by_service and labels.get("com.docker.compose.oneoff", "False").lower() != "true",
|
self.engine.current["services"].update(retained)
|
||||||
"Unbekannte oder doppelte Projektcontainer vorhanden.")
|
self.engine.retired_services = sorted(set(self.engine.current["services"]) & u.LEGACY_MATRIX_SERVICES)
|
||||||
by_service[name] = c
|
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")),
|
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.")
|
"Die bestehende Instanz muss vor der Planung laufen.")
|
||||||
for name in ("minio", "backend", "db"):
|
for name in ("minio", "backend", "db"):
|
||||||
@@ -286,7 +311,8 @@ class Migration:
|
|||||||
"toolsRetainTag": tools_tag,
|
"toolsRetainTag": tools_tag,
|
||||||
"version": env.get("FEDEO_VERSION"), "fingerprint": self.fingerprint(), "writesReleased": False,
|
"version": env.get("FEDEO_VERSION"), "fingerprint": self.fingerprint(), "writesReleased": False,
|
||||||
"backendWorkingDir": by_service["backend"]["Config"].get("WorkingDir") or "/",
|
"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"],
|
"running": c["State"]["Running"], "restart": c["HostConfig"]["RestartPolicy"],
|
||||||
"networks": c["NetworkSettings"]["Networks"]} for name, c in by_service.items()}}
|
"networks": c["NetworkSettings"]["Networks"]} for name, c in by_service.items()}}
|
||||||
# Planning errors must not leave a usable partial state file.
|
# Planning errors must not leave a usable partial state file.
|
||||||
@@ -363,6 +389,8 @@ class Migration:
|
|||||||
for name, c in self.state["containers"].items():
|
for name, c in self.state["containers"].items():
|
||||||
actual = self.inspect(c["id"])
|
actual = self.inspect(c["id"])
|
||||||
require(actual["Image"] == c["image"], "Containeridentität verändert: " + name)
|
require(actual["Image"] == c["image"], "Containeridentität verändert: " + name)
|
||||||
|
if name in self.state.get("retainedServices", []):
|
||||||
|
index_containers({"services": {}}, [actual])
|
||||||
|
|
||||||
def freeze(self):
|
def freeze(self):
|
||||||
self.save("freezing")
|
self.save("freezing")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import tarfile
|
||||||
import uuid
|
import uuid
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -72,6 +73,15 @@ def run(case):
|
|||||||
try:
|
try:
|
||||||
docker(*base, "up", "-d", "--pull", "never", "--wait", "--wait-timeout", "90")
|
docker(*base, "up", "-d", "--pull", "never", "--wait", "--wait-timeout", "90")
|
||||||
seed(root, project, hidden=case == "hidden-prefix")
|
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")
|
migration = m.Migration(root, "fedeo-storage-tools:pilot")
|
||||||
with migration.engine.lock():
|
with migration.engine.lock():
|
||||||
if case == "hidden-prefix":
|
if case == "hidden-prefix":
|
||||||
@@ -85,12 +95,16 @@ def run(case):
|
|||||||
return
|
return
|
||||||
migration.plan()
|
migration.plan()
|
||||||
assert migration.state["count"] == 1
|
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)
|
migration.migrate(True)
|
||||||
assert migration.state["phase"] == "verified"
|
assert migration.state["phase"] == "verified"
|
||||||
assert m.u.read_json(root / ".updates/status.json")["recoveryRequired"]
|
assert m.u.read_json(root / ".updates/status.json")["recoveryRequired"]
|
||||||
# Simulate restart by reconstructing the process and persisted state.
|
# Simulate restart by reconstructing the process and persisted state.
|
||||||
migration = m.Migration(root)
|
migration = m.Migration(root)
|
||||||
if case == "rollback":
|
if case.endswith("rollback"):
|
||||||
migration.rollback()
|
migration.rollback()
|
||||||
assert migration.state["phase"] == "rolled_back"
|
assert migration.state["phase"] == "rolled_back"
|
||||||
assert not m.u.read_json(root / ".updates/status.json").get("recoveryRequired")
|
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")
|
raise AssertionError("rollback after writes must fail")
|
||||||
except m.Error:
|
except m.Error:
|
||||||
pass
|
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)
|
print(json.dumps({"case": case, "result": "passed", "evidence": str(root)}), flush=True)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
print("Failure evidence: " + str(root), flush=True)
|
print("Failure evidence: " + str(root), flush=True)
|
||||||
@@ -122,7 +142,7 @@ def run(case):
|
|||||||
finally:
|
finally:
|
||||||
ids = docker("ps", "-aq", "--filter", "label=com.docker.compose.project=" + project).split()
|
ids = docker("ps", "-aq", "--filter", "label=com.docker.compose.project=" + project).split()
|
||||||
if ids:
|
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()
|
remaining = docker("ps", "-aq", "--filter", "label=io.fedeo.storage-job=" + str(root / ".updates/storage-migration")).split()
|
||||||
if remaining:
|
if remaining:
|
||||||
docker("rm", "-f", *remaining)
|
docker("rm", "-f", *remaining)
|
||||||
@@ -131,5 +151,5 @@ def run(case):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
for case in (sys.argv[1:] or ["hidden-prefix", "rollback", "activate"]):
|
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)
|
run(case)
|
||||||
|
|||||||
@@ -46,6 +46,45 @@ class HostSafetyTests(unittest.TestCase):
|
|||||||
with self.assertRaises(m.Error):
|
with self.assertRaises(m.Error):
|
||||||
m.validate_config(self.config, self.root)
|
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):
|
def test_cold_index_detects_objects_hidden_by_minio_listing(self):
|
||||||
source = self.root / "minio"
|
source = self.root / "minio"
|
||||||
(source / ".minio.sys").mkdir()
|
(source / ".minio.sys").mkdir()
|
||||||
|
|||||||
Reference in New Issue
Block a user