diff --git a/backend/src/modules/selfhost-updater.ts b/backend/src/modules/selfhost-updater.ts index 6518eeae..5bf3c7b1 100644 --- a/backend/src/modules/selfhost-updater.ts +++ b/backend/src/modules/selfhost-updater.ts @@ -36,6 +36,7 @@ const state = z.object({ const plan = state.omit({ status: true }).extend({ updateAvailable: z.boolean(), requiresComposeAdoption: z.boolean(), estimatedBackupBytes: z.number().nonnegative().finite().optional(), + storageProvider: z.enum(["minio", "seaweedfs"]).optional(), storageImageRetained: z.boolean().optional(), retiredServices: z.array(z.enum(["matrix-db", "matrix-synapse", "matrix-redis", "matrix-element", "matrix-livekit", "matrix-rtc-jwt", "matrix-turn", "matrix-well-known"])), }); @@ -48,6 +49,7 @@ const job = state.extend({ plan: plan.optional(), progress: state.optional(), const status = z.object({ apiVersion: z.literal(1), acceptingRequests: z.boolean(), engine: state, installed: state.pick({ version: true, revision: true }), request: job.optional(), capabilities: z.object({ requestLogs: z.boolean() }).optional(), + storage: z.object({ provider: z.literal("seaweedfs"), migrationPending: z.boolean() }).optional(), }); const logs = z.object({ requestId, logs: z.array(z.object({ source: z.enum(["service", "events", "commands"]), content: z.string().max(6000), truncated: z.boolean(), diff --git a/backend/src/modules/system-status.service.ts b/backend/src/modules/system-status.service.ts index c1f41a7d..094d6aa4 100644 --- a/backend/src/modules/system-status.service.ts +++ b/backend/src/modules/system-status.service.ts @@ -1,4 +1,7 @@ import { FastifyInstance } from "fastify" +import { HeadBucketCommand } from "@aws-sdk/client-s3" +import { s3 } from "../utils/s3" +import { secrets } from "../utils/secrets" type MetricSample = { labels: Record @@ -10,9 +13,6 @@ const metricLinePattern = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+(-?(?:\ const nodeExporterUrl = () => (process.env.NODE_EXPORTER_URL || "http://node-exporter:9100").replace(/\/+$/, "") -const s3EndpointUrl = () => - (process.env.S3_ENDPOINT || "").replace(/\/+$/, "") - const parseLabels = (value = "") => { const labels: Record = {} const labelPattern = /(\w+)="((?:\\"|[^"])*)"/g @@ -63,20 +63,17 @@ const serviceState = (ok: boolean, detail?: Record) => ({ ...detail, }) -const checkHttp = async (url: string, timeoutMs = 3000) => { +export const checkFileStorage = async (client = s3, bucket = secrets.S3_BUCKET, timeoutMs = 3000) => { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), timeoutMs) try { - const response = await fetch(url, { signal: controller.signal }) - return serviceState(response.ok, { - httpStatus: response.status, - url, - }) - } catch (err: any) { + if (!client || !bucket) return serviceState(false, { error: "S3-Dateispeicher ist nicht eingerichtet" }) + await client.send(new HeadBucketCommand({ Bucket: bucket }), { abortSignal: controller.signal }) + return serviceState(true, { provider: process.env.FEDEO_STORAGE_PROVIDER === "seaweedfs" ? "seaweedfs" : "s3" }) + } catch { return serviceState(false, { - url, - error: err?.message || "HTTP-Abfrage fehlgeschlagen", + error: controller.signal.aborted ? "S3-Prüfung hat das Zeitlimit überschritten" : "S3-Bucket nicht erreichbar oder Zugriff verweigert", }) } finally { clearTimeout(timeout) @@ -116,7 +113,6 @@ export const buildSystemStatus = async (server: FastifyInstance) => { const uname = nodeMetrics?.get("node_uname_info")?.[0]?.labels || null const databaseCheck = await server.db.execute("SELECT NOW() as now") - const minioUrl = s3EndpointUrl() return { checkedAt: checkedAt.toISOString(), @@ -160,9 +156,8 @@ export const buildSystemStatus = async (server: FastifyInstance) => { url: nodeExporterMetricsUrl, error: nodeExporterError, }), - minio: minioUrl ? await checkHttp(`${minioUrl}/minio/health/live`) : serviceState(false, { - error: "S3_ENDPOINT ist nicht gesetzt", - }), + // Preserve the response key for older clients; check the authenticated S3 API for every provider. + minio: await checkFileStorage(), }, } } diff --git a/backend/tests/selfhostUpdates.test.ts b/backend/tests/selfhostUpdates.test.ts index e21e4a6a..1f217f76 100644 --- a/backend/tests/selfhostUpdates.test.ts +++ b/backend/tests/selfhostUpdates.test.ts @@ -128,6 +128,20 @@ test("Unix client bounds response size, deadline, and protocol shape", async t = }); } }); +test("provider status and plan cross the socket boundary without private storage configuration", async t => { + const client = await socketFixture(t, (_req, res) => res.end(JSON.stringify({ + apiVersion: 1, acceptingRequests: true, engine: { status: "recovered", phase: "resolved" }, installed: {}, + storage: { provider: "seaweedfs", migrationPending: false, service: { secret: "hidden" } }, + request: { status: "checked", plan: { updateAvailable: true, requiresComposeAdoption: false, retiredServices: [], + storageProvider: "seaweedfs", storageImageRetained: true, storageBackupPaths: ["/private/path"] } }, + }))); + const { body } = await client("GET", "/v1/status") as { body: any }; + assert.deepEqual(body.storage, { provider: "seaweedfs", migrationPending: false }); + assert.equal(body.request.plan.storageProvider, "seaweedfs"); + assert.equal(body.request.plan.storageImageRetained, true); + assert.ok(!JSON.stringify(body).includes("hidden")); + assert.ok(!JSON.stringify(body).includes("private/path")); +}); test("Unix client validates bounded log responses and their request association", async t => { let payload: any = { requestId: id, logs: [{ source: "commands", content: "pull access denied", truncated: true, path: "/private/commands.log" }], private: "omitted" }; diff --git a/backend/tests/systemStatusStorage.test.ts b/backend/tests/systemStatusStorage.test.ts new file mode 100644 index 00000000..e2e5b443 --- /dev/null +++ b/backend/tests/systemStatusStorage.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { HeadBucketCommand } from "@aws-sdk/client-s3"; +import { checkFileStorage } from "../src/modules/system-status.service"; + +test("file storage health uses an authenticated S3 bucket request for every provider", async () => { + const client = { send: async (command: any, options: any) => { + assert.ok(command instanceof HeadBucketCommand); + assert.deepEqual(command.input, { Bucket: "fedeo" }); + assert.ok(options.abortSignal instanceof AbortSignal); + } }; + assert.equal((await checkFileStorage(client, "fedeo")).ok, true); +}); + +test("unconfigured or denied S3 access is unhealthy and does not disclose SDK details", async () => { + assert.equal((await checkFileStorage(null, "fedeo")).ok, false); + const result = await checkFileStorage({ send: async () => { throw new Error("secret@private-host"); } }, "fedeo"); + assert.equal(result.ok, false); + assert.ok(!JSON.stringify(result).includes("private-host")); +}); + +test("S3 health aborts a hung request at its deadline", async () => { + const client = { send: (_command: any, options: any) => new Promise((_resolve, reject) => { + options.abortSignal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }) }; + const result = await checkFileStorage(client, "fedeo", 10); + assert.equal(result.ok, false); + assert.match(result.error, /Zeitlimit/); +}); diff --git a/docs/minio-umstellung-pilot.md b/docs/minio-umstellung-pilot.md index 2cd28083..a89c0ccb 100644 --- a/docs/minio-umstellung-pilot.md +++ b/docs/minio-umstellung-pilot.md @@ -27,19 +27,20 @@ Die fachliche Abnahme erfolgt zusätzlich auf der konkreten Instanz. und die unveränderte Anwendung wieder starten. **Der Pilot ist noch kein neuer Standardinstaller und kein reguläres -FEDEO-Release.** Der bisherige Anwendungs-Updater kennt nur MinIO. Seine Aufträge -bleiben nach der Umstellung bewusst über `recoveryRequired` gesperrt, bis ein -getesteter Provider-Adapter installiert wird. Diesen Zustand nicht mit dem alten +FEDEO-Release.** Ältere Anwendungs-Updater kennen nur MinIO. Ihre Aufträge +bleiben nach der Umstellung bewusst über `recoveryRequired` gesperrt. Dieser +Entwicklungsstand enthält den SeaweedFS-Adapter und eine ausdrücklich geprüfte +Übergabe über `adopt-updater` (unten). Diesen Zustand nicht mit dem alten `resolve` oder durch Löschen der Statusdatei aufheben. Ein alter Installer oder ein altes Release-Compose darf die umgestellte Installation nicht überschreiben. FEDEO 3.3.0 kann im Systemstatus weiterhin den MinIO-spezifischen Health-Endpunkt abfragen und dadurch den Dateispeicher als gestört anzeigen. Die tatsächliche -Funktionsprüfung erfolgt im Pilot über signierte S3-Operationen. Herstellerneutrale -Oberflächenchecks, eine Migrationskarte in der Oberfläche, automatische Updates -auf SeaweedFS und der Standardinstaller sind nachfolgende Arbeitspakete. -Wer diese Einschränkungen noch nicht im Betrieb tragen möchte, führt zunächst -nur die Vorprüfung durch und bleibt mit der Instanz auf MinIO. +Funktionsprüfung erfolgt im Pilot über signierte S3-Operationen. Dieser +Entwicklungsstand prüft den angemeldeten S3-Bucket auch im Systemstatus; dafür +ist anschließend ein Anwendungsrelease mit dieser Änderung erforderlich. +Der Speicherwechsel selbst bleibt eine eigene betreute Hostaktion. Der bisherige +Standardinstaller wird durch die Migration nicht ersetzt. ### Warum das Pilotimage eine gezielte Korrektur enthält @@ -230,6 +231,47 @@ demselben Host anschließend auf das vorgesehene externe Backupziel übernehmen. MinIO-Daten, alte Container, Test-Restores und Offline-Images werden vom Pilot nicht automatisch gelöscht. +## An den regulären Updater übergeben + +Erst nach `phase: complete`, erfolgreicher Anmeldung und fachlicher Prüfung eines +bestehenden Dokuments ausführen. Die Übergabe stoppt keine FEDEO-Container: + +```sh +sudo python3 pilot.py adopt-updater --directory /opt/fedeo +sudo python3 /usr/local/lib/fedeo-updater/selfhost-update.py check --directory /opt/fedeo +``` + +Das neu erstellte Paket enthält die vier zusammengehörigen Updater-Dienstdateien +mit Prüfsummen. `adopt-updater` installiert zuerst diesen Host-Dienst und ruft +danach `adopt-storage` auf. Damit wird die alte Sperre erst nach Installation des +passenden Updaters freigegeben. Bereits ausgegebene Pilotarchive bleiben unverändert; +für eine schon umgestellte Instanz ein neues geprüftes Paket bereitstellen. +**Dort nur `adopt-updater` ausführen, nicht erneut planen, kopieren oder aktivieren.** +Für diese reine Übergabe ist kein erneutes Laden der Images erforderlich. + +Die Übernahme verlangt den abgeschlossenen Migrationszustand einschließlich +Schreibfreigabe, vollständigem Kopiernachweis, erfolgreicher S3-Abnahmen und +prüfsummengleicher Restore-Sicherung. Compose und `.env` müssen dem Pilotstand +entsprechen. Sie prüft außerdem die tatsächlich laufenden Anwendungsimages, +SeaweedFS-Image, lokale Mounts, Konfigurationsdateien und internen S3-Alias sowie +das gestoppte und getrennte ursprüngliche MinIO. Ein aktueller Lesezugriff erfolgt +mit dem installierten Backend-SDK. Fehler lassen die Updatesperre bestehen. + +Danach sind `check` und `apply` über den Host-Dienst und die bestehende Oberfläche +möglich. Der erste Prüfplan benötigt keine pauschale Compose-Übernahmefreigabe. +Der Adapter erhält SeaweedFS samt Konfiguration, Datenpfaden, Zugangsdaten und +dem während der Migration festgehaltenen PostgreSQL-Image. Er entfernt die alten +MinIO-Dienste aus dem heruntergeladenen Release-Compose, bevor Images geladen +werden. Ein Anwendungsupdate lädt oder startet daher weder MinIO noch `mc`. +Ein Wechsel der SeaweedFS- oder PostgreSQL-Version ist ein separater Vorgang. + +Vor jeder Datenbankschemamigration prüft das **Zielbackend-SDK** Schreiben, +Lesen, MIME-Erhalt, Streaming, signierte Links, anonymen Zugriffsschutz und +Löschen am aktiven Speicher. Ein Fehler hält die Anwendung angehalten und +bewahrt den Wiederherstellungszustand. Die normale Anwendungsupdatesicherung +enthält `storage/`, die Speicherbeschreibung und das exakte SeaweedFS-Image. +Wiederherstellung und Grenzen stehen in [Selfhost-Updates](selfhost-updates.md). + ## Nachgewiesene Tests und ausstehende Freigaben Der lokale S3-Test verwendet das offizielle MinIO-Release-Binary vom 7. September @@ -249,7 +291,10 @@ Die Host-Integrationstests verwenden echte Speicher- und PostgreSQL-Container, aber künstliche Anwendungsprozesse. Rückkehr vor Schreibfreigabe sowie vollständige Umschaltung mit Restore und gesperrter Rückkehr nach Schreibfreigabe wurden erfolgreich durchlaufen. Die fachliche Abnahme von FEDEO auf der -konkreten Instanz, ein physischer Hostneustart während jeder Phase, das reguläre -Anwendungsupdate auf SeaweedFS und die vollständige Wiederherstellung auf einem -anderen Host sind damit noch nicht nachgewiesen. Deshalb bleibt dieser Stand ein +konkreten Instanz, ein physischer Hostneustart während jeder Phase und die +vollständige Wiederherstellung auf einem anderen Host sind damit noch nicht +nachgewiesen. Der zusätzliche Test `scripts/update/tests/host_storage_integration.py` +prüft Migration, Updater-Übernahme, zwei Anwendungsupdates und Wiederherstellung +aus einer regulären Updatesicherung mit echten Speicher-/Datenbankcontainern +und künstlichen Anwendungsimages. Deshalb bleibt der Speicherwechsel ein betreuter Pilot; die allgemeinen Freigabekriterien des Konzepts gelten weiter. diff --git a/docs/selfhost-updates.md b/docs/selfhost-updates.md index d8e1fd30..ae13add7 100644 --- a/docs/selfhost-updates.md +++ b/docs/selfhost-updates.md @@ -6,20 +6,75 @@ - Linux/amd64, Python ab 3.10, Docker Compose ab 2.24, `tar` und `du`. - Bestehendes Betriebsverzeichnis, standardmäßig `/opt/fedeo`, mit `.env` und `docker-compose.yml`; kein Git-Checkout. -- Standard-Stack mit `traefik`, `db`, `minio`, `createbuckets`, `backend`, `frontend` und `node-exporter`. PostgreSQL und MinIO verwenden die lokalen Verzeichnisse `postgres/` und `minio/`. +- Standard-Stack mit `traefik`, `db`, `minio`, `createbuckets`, `backend`, `frontend` und `node-exporter`. PostgreSQL und MinIO verwenden die lokalen Verzeichnisse `postgres/` und `minio/`. Nach geprüfter Pilotübernahme ersetzt `objectstore` (SeaweedFS) die beiden MinIO-Dienste; dessen Daten liegen unter `storage/`. - Als root auf dem Server ausführen: Docker-Zugriff und Leserechte für die Daten- und Zertifikatsverzeichnisse werden benötigt. Ein entfernter Docker-Daemon wird abgewiesen. - Die öffentliche HTTPS-Adresse muss vom Host erreichbar sein und ein gültiges Zertifikat haben. - Eine Wartungsunterbrechung für alle Mandanten einplanen. Vor dem Backup werden alle laufenden Dienste außer PostgreSQL angehalten, auch der öffentliche Proxy und alle im Backend laufenden Hintergrundaufgaben. Eine separate Wartungsseite folgt mit der späteren Oberflächenintegration. 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. -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. +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 FEDEO-Dienste des jeweiligen Speicherproviders enthalten, auch nach Zusammenführung mit einer Override-Datei. Matrix wird nicht in den neuen Stack übernommen. Bereits stillgelegte Matrix-Container bleiben erhalten und ihre lokalen Bind-Verzeichnisse werden weiter gesichert. 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. +Die Zielversion muss die Backend-/Frontend-Healthchecks enthalten; bei weiterhin aktivem MinIO zusätzlich feste MinIO-/mc-Digests. Ä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. + +## Bereits auf SeaweedFS umgestellte Instanzen + +Speicherumstellung und Anwendungsupdate bleiben getrennte Wartungsschritte. +Für weitere Installer-Instanzen zuerst den [Migrationsablauf](minio-umstellung-pilot.md) +mit Kopie, vollständiger Prüfung und echtem Restore durchführen. Er verwendet +die vorhandenen MinIO-Images und benötigt keinen funktionierenden MinIO-Download. +Der normale `apply`-Befehl startet keine verdeckte Speichermigration. +Der neue Standardinstaller und Setup-Assistent erkennen vorbereitete bzw. +durchgeführte Speicherumstellungen und verweigern dort das Überschreiben der +Konfiguration oder ein pauschales Entfernen des Stacks. Alte Skriptstände besitzen +diesen Schutz nicht und dürfen auf umgestellten Instanzen nicht mehr ausgeführt werden. + +Nach Installation der **vier zusammengehörigen neuen Host-Dienstdateien** übernimmt +der neue Updater einen vollständig abgeschlossenen Pilot: + +```sh +sudo python3 /usr/local/lib/fedeo-updater/selfhost-update.py adopt-storage --directory /opt/fedeo +sudo python3 /usr/local/lib/fedeo-updater/selfhost-update.py check --directory /opt/fedeo +``` + +Das neue Pilotpaket verbindet Dienstinstallation und Übernahme als `pilot.py +adopt-updater`. Die Übernahme verändert keine Anwendungsimages und stoppt keine +FEDEO-Dienste. Sie prüft Migrations-/Restore-Nachweise, Originalzustand der +Anwendung, aktiven Speicher und S3-Lesezugriff und schreibt erst dann +`.updates/storage-provider.json`. Die alte Sperre wird als letzter Schritt +freigegeben. Bei Abbruch kann derselbe Befehl wiederholt werden. `resolve` +verweigert ausdrücklich die Freigabe einer Speichermigration. +Bei Installation aus dem Pilotpaket die CLI unter `/usr/local/lib/fedeo-updater/` +verwenden. Eventuell noch vorhandene ältere Kopien unter `/opt/fedeo/scripts/` +werden durch die Dienstinstallation nicht ersetzt. + +Weitere Anwendungsupdates erzeugen das Release-Compose mit dem übernommenen +SeaweedFS-Dienst. Image, Mounts, Startkonfiguration, interner Alias und Zugangsdaten +bleiben erhalten; MinIO und `mc` werden weder geladen noch gestartet. Das bei der +Pilotmigration festgehaltene PostgreSQL-Image bleibt ebenfalls erhalten. Eine +geänderte PostgreSQL-Referenz im Release blockiert die Anwendung bis zur separaten +Datenbankmigration. Ein Anwendungsupdate aktualisiert **nicht** die SeaweedFS-Version. + +Das Backup enthält den Datenbankdump und die bei gestopptem SeaweedFS gesicherte +vollständige Ablage `storage/` in `files.tar`, zusätzlich `storage-image.tar` und +`config/.updates/storage-provider.json`. Der Platzbedarf berücksichtigt das Image. +Vor den SQL-Migrationen wird das neue Backendimage mit seinem tatsächlichen SDK +gegen S3 geprüft (PUT/HEAD/GET/DELETE, Streaming, MIME, 24-Stunden-Signatur und +anonymer Zugriffsschutz). Ein unterbrochener Test protokolliert seinen eindeutigen +Prüfschlüssel; `resolve` bereinigt ausschließlich diesen mit passender Prüfmetadatenmarkierung, +nachdem zurückgebliebene Prüfcontainer entfernt wurden und die Anwendung wieder +erreichbar ist. Unbekannte Dateien werden nicht gelöscht. + +Die Oberfläche neuer FEDEO-Releases zeigt den erkannten Speicher im Updateplan +und unterscheidet die ausstehende Speicherübergabe von einem fehlgeschlagenen +Anwendungsupdate. Ältere Backends können den neuen Host-Dienst weiterhin nutzen; +zusätzliche Planinformationen werden dort ausgeblendet. Nach einem Apprelease +mit dem neuen Systemstatus ersetzt ein authentifizierter S3-Bucket-Check den +MinIO-spezifischen Health-Endpunkt. ## Bereitstellung @@ -162,7 +217,7 @@ Aufträge liegen dauerhaft und root-lesbar unter `.updates/service/`. Nach einem 3. Aktuelle und neue Compose-Konfiguration mit Docker Compose validieren. Projektname, Container-Identitäten, Mounts, Netzwerke und Zugangsdaten erhalten. Die Shell-Umgebung überschreibt dabei keine Werte aus der Instanz-`.env`. 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 mit exakt gepinntem SHA-256-Digest nur laden, wenn sie lokal fehlen (`pull --policy missing`); vorhandene Tags ohne passenden Digest reichen dafür nicht aus. Bewegliche Infrastruktur-Tags weiterhin aktualisieren (`pull --policy always`). Ein fehlendes, nicht herunterladbares Image beendet den Auftrag vor der Wartung. Backup-Platz erneut prüfen. Während der anschließenden Infrastrukturstarts und Migrationen erfolgt kein weiterer Pull (`up`/`run --pull never`), auch nicht bei einer abweichenden Pull-Policy in einer Override-Datei. 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. +6. Konfiguration, Container-Metadaten, PostgreSQL-Dump im Custom-Format, globale PostgreSQL-Rollen, den gestoppten Dateispeicher und Zertifikate sichern. Bei SeaweedFS gehören dessen vollständige Ablage, Konfiguration und Image dazu. 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. @@ -185,7 +240,7 @@ Für die Rückkehr zum alten Stand in einem geplanten Wartungsfenster: 1. Alle schreibenden Dienste und den Proxy angehalten lassen; keinen neuen Update-Auftrag starten. 2. Prüfsummen aus `backup/complete.json` prüfen. Ursprüngliche Compose-Datei, `.env` und gegebenenfalls Override aus `backup/config/` wiederherstellen. `backup/containers.json` dokumentiert die ursprünglichen Image-IDs; insbesondere bewegliche Infrastruktur-Tags vor dem Start auf die gesicherten Image-Stände festlegen. -3. Falls Migrationen/Infrastrukturänderungen bereits begonnen haben: PostgreSQL und Dateispeicher aus dem **zusammengehörigen** Backup wiederherstellen. Der PostgreSQL-Dump ist für `pg_restore` geeignet; `globals.sql` enthält Rollen und Kennwörter und muss beim Wiederaufbau der Datenbank berücksichtigt werden. MinIO und Zertifikate bei gestoppten Diensten aus `files.tar` wiederherstellen. Die konkrete Rücksicherung bestehender Daten erfolgt bewusst manuell und ersetzt Daten. +3. Falls Migrationen/Infrastrukturänderungen bereits begonnen haben: PostgreSQL und Dateispeicher aus dem **zusammengehörigen** Backup wiederherstellen. Der PostgreSQL-Dump ist für `pg_restore` geeignet; `globals.sql` enthält Rollen und Kennwörter und muss beim Wiederaufbau der Datenbank berücksichtigt werden. MinIO bzw. die vollständige SeaweedFS-Ablage `storage/` und Zertifikate bei gestoppten Diensten aus `files.tar` wiederherstellen. Bei SeaweedFS auch die Speicherbeschreibung aus `backup/config/.updates/storage-provider.json` zurückspielen und das gesicherte Image mit `docker image load -i backup/storage-image.tar` laden. Die konkrete Rücksicherung bestehender Daten erfolgt bewusst manuell und ersetzt Daten. Der vor der Speicherumstellung erhaltene MinIO-Bestand gehört nicht zu einem aktuellen Anwendungsbackup; ihn keinesfalls als Abkürzung reaktivieren. 4. Wiederhergestellten Stack mit dessen Compose-Dateien starten und Funktionen prüfen. Alternativ den Fehler der Zielversion beheben und diese erfolgreich starten. 5. Erst nach erfolgreicher Wiederherstellung die Sperre aufheben: @@ -202,8 +257,15 @@ Bei Rückkehr zum alten Matrix-Stack dessen ursprüngliche Compose-Datei aus dem ```bash npm test npm run commits:check -cd backend && node --import tsx --test tests/selfhostUpdates.test.ts +cd backend && node --import tsx --test tests/selfhostUpdates.test.ts tests/systemStatusStorage.test.ts cd ../frontend && node --test tests/selfhost-updates.test.cjs ``` 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. + +`python3 -B scripts/update/tests/host_storage_integration.py` prüft zusätzlich +MinIO-Migration, Speicherübernahme, zwei Anwendungsupdates und Restore der +regulären SeaweedFS-Updatesicherung mit echten Docker-Containern. Anwendung und +Release-/HTTP-Antworten sind isolierte Testdaten. Benötigte Images und Aufbau +stehen in `scripts/storage/README.md`. Die fachliche Abnahme des veröffentlichten +FEDEO-Releases auf einer Testinstanz bleibt zusätzlich erforderlich. diff --git a/frontend/components/SelfhostUpdateCard.vue b/frontend/components/SelfhostUpdateCard.vue index 594830a4..3d1dd504 100644 --- a/frontend/components/SelfhostUpdateCard.vue +++ b/frontend/components/SelfhostUpdateCard.vue @@ -47,7 +47,8 @@ const reload = () => window.location.reload()

Aktualisiert die gesamte Selfhost-Instanz einschließlich Docker Compose und Datenbank. Vor Änderungen wird ein Backup erstellt.

- + +

{{ statusLabel }}

@@ -58,6 +59,8 @@ const reload = () => window.location.reload()
Commit
{{ job.plan.revision?.slice(0, 8) }}
Geschätztes Backup
{{ backupSize }}
+

Dateispeicher: SeaweedFS. Die geprüfte Speicherversion bleibt erhalten und wird zusammen mit der Datenbank gesichert.

+

Dateispeicher: MinIO. Eine Umstellung auf SeaweedFS erfolgt als eigener, geprüfter Wartungsschritt auf dem Host.

Alte Matrix-Dienste werden stillgelegt; Daten und Container bleiben erhalten: {{ job.plan.retiredServices.join(', ') }}.

Auftrag: {{ job.requestId }}

diff --git a/frontend/composables/useSelfhostUpdates.ts b/frontend/composables/useSelfhostUpdates.ts index cb776bd8..6e668e81 100644 --- a/frontend/composables/useSelfhostUpdates.ts +++ b/frontend/composables/useSelfhostUpdates.ts @@ -5,13 +5,15 @@ export type UpdateState = { export type UpdateJob = UpdateState & { progress?: UpdateState; error?: { code: string; message: string }; plan?: { version: string; revision: string; previousVersion?: string; updateAvailable: boolean; - requiresComposeAdoption: boolean; estimatedBackupBytes?: number; retiredServices: string[] }; + requiresComposeAdoption: boolean; estimatedBackupBytes?: number; retiredServices: string[]; + storageProvider?: 'minio' | 'seaweedfs'; storageImageRetained?: boolean }; } export type UpdateLogs = { requestId: string; logs: { source: 'service' | 'events' | 'commands'; content: string; truncated: boolean }[] } type Overview = { acceptingRequests: boolean; engine: UpdateState; request?: UpdateJob; installed: { version?: string }; backend: { version: string; revision: string }; capabilities?: { requestLogs: boolean }; + storage?: { provider: 'seaweedfs'; migrationPending: boolean }; } type Submission = { command: 'check' | 'apply'; body: { requestId: string; version?: string; checkRequestId?: string } } diff --git a/frontend/tests/selfhost-updates.test.cjs b/frontend/tests/selfhost-updates.test.cjs index 03810c71..39aa8b35 100644 --- a/frontend/tests/selfhost-updates.test.cjs +++ b/frontend/tests/selfhost-updates.test.cjs @@ -82,6 +82,19 @@ test('recovery, up-to-date, unmanaged compose and other active jobs prevent appl } }) +test('a completed storage migration stays blocked until the host handover is complete', async t => { + let state = { ...overview(check, { status: 'failed', recoveryRequired: true }), + storage: { provider: 'seaweedfs', migrationPending: true } } + const f = await setup(t, async path => path.endsWith('/status') ? state : state.request) + assert.equal(f.ui.overview.value.storage.migrationPending, true) + assert.equal(f.ui.canApply.value, false) + state = { ...overview({ ...check, plan: { ...check.plan, storageProvider: 'seaweedfs', storageImageRetained: true } }), + storage: { provider: 'seaweedfs', migrationPending: false } } + await f.ui.refresh() + assert.equal(f.ui.canApply.value, true) + assert.equal(f.ui.job.value.plan.storageProvider, 'seaweedfs') +}) + test('an older polling response cannot erase a new submission whose response was lost', async t => { let hold = false, release const f = await setup(t, async (path, options) => { diff --git a/scripts/selfhost-install.sh b/scripts/selfhost-install.sh index 28480e73..185bbc7d 100755 --- a/scripts/selfhost-install.sh +++ b/scripts/selfhost-install.sh @@ -163,6 +163,16 @@ download_file() { mv "$temp_file" "$target" } +guard_storage_migration() { + if [[ -e "$FEDEO_DIR/storage" || -L "$FEDEO_DIR/storage" || + -e "$FEDEO_DIR/.updates/storage-provider.json" || + -e "$FEDEO_DIR/.updates/storage-migration/state.json" ]]; then + echo "Eine Speicherumstellung wurde vorbereitet oder durchgeführt. Der Standardinstaller darf diese Instanz nicht überschreiben." >&2 + echo "Den geprüften Migrationsablauf bzw. den Host-Updater verwenden (SELFHOST-UPDATES.md)." >&2 + exit 1 + fi +} + download_selfhost_files() { echo echo "Zielverzeichnis: $FEDEO_DIR" @@ -177,6 +187,8 @@ download_selfhost_files() { $SUDO chown "$(id -u):$(id -g)" "$FEDEO_DIR" "$FEDEO_DIR/scripts" fi + guard_storage_migration + if [[ -d "$FEDEO_DIR/.git" ]]; then echo "Hinweis: In $FEDEO_DIR liegt noch ein alter Git-Checkout. Der Installer aktualisiert nur die Selfhost-Dateien und lässt Daten, .env und Checkout unangetastet." fi @@ -210,6 +222,7 @@ Dieser Installer bringt die typische Serverstruktur mit: EOF + guard_storage_migration install_base_packages ensure_docker download_selfhost_files diff --git a/scripts/selfhost-setup.sh b/scripts/selfhost-setup.sh index fa8668f9..9691b82b 100755 --- a/scripts/selfhost-setup.sh +++ b/scripts/selfhost-setup.sh @@ -467,6 +467,14 @@ uninstall_stack() { } main() { + if [[ -e "$ROOT_DIR/storage" || -L "$ROOT_DIR/storage" || + -e "$ROOT_DIR/.updates/storage-provider.json" || + -e "$ROOT_DIR/.updates/storage-migration/state.json" ]]; then + echo "Eine Speicherumstellung wurde vorbereitet oder durchgeführt. Das Standardsetup darf diese Instanz nicht überschreiben oder entfernen." >&2 + echo "Den geprüften Migrationsablauf bzw. den Host-Updater verwenden (SELFHOST-UPDATES.md)." >&2 + exit 1 + fi + if [[ "$UNINSTALL" == "true" ]]; then uninstall_stack return diff --git a/scripts/selfhost-update-service.py b/scripts/selfhost-update-service.py index b8657fea..3c8095ae 100644 --- a/scripts/selfhost-update-service.py +++ b/scripts/selfhost-update-service.py @@ -54,8 +54,10 @@ def identifier(value): def public_state(state): """An allowlist, never raw command logs, exceptions, host paths, config or diff contents.""" result = {"status": state.get("status") if state.get("status") in STATUSES else "idle"} - if state.get("phase") in PHASES: - result["phase"] = state["phase"] + # Host tools are installed before the application update; old backend enums must still parse us. + phase = {"checking_storage": "validating", "storage_adopted": "resolved"}.get(state.get("phase"), state.get("phase")) + if phase in PHASES: + result["phase"] = phase for key in ("recoveryRequired", "maintenance", "mutationStarted"): if key in state: result[key] = bool(state[key]) @@ -81,6 +83,9 @@ def public_plan(plan): if type(plan.get("estimatedBackupBytes")) is int: result["estimatedBackupBytes"] = plan["estimatedBackupBytes"] result["retiredServices"] = sorted(set(plan.get("retiredServices", [])) & engine.LEGACY_MATRIX_SERVICES) + if plan.get("storageProvider") in ("minio", "seaweedfs"): + result["storageProvider"] = plan["storageProvider"] + result["storageImageRetained"] = plan.get("storageImageRetained") is True return result @@ -254,6 +259,11 @@ class Controller: installed = engine.read_json(updater.home / "installed.json", {}).get("manifest", {}) result["installed"] = {k: v for k, v in public_state({"version": installed.get("version"), "revision": installed.get("commit")}).items() if k != "status"} + storage = engine.read_json(updater.home / "storage-provider.json", {}) + migration = engine.read_json(updater.home / "storage-migration/state.json", {}) + if storage.get("provider") == "seaweedfs" or migration.get("writesReleased"): + result["storage"] = {"provider": "seaweedfs", + "migrationPending": engine.read_json(updater.home / "status.json", {}).get("phase") == "storage_migration"} if self.active: result["request"] = self.request_status(self.active) return result diff --git a/scripts/selfhost-update.py b/scripts/selfhost-update.py index a8e0e892..fbe102e7 100644 --- a/scripts/selfhost-update.py +++ b/scripts/selfhost-update.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 """FEDEO host-side update engine. Python 3.10+, Docker Compose 2.24+; no pip packages. -The CLI and a future host service share Updater. No daemon or HTTP API is installed. +The CLI and the optional local host service share Updater and the exclusive lock. All commands operate on an existing local selfhost installation, never a git checkout. """ import argparse import contextlib +import copy import datetime import difflib import fcntl @@ -31,6 +32,103 @@ FILES = ("release-manifest.json", "docker-compose.selfhost.yml", ".env.example", GIB = 1024 ** 3 LEGACY_MATRIX_SERVICES = {"matrix-db", "matrix-synapse", "matrix-redis", "matrix-element", "matrix-livekit", "matrix-rtc-jwt", "matrix-turn", "matrix-well-known"} +CORE_SERVICES = {"backend", "frontend", "db", "traefik", "node-exporter"} +IMAGE_ID = re.compile(r"sha256:[a-f0-9]{64}\Z") + + +def compose_strings(value, *, escape=False): + """Round-trip resolved Compose output, including literal dollars in secrets.""" + if isinstance(value, str): + return value.replace("$", "$$") if escape else value.replace("$$", "$") + if isinstance(value, list): + return [compose_strings(v, escape=escape) for v in value] + if isinstance(value, dict): + return {k: compose_strings(v, escape=escape) for k, v in value.items()} + return value + + +def file_sha(path): + digest = hashlib.sha256() + with Path(path).open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +# Runs with the actual application's SDK. Never prints keys, URLs or credentials. +S3_READ_CHECK = r""" +const {S3Client, HeadBucketCommand, ListObjectsV2Command, GetObjectCommand} = require('@aws-sdk/client-s3'); +const client = new S3Client({endpoint:process.env.S3_ENDPOINT, region:process.env.S3_REGION, + forcePathStyle:true, maxAttempts:8, credentials:{accessKeyId:process.env.S3_ACCESS_KEY,secretAccessKey:process.env.S3_SECRET_KEY}}); +const timer = setTimeout(()=>process.exit(1), 180000); +(async()=>{ + const Bucket=process.env.S3_BUCKET; + await client.send(new HeadBucketCommand({Bucket})); + const listed=await client.send(new ListObjectsV2Command({Bucket,MaxKeys:1})); + const first=listed.Contents?.[0]; + if(first) { + const object=await client.send(new GetObjectCommand({Bucket,Key:first.Key,...(first.Size ? {Range:'bytes=0-0'} : {})})); + await object.Body.transformToByteArray(); + } +})().catch(()=>{console.error('S3-Zugriffsprüfung fehlgeschlagen');process.exitCode=1}) + .finally(()=>{clearTimeout(timer);client.destroy()}); +""" + +S3_UPGRADE_CHECK = r""" +const s3 = require('@aws-sdk/client-s3'); +const {getSignedUrl} = require('@aws-sdk/s3-request-presigner'); +const {Readable} = require('node:stream'); +const assert = require('node:assert/strict'); +const client = new s3.S3Client({endpoint:process.env.S3_ENDPOINT, region:process.env.S3_REGION, + forcePathStyle:true, maxAttempts:8, + credentials:{accessKeyId:process.env.S3_ACCESS_KEY,secretAccessKey:process.env.S3_SECRET_KEY}}); +const Bucket=process.env.S3_BUCKET, Key=process.env.FEDEO_UPDATE_PROBE_KEY; +const timer=setTimeout(()=>process.exit(1), 180000); +(async()=>{ + assert.match(Key, /^\.fedeo-update-check\/[a-f0-9]{32}\.pdf$/); + try { + await client.send(new s3.HeadBucketCommand({Bucket})); + const body=Buffer.from('FEDEO S3 update check'); + await client.send(new s3.PutObjectCommand({Bucket,Key,Body:Readable.from([body]),ContentLength:body.length, + ContentType:'application/octet-stream',Metadata:{fedeo:'update-check'}})); + const head=await client.send(new s3.HeadObjectCommand({Bucket,Key})); + assert.equal(head.ContentType,'application/octet-stream'); + assert.equal(head.Metadata.fedeo,'update-check'); + assert.ok(!head.ContentEncoding); + const result=await client.send(new s3.GetObjectCommand({Bucket,Key})); + assert.deepEqual(Buffer.from(await result.Body.transformToByteArray()),body); + const signed=await getSignedUrl(client,new s3.GetObjectCommand({Bucket,Key}),{expiresIn:86400}); + const response=await fetch(signed); + assert.equal(response.status,200); + assert.deepEqual(Buffer.from(await response.arrayBuffer()),body); + const anonymous=new URL(signed); anonymous.search=''; + const denied=await fetch(anonymous); + assert.equal(denied.status,403); await denied.body?.cancel(); + } finally { await client.send(new s3.DeleteObjectCommand({Bucket,Key})); } + try { await client.send(new s3.HeadObjectCommand({Bucket,Key})); throw new Error('probe remains'); } + catch(error) { if(error.$metadata?.httpStatusCode!==404) throw error; } +})().catch(()=>{console.error('S3-Vertrag des Zielbackends fehlgeschlagen');process.exitCode=1}) + .finally(()=>{clearTimeout(timer);client.destroy()}); +""" + +S3_CLEANUP_CHECK = r""" +const s3=require('@aws-sdk/client-s3'), assert=require('node:assert/strict'); +const client=new s3.S3Client({endpoint:process.env.S3_ENDPOINT,region:process.env.S3_REGION,forcePathStyle:true, + credentials:{accessKeyId:process.env.S3_ACCESS_KEY,secretAccessKey:process.env.S3_SECRET_KEY}}); +const Bucket=process.env.S3_BUCKET, Key=process.env.FEDEO_UPDATE_PROBE_KEY; +const timer=setTimeout(()=>process.exit(1),60000); +(async()=>{ + assert.match(Key,/^\.fedeo-update-check\/[a-f0-9]{32}\.pdf$/); + let head; + try { head=await client.send(new s3.HeadObjectCommand({Bucket,Key})); } + catch(error) { if(error.$metadata?.httpStatusCode===404) return; throw error; } + assert.equal(head.Metadata?.fedeo,'update-check'); + await client.send(new s3.DeleteObjectCommand({Bucket,Key})); + try { await client.send(new s3.HeadObjectCommand({Bucket,Key})); throw new Error('probe remains'); } + catch(error) { if(error.$metadata?.httpStatusCode!==404) throw error; } +})().catch(()=>{console.error('Protokollierten S3-Prüfschlüssel manuell prüfen');process.exitCode=1}) + .finally(()=>{clearTimeout(timer);client.destroy()}); +""" class UpdateError(Exception): @@ -283,7 +381,7 @@ class Updater: return self.runner.run(prefix + list(args), **kwargs) def config(self, candidate): - return json.loads(self.compose(candidate, "config", "--format", "json")) + return compose_strings(json.loads(self.compose(candidate, "config", "--format", "json"))) def inspect(self, name): return json.loads(self.runner.run(["docker", "inspect", name]))[0] @@ -292,6 +390,166 @@ class Updater: if shutil.disk_usage(directory).free < required: raise UpdateError(f"Zu wenig freier Speicher auf {directory}: mindestens {required // GIB + 1} GiB erforderlich.") + def storage_record(self): + path = self.home / "storage-provider.json" + if path.is_symlink(): + raise UpdateError("Speicherbeschreibung darf kein symbolischer Link sein.") + record = read_json(path, {}) + if record and (record.get("schemaVersion") != 1 or record.get("provider") != "seaweedfs" + or not IMAGE_ID.fullmatch(str(record.get("image", "")))): + raise UpdateError("Unbekannte Speicherbeschreibung; Speichermigration auf dem Host prüfen.") + return record + + def validate_storage(self, config, record, *, running=True): + """The adopted storage service is immutable across application releases.""" + store = config.get("services", {}).get("objectstore", {}) + if store != record.get("service") or store.get("image") != record.get("image"): + raise UpdateError("SeaweedFS-Konfiguration oder Image verändert; separate Speichermigration erforderlich.") + for directory in ("storage", "storage/seaweedfs", "storage/config"): + path = self.root / directory + if not path.is_dir() or path.resolve() != path: + raise UpdateError("SeaweedFS benötigt reguläre lokale storage/-Verzeichnisse.") + if any(p.is_symlink() for p in (self.root / "storage").rglob("*")): + raise UpdateError("Symbolische Links in storage/ können nicht vollständig gesichert werden.") + expected = {"/data": str(self.root / "storage/seaweedfs"), "/etc/fedeo": str(self.root / "storage/config")} + volumes = store.get("volumes", []) + if (len(volumes) != 2 or any(v.get("type") != "bind" for v in volumes) + or {v.get("target"): v.get("source") for v in volumes} != expected + or not next(v for v in volumes if v["target"] == "/etc/fedeo").get("read_only")): + raise UpdateError("SeaweedFS-Datenmounts entsprechen nicht der übernommenen lokalen Ablage.") + for name in ("s3.json", "start-seaweedfs.sh"): + path = self.root / "storage/config" / name + if not path.is_file() or file_sha(path) != record.get("configSha256", {}).get(name): + raise UpdateError("Geschützte SeaweedFS-Konfiguration verändert oder unvollständig.") + if store.get("environment", {}).get("FEDEO_S3_CONFIG_SHA256") != record["configSha256"]["s3.json"]: + raise UpdateError("SeaweedFS-Konfigurationsprüfsumme stimmt nicht.") + network = config.get("networks", {}).get("internal", {}).get("name") + if (network != record.get("network") or store.get("ports") or set(store.get("networks", {})) != {"internal"} + or "minio" not in store["networks"]["internal"].get("aliases", [])): + raise UpdateError("SeaweedFS muss ausschließlich am bisherigen internen S3-Anschluss liegen.") + old = self.inspect(record["retainedMinio"]["id"]) + if (old.get("Image") != record["retainedMinio"]["image"] + or any(old.get("State", {}).get(k) for k in ("Running", "Restarting", "Paused")) + or old.get("HostConfig", {}).get("RestartPolicy", {}).get("Name") != "no" + or network in old.get("NetworkSettings", {}).get("Networks", {})): + raise UpdateError("Der erhaltene MinIO-Container ist nicht unverändert stillgelegt.") + if running: + cid = self.compose(False, "ps", "--quiet", "objectstore").strip() + actual = self.inspect(cid) if cid and "\n" not in cid else {} + mounts = actual.get("Mounts", []) + if (actual.get("Image") != record["image"] or not actual.get("State", {}).get("Running") + or actual.get("State", {}).get("Health", {}).get("Status") != "healthy" + or actual.get("HostConfig", {}).get("PortBindings") + or set(actual.get("NetworkSettings", {}).get("Networks", {})) != {network} + or "minio" not in actual["NetworkSettings"]["Networks"][network].get("Aliases", []) + or len(mounts) != 2 or any(v.get("Type") != "bind" for v in mounts) + or {v.get("Destination"): v.get("Source") for v in mounts} != expected + or any(v.get("RW") for v in mounts if v.get("Destination") == "/etc/fedeo") + or ("FEDEO_S3_CONFIG_SHA256=" + record["configSha256"]["s3.json"]) not in actual.get("Config", {}).get("Env", [])): + raise UpdateError("Laufender SeaweedFS-Container weicht vom geprüften Speicher ab.") + + def adopt_storage(self): + """Explicit host-only handover; cannot resolve an interrupted migration/update.""" + with self.lock(): + guard = read_json(self.home / "status.json", {}) + if guard.get("phase") != "storage_migration" or not guard.get("recoveryRequired"): + if self.storage_record() and not guard.get("recoveryRequired"): + return {"status": "already_adopted", "storageProvider": "seaweedfs"} + raise UpdateError("Keine abgeschlossene Pilotmigration zur Übernahme vorhanden.") + migration = self.home / "storage-migration" + if migration.is_symlink() or guard.get("job") != str(migration): + raise UpdateError("Speichermigrationsauftrag stimmt nicht.") + state = read_json(migration / "state.json", {}) + if (state.get("schemaVersion") != 1 or state.get("phase") != "complete" + or state.get("writesReleased") is not True or state.get("provider") != "seaweedfs" + or not state.get("restoreVerifiedAt") or not IMAGE_ID.fullmatch(str(state.get("weedImage", "")))): + raise UpdateError("Speichermigration und geprüften Restore zuerst vollständig abschließen; kein resolve verwenden.") + if any((self.root / n).exists() for n in ("docker-compose.override.yml", "docker-compose.override.yaml", "compose.yml", "compose.yaml")): + raise UpdateError("Pilotübernahme unterstützt keine zusätzlichen Compose-Dateien.") + before = {n: file_sha(self.root / n) for n in (".env", "docker-compose.yml")} + if any((self.root / n).is_symlink() for n in before): + raise UpdateError("Konfigurationsdateien dürfen keine symbolischen Links sein.") + if (before["docker-compose.yml"] != file_sha(migration / "target.json") + or before[".env"] != state.get("fingerprint", {}).get(".env")): + raise UpdateError("Installation nach der Speichermigration verändert; Übernahme manuell prüfen.") + verified = read_json(migration / "copy/verified.json", {}) + if (not verified.get("verifiedAt") or verified.get("count") != state.get("count") + or verified.get("bytes") != state.get("bytes") + or len(verified.get("objects", [])) != state.get("count")): + raise UpdateError("Vollständiger Kopiernachweis der Speichermigration fehlt.") + for name in ("restore-contract", "acceptance", "backend-acceptance"): + if not {"PUT", "HEAD", "GET", "DELETE", "SigV4", "presigned-24h", "anonymous-denied", "wrong-key-denied"} <= set( + read_json(migration / name / "result.json", {}).get("operations", [])): + raise UpdateError("S3-Abnahme der Speichermigration fehlt: " + name) + if (not (migration / "backup/complete.json").is_file() + or file_sha(migration / "backup/seaweedfs.tar") != state.get("seaweedBackupSha256")): + raise UpdateError("Geprüfte Speichersicherung fehlt oder wurde verändert.") + self.job, self.override = migration, None + self.runner = self.runner_factory(migration / "adoption.log") + endpoint = self.runner.run(["docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"] ).strip() + if not os.environ.get("DOCKER_HOST", endpoint).startswith("unix://"): + raise UpdateError("Speicherübernahme muss direkt auf dem lokalen Docker-Host laufen.") + config = self.config(False) + self.project = config["name"] + required = CORE_SERVICES | {"objectstore"} + if not required <= set(config["services"]) or set(config["services"]) - required - LEGACY_MATRIX_SERVICES: + raise UpdateError("Speicherübernahme benötigt den Standard-Stack; nur bekannte alte Matrix-Dienste sind zusätzlich erlaubt.") + original = read_json(migration / "original.json") + record = {"schemaVersion": 1, "provider": "seaweedfs", "adoptedAt": now(), + "image": state["weedImage"], "network": state["network"], + "service": config["services"]["objectstore"], + "databaseSourceImage": original["services"]["db"]["image"], + "databaseImage": state["containers"]["db"]["image"], + "retainedMinio": {k: state["containers"]["minio"][k] for k in ("id", "image")}, + "configSha256": {n: file_sha(self.root / "storage/config" / n) for n in ("s3.json", "start-seaweedfs.sh")}} + self.validate_storage(config, record) + for name in CORE_SERVICES: + cid = self.compose(False, "ps", "--quiet", name).strip() + actual = self.inspect(cid) if cid and "\n" not in cid else {} + if (actual.get("Image") != state["containers"][name]["image"] + or not actual.get("State", {}).get("Running") + or actual.get("State", {}).get("Health", {}).get("Status") not in (None, "healthy")): + raise UpdateError("Anwendung nach Speichermigration nicht gesund/unverändert: " + name) + self.compose(False, "exec", "-T", "backend", "node", "-e", S3_READ_CHECK, timeout=210) + if any(file_sha(self.root / n) != digest for n, digest in before.items()): + raise UpdateError("Konfiguration während Speicherübernahme verändert.") + # The old guard remains until both records are durable; retry after a crash is safe. + write_json(self.home / "storage-provider.json", record) + installed = read_json(self.home / "installed.json", {}) + installed.update(composeSha256=before["docker-compose.yml"], storageProvider="seaweedfs") + write_json(self.home / "installed.json", installed) + write_json(migration / "updater-adoption.json", {"adoptedAt": record["adoptedAt"], "provider": "seaweedfs"}) + write_json(self.home / "status.json", {"status": "recovered", "phase": "storage_adopted", + "updatedAt": now(), "recoveryRequired": False, "maintenance": False, "mutationStarted": False}) + return {"status": "adopted", "storageProvider": "seaweedfs", "version": state["version"]} + + def adapt_storage_candidate(self): + self.storage = self.storage_record() + self.storage_service = "objectstore" if self.storage else "minio" + self.storage_paths = ["storage"] if self.storage else ["minio"] + if not self.storage: + if "objectstore" in self.current["services"]: + raise UpdateError("SeaweedFS zuerst mit adopt-storage aus der abgeschlossenen Pilotmigration übernehmen.") + return + self.validate_storage(self.current, self.storage) + services = self.candidate["services"] + if set(services) != CORE_SERVICES | {"minio", "createbuckets"}: + raise UpdateError("Release verwendet einen unbekannten Speicheraufbau; Updater-Unterstützung erforderlich.") + if services["db"]["image"] != self.storage["databaseSourceImage"]: + raise UpdateError("Ein Wechsel der PostgreSQL-Version benötigt eine separate Datenbankmigration.") + services["db"]["image"] = self.storage["databaseImage"] + del services["minio"], services["createbuckets"] + services["objectstore"] = copy.deepcopy(self.storage["service"]) + dependencies = services["backend"].setdefault("depends_on", {}) + dependencies.pop("minio", None) + dependencies.pop("createbuckets", None) + dependencies["objectstore"] = {"condition": "service_healthy", "required": True} + services["backend"].setdefault("environment", {}).update( + AWS_REQUEST_CHECKSUM_CALCULATION="WHEN_REQUIRED", FEDEO_RUN_MIGRATIONS="false", FEDEO_STORAGE_PROVIDER="seaweedfs") + write_json(self.job / "target.compose.yml", compose_strings(self.candidate, escape=True)) + self.candidate = self.config(True) + self.validate_storage(self.candidate, self.storage) + def prepare(self, version, *, allow_current=False): for name in (".env", "docker-compose.yml"): path = self.root / name @@ -345,14 +603,51 @@ class Updater: raise UpdateError(f"Release-Compose enthält kein eindeutiges {name}-Image.") atomic(self.job / "target.compose.yml", compose) atomic(self.job / "target.env", set_version((self.root / ".env").read_bytes(), self.target)) - diff = "".join(difflib.unified_diff(original.decode().splitlines(True), compose.splitlines(True), - fromfile="installed/docker-compose.yml", tofile="release/docker-compose.yml")) - atomic(self.job / "compose.diff", diff) self.candidate = self.config(True) + self.adapt_storage_candidate() + diff = "".join(difflib.unified_diff(original.decode().splitlines(True), + (self.job / "target.compose.yml").read_text().splitlines(True), + fromfile="installed/docker-compose.yml", tofile="release/docker-compose.yml")) + atomic(self.job / "compose.diff", diff) self.validate_configs() - ids = self.compose(False, "ps", "--all", "--quiet").split() + # Compose ps can omit services removed from the current file. Retained + # Matrix/MinIO containers still belong to the project and its backup plan. + ids = self.runner.run(["docker", "ps", "--all", "--quiet", "--filter", + "label=com.docker.compose.project=" + self.project]).split() self.containers = [self.inspect(container) for container in ids] write_json(self.job / "original-containers.json", self.containers) + retained = {} + seen = set() + for container in self.containers: + labels = container.get("Config", {}).get("Labels", {}) + name = labels.get("com.docker.compose.service") + if labels.get("com.docker.compose.oneoff", "False").lower() == "true": + if container.get("State", {}).get("Running"): + raise UpdateError("Ein Einmalcontainer läuft noch; dessen Arbeit zuerst abschließen.") + continue + if not name or name in seen: + raise UpdateError("Unbekannte oder doppelte Projektcontainer vorhanden.") + seen.add(name) + if name not in self.current["services"]: + if self.storage and name in ("minio", "createbuckets"): + continue # MinIO's exact identity and retirement are checked by validate_storage. + if name not in LEGACY_MATRIX_SERVICES: + raise UpdateError("Unbekannter Projektcontainer: " + name) + state, host = container.get("State", {}), container.get("HostConfig", {}) + if (any(state.get(k) for k in ("Running", "Restarting", "Paused")) + or host.get("RestartPolicy", {}).get("Name") != "no" or host.get("AutoRemove")): + raise UpdateError("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"]}) + elif mount["Type"] != "volume" or (name, mount["Destination"]) not in { + ("matrix-redis", "/data"), ("matrix-turn", "/var/lib/coturn")}: + raise UpdateError("Abweichender Datenmount eines alten Matrix-Containers: " + name) + retained[name] = {"volumes": volumes} + self.current["services"].update(retained) + self.retired_services = sorted(set(self.retired_services) | set(retained)) + self.validate_legacy_matrix() 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): @@ -369,16 +664,22 @@ class Updater: 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 if c["State"]["Running"] and c["Config"]["Labels"].get("com.docker.compose.oneoff", "False").lower() != "true"] - if not {"backend", "frontend", "db", "minio"}.issubset(self.running): - raise UpdateError("Backend, Frontend, Datenbank und MinIO müssen vor dem Update laufen.") + if not {"backend", "frontend", "db", self.storage_service}.issubset(self.running): + raise UpdateError("Backend, Frontend, Datenbank und Dateispeicher müssen vor dem Update laufen.") if not set(self.running).issubset(self.current["services"]): raise UpdateError("Nicht konfigurierte Container dieses Compose-Projekts laufen noch.") self.before = {str(p): sha(p.read_bytes()) for p in [self.root / ".env", self.root / "docker-compose.yml", *([self.override] if self.override else [])]} + if self.storage: + for path in [self.home / "storage-provider.json", *[self.root / "storage/config" / n for n in ("s3.json", "start-seaweedfs.sh")]]: + self.before[str(path)] = file_sha(path) database_size = int(self.compose(False, "exec", "-T", "db", "sh", "-ec", '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 + storage_size = sum(int(self.runner.run(["du", "-sb", str(self.root / path)]).split()[0]) for path in self.storage_paths) + self.backup_size = database_size * 2 + storage_size + GIB + if self.storage: + image = json.loads(self.runner.run(["docker", "image", "inspect", self.storage["image"]]))[0] + self.backup_size += image["Size"] * 2 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) @@ -387,6 +688,8 @@ class Updater: "releaseNotes": str(self.job / "CHANGELOG.md"), "requiresComposeAdoption": not self.managed, "estimatedBackupBytes": self.backup_size, "services": list(self.candidate["services"]), "retiredServices": self.retired_services, "retainedDataPaths": self.matrix_backup_paths, + "storageProvider": "seaweedfs" if self.storage else "minio", + "storageImageRetained": bool(self.storage), "storageBackupPaths": self.storage_paths, "updateAvailable": update_available, # Kept in private job records; the service never exposes configuration fingerprints. "approvalFingerprint": sha(json.dumps({"manifest": self.manifest, "configuration": self.before, @@ -396,7 +699,7 @@ class Updater: def validate_configs(self): old, new = self.current["services"], self.candidate["services"] - required = {"backend", "frontend", "db", "minio", "createbuckets", "traefik", "node-exporter"} + required = CORE_SERVICES | ({"objectstore"} if self.storage else {"minio", "createbuckets"}) differences = [] for label, services in (("Bestehende Konfiguration", old), ("Zielkonfiguration", new)): missing, additional = required - set(services), set(services) - required @@ -410,7 +713,7 @@ class Updater: if details: differences.append(label + " (" + "; ".join(details) + ")") if differences: - raise UpdateError("Dieser Updater unterstützt den Standard-Selfhost-Stack mit lokalen Daten und MinIO. " + raise UpdateError("Dieser Updater unterstützt den Standard-Selfhost-Stack mit lokalem Dateispeicher. " + ". ".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 = [] @@ -431,7 +734,10 @@ class Updater: raise UpdateError(f"Container-Identität von {name} würde verändert.") if self.current.get("networks") != self.candidate.get("networks"): raise UpdateError("Docker-Netzwerke würden verändert.") - for name, target, directory in (("db", "/var/lib/postgresql/data", "postgres"), ("minio", "/data", "minio")): + data_services = [("db", "/var/lib/postgresql/data", "postgres")] + if not self.storage: + data_services.append(("minio", "/data", "minio")) + for name, target, directory in data_services: volumes = new[name].get("volumes", []) if len(volumes) != 1 or volumes[0].get("type") != "bind" or volumes[0].get("target") != target or volumes[0].get("source") != str(self.root / directory): raise UpdateError(f"{name} benötigt das lokale Standard-Datenverzeichnis.") @@ -448,7 +754,7 @@ class Updater: healthcheck = new[name].get("healthcheck", {}) if new[name]["image"] != self.images[name] or healthcheck.get("disable") or not healthcheck.get("test") or healthcheck["test"] == ["NONE"]: raise UpdateError(f"{name}: Release-Digest oder Healthcheck wurde überschrieben.") - for name in ("minio", "createbuckets"): + for name in (() if self.storage else ("minio", "createbuckets")): if not re.search(r"@sha256:[a-f0-9]{64}$", new[name]["image"]): raise UpdateError("MinIO und mc müssen auf feste Digests gepinnt sein.") env = new["backend"].get("environment", {}) @@ -522,13 +828,21 @@ class Updater: atomic(directory / "config" / name, (self.root / name).read_bytes()) if self.override: atomic(directory / "config" / self.override.name, self.override.read_bytes()) + if self.storage: + self.validate_storage(self.current, self.storage, running=False) + cid = self.compose(False, "ps", "--all", "--quiet", "objectstore").strip() + store_state = self.inspect(cid).get("State", {}) if cid else {"Running": True} + if any(store_state.get(k) for k in ("Running", "Restarting", "Paused")): + raise UpdateError("SeaweedFS muss vor der kalten Sicherung gestoppt sein.") + atomic(directory / "config/.updates/storage-provider.json", (self.home / "storage-provider.json").read_bytes()) + self.runner.run(["docker", "image", "save", "-o", str(directory / "storage-image.tar"), self.storage["image"]], timeout=3600) write_json(directory / "containers.json", self.containers) self.compose(False, "exec", "-T", "db", "sh", "-ec", 'exec pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom', output=directory / "database.dump") 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", *self.matrix_backup_paths] + paths = [*self.storage_paths, *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) @@ -543,7 +857,13 @@ class Updater: os.fsync(stream.fileno()) checksums[str(path.relative_to(directory))] = digest.hexdigest() self.verify_matrix_stopped() + if self.storage: + cid = self.compose(False, "ps", "--all", "--quiet", "objectstore").strip() + if not cid or any(self.inspect(cid).get("State", {}).get(k) for k in ("Running", "Restarting", "Paused")): + raise UpdateError("SeaweedFS wurde während der kalten Sicherung gestartet.") write_json(directory / "complete.json", {"createdAt": now(), "database": "database.dump", "files": "files.tar", + "storageProvider": "seaweedfs" if self.storage else "minio", "storageDataPaths": self.storage_paths, + "storageImage": self.storage["image"] if self.storage else None, "retiredServices": self.retired_services, "retainedContainerIds": self.state["retiredContainerIds"], "matrixDataPaths": self.matrix_backup_paths, "sha256": checksums}) self.save("backed_up", backup=str(directory)) @@ -570,12 +890,18 @@ class Updater: # Mutable infrastructure tags must still be refreshed before entering maintenance. for policy in ("missing", "always"): services = [name for name, service in self.candidate["services"].items() - if bool(re.search(r"@sha256:[a-f0-9]{64}$", service["image"])) == (policy == "missing")] + if not IMAGE_ID.fullmatch(service["image"]) + and bool(re.search(r"@sha256:[a-f0-9]{64}$", service["image"])) == (policy == "missing")] if services: self.compose(True, "pull", "--policy", policy, *services, timeout=3600) + for service in self.candidate["services"].values(): + if IMAGE_ID.fullmatch(service["image"]): + self.runner.run(["docker", "image", "inspect", service["image"]]) self.check_space(self.backup_size, self.home) if any(sha(Path(path).read_bytes()) != value for path, value in self.before.items()): raise UpdateError("Konfiguration wurde während der Vorbereitung verändert; erneut prüfen.") + if self.storage: + self.validate_storage(self.current, self.storage) self.save("maintenance", recoveryRequired=True, maintenance=True, runningServices=self.running) # Stop every writer and public entry point. The database stays up for pg_dump. self.compose(False, "stop", "--timeout", "60", *[s for s in self.running if s != "db"]) @@ -596,8 +922,16 @@ class Updater: atomic(self.root / ".env.example", (self.job / ".env.example").read_bytes()) atomic(self.root / "scripts/selfhost-setup.sh", (self.job / "selfhost-setup.sh").read_bytes()) os.chmod(self.root / "scripts/selfhost-setup.sh", 0o700) - self.compose(True, "up", "--pull", "never", "-d", "--no-deps", "--wait", "--wait-timeout", "180", "db", "minio", "node-exporter") - self.compose(True, "run", "--pull", "never", "--rm", "--no-deps", "createbuckets") + self.compose(True, "up", "--pull", "never", "-d", "--no-deps", "--wait", "--wait-timeout", "180", "db", self.storage_service, "node-exporter") + if self.storage: + self.save("checking_storage", storageCheckContainer="fedeo-storage-check-" + self.job.name, + storageProbeKey=".fedeo-update-check/" + uuid.uuid4().hex + ".pdf") + self.compose(True, "run", "--pull", "never", "--rm", "--no-deps", "--name", self.state["storageCheckContainer"], + "-e", "FEDEO_UPDATE_PROBE_KEY=" + self.state["storageProbeKey"], + "--entrypoint", "node", "backend", "-e", S3_UPGRADE_CHECK, timeout=210) + self.save(storageProbeKey=None) + else: + self.compose(True, "run", "--pull", "never", "--rm", "--no-deps", "createbuckets") self.save("migrating", migrationContainer="fedeo-update-" + self.job.name) self.compose(True, "run", "--pull", "never", "--rm", "--no-deps", "--name", self.state["migrationContainer"], "-e", "FEDEO_RUN_MIGRATIONS=false", "--entrypoint", "npm", "backend", "run", "migrate") @@ -605,6 +939,8 @@ class Updater: self.compose(True, "up", "--pull", "never", "-d", "--wait", "--wait-timeout", "600", timeout=900) self.save("verifying") self.verify_matrix_stopped(retired=True) + if self.storage: + self.validate_storage(self.candidate, self.storage) for name, image in self.images.items(): container_id = self.compose(True, "ps", "--quiet", name).strip() container = self.inspect(container_id) @@ -618,6 +954,8 @@ class Updater: def execute(self, command, version=None, accept_compose=False, *, expected_fingerprint=None, request_id=None): with self.lock(): previous = read_json(self.home / "status.json", {}) + if previous.get("phase") == "storage_migration": + raise UpdateError("Speichermigration prüfen und nach vollständigem Abschluss adopt-storage ausführen; kein resolve verwenden.") if previous.get("recoveryRequired"): raise UpdateError("Ein vorheriges Update benötigt Wiederherstellung. status und docs/selfhost-updates.md prüfen; danach resolve ausführen.") self.job = self.home / (datetime.datetime.now().strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:8]) @@ -657,6 +995,8 @@ class Updater: raise UpdateError("Die manuell wiederhergestellte Version mit --version angeben.") with self.lock(): state = read_json(self.home / "status.json", {}) + if state.get("phase") == "storage_migration": + raise UpdateError("Speichermigration kann nicht mit resolve freigegeben werden; Status prüfen und nach Abschluss adopt-storage verwenden.") if not state.get("recoveryRequired"): raise UpdateError("Kein offener Wiederherstellungsfall.") self.job = Path(state["job"]) @@ -664,11 +1004,16 @@ class Updater: self.runner = self.runner_factory(self.job / "commands.log") self.override = next((self.root / name for name in ("docker-compose.override.yml", "docker-compose.override.yaml") if (self.root / name).exists()), None) current = self.config(False) + self.storage = self.storage_record() + self.storage_service = "objectstore" if self.storage else "minio" + if self.storage: + self.validate_storage(current, self.storage) self.public = current["services"]["frontend"]["environment"]["NUXT_PUBLIC_API_BASE"].removesuffix("/backend") - if state.get("migrationContainer"): - remaining = self.runner.run(["docker", "ps", "--all", "--quiet", "--filter", "name=^/" + state["migrationContainer"] + "$"]).strip() - if remaining: - raise UpdateError("Den verbliebenen Migrationscontainer zuerst prüfen und manuell entfernen.") + for key in ("migrationContainer", "storageCheckContainer"): + if state.get(key): + remaining = self.runner.run(["docker", "ps", "--all", "--quiet", "--filter", "name=^/" + state[key] + "$"]).strip() + if remaining: + raise UpdateError("Den verbliebenen Prüf-/Migrationscontainer zuerst prüfen und manuell entfernen.") # Older releases have /version and /health, but no frontend health endpoint. version_info = self.http.json(self.public + "/backend/version") if version_info.get("product") != "FEDEO" or version_info.get("version") != version: @@ -676,10 +1021,16 @@ class Updater: if self.http.json(self.public + "/backend/health").get("status") != "ok": raise UpdateError("Das wiederhergestellte Backend ist nicht erreichbar.") self.http.get(self.public + "/") + if state.get("storageProbeKey"): + if not self.storage or not re.fullmatch(r"\.fedeo-update-check/[a-f0-9]{32}\.pdf", state["storageProbeKey"]): + raise UpdateError("Ungültiger protokollierter S3-Prüfschlüssel; manuell prüfen.") + self.compose(False, "exec", "-T", "-e", "FEDEO_UPDATE_PROBE_KEY=" + state["storageProbeKey"], + "backend", "node", "-e", S3_CLEANUP_CHECK, timeout=90) + self.save(storageProbeKey=None) original_images = {c["Config"]["Labels"]["com.docker.compose.service"]: c["Image"] for c in read_json(self.job / "original-containers.json", [])} manifest = read_json(self.job / "release-manifest.json") - for name in ("backend", "frontend", "db", "minio"): + for name in ("backend", "frontend", "db", self.storage_service): container_id = self.compose(False, "ps", "--quiet", name).strip() container = self.inspect(container_id) if container_id else {} container_state = container.get("State", {}) @@ -702,9 +1053,7 @@ class Updater: 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 {} + restored = self.inspect(original["Id"]) 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")) @@ -724,7 +1073,7 @@ def main(argv=None): print("Python ab Version 3.10 wird benötigt.", file=sys.stderr) return 1 parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=("check", "apply", "status", "resolve")) + parser.add_argument("command", choices=("check", "apply", "status", "resolve", "adopt-storage")) parser.add_argument("--directory", default=os.environ.get("FEDEO_DIR", "/opt/fedeo")) parser.add_argument("--version", help="Zielversion, z. B. 3.1.0; check wählt sonst den neuesten stabilen Release") parser.add_argument("--accept-compose-replacement", action="store_true", help="Zuvor geprüften Austausch einer nicht verwalteten Compose-Datei akzeptieren") @@ -739,6 +1088,8 @@ def main(argv=None): result = updater.status() elif args.command == "resolve": result = updater.resolve(args.version) + elif args.command == "adopt-storage": + result = updater.adopt_storage() else: result = updater.execute(args.command, args.version, args.accept_compose_replacement) print(json.dumps(result, ensure_ascii=False, indent=2)) diff --git a/scripts/storage/README.md b/scripts/storage/README.md index 224e3a8f..e6f25b16 100644 --- a/scripts/storage/README.md +++ b/scripts/storage/README.md @@ -15,6 +15,8 @@ docker build -f scripts/storage/tests/Dockerfile.minio -t fedeo-minio-fixture:20 docker build -t fedeo-storage-tools:pilot scripts/storage node scripts/storage/tests/integration.mjs python3 -B scripts/storage/tests/host_integration.py +docker pull registry:2 +python3 -B scripts/update/tests/host_storage_integration.py ``` Der S3-Test verwendet ausschließlich neu angelegte Testcontainer mit künstlichen @@ -28,6 +30,14 @@ Der Host-Test verwendet einen echten PostgreSQL-/MinIO-/SeaweedFS-Stack und künstliche Anwendungscontainer. Er ersetzt keine fachliche Abnahme von FEDEO (PDF, WebDAV, Exporte, Mandantenrechte) auf der konkreten Instanz. +Der zusätzliche Updater-Test führt Migration, geprüfte Übernahme, zwei Updates +und Restore aus der regulären Updatesicherung aus. Er startet eine eigene lokale +Testregistry. Release-HTTP und öffentliche Versionsantworten sind Testdaten; +Docker Compose, Image-Registry, PostgreSQL, S3-Verträge und Restore sind echt. +Das neue Pilotpaket enthält auch die zusammengehörigen Host-Updater-Dateien. +Nach erfolgreicher Migration übernimmt `pilot.py adopt-updater` den Speicher; +bereits umgestellte Instanzen werden dafür nicht erneut migriert. + Die erste Übernahme ist auf 200.000 Objekte und 1 GiB pro Objekt begrenzt. Das größte Objekt wird auf Platte gespult; RAM-Verbrauch wächst nicht mit dessen Größe. Multipart-Übernahmen und Sonderkonfigurationen werden abgelehnt. diff --git a/scripts/storage/build-pilot.sh b/scripts/storage/build-pilot.sh index 97476d82..3666b006 100644 --- a/scripts/storage/build-pilot.sh +++ b/scripts/storage/build-pilot.sh @@ -15,10 +15,10 @@ storage_tools="fedeo-storage-tools:pilot-${storage_tools_id#sha256:}" storage_weed_local="fedeo-seaweedfs:pilot-${storage_weed_id#sha256:}" docker image tag "$storage_tools_id" "$storage_tools" docker image tag "$storage_weed_id" "$storage_weed_local" -cp "$storage_repo/scripts/selfhost-storage-migrate.py" "$storage_repo/scripts/selfhost-update.py" "$storage_output/scripts/" +cp "$storage_repo/scripts/"{selfhost-storage-migrate.py,selfhost-update.py,selfhost-update-service.py,selfhost-update-service-install.sh,fedeo-updater.service} "$storage_output/scripts/" cp "$storage_repo/scripts/storage/"{s3.mjs,cli.mjs,seaweedfs.json,start-seaweedfs.sh,package.json,package-lock.json,Dockerfile.seaweedfs,preserve-s3-content-type.patch} "$storage_output/scripts/storage/" cp "$storage_repo/scripts/storage/pilot.py" "$storage_output/pilot.py" -cp "$storage_repo/docs/minio-umstellung-pilot.md" "$storage_output/docs/" +cp "$storage_repo/docs/"{minio-umstellung-pilot.md,selfhost-updates.md} "$storage_output/docs/" docker image save -o "$storage_output/images.tar" "$storage_tools" "$storage_weed_local" python3 - "$storage_output" "$storage_tools" "$storage_weed_local" "$storage_weed" <<'PY' import hashlib,json,pathlib,sys diff --git a/scripts/storage/pilot.py b/scripts/storage/pilot.py index 70896972..a384a47e 100644 --- a/scripts/storage/pilot.py +++ b/scripts/storage/pilot.py @@ -20,7 +20,7 @@ def checksum(path): def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("command", choices=["load-images", "plan", "migrate", "activate", "resume-start", "rollback", "status"]) + parser.add_argument("command", choices=["load-images", "plan", "migrate", "activate", "resume-start", "rollback", "status", "adopt-updater"]) parser.add_argument("--directory", default="/opt/fedeo") parser.add_argument("--writers-exclusive", action="store_true") args = parser.parse_args() @@ -39,6 +39,11 @@ def main(): subprocess.run(["docker", "image", "inspect", image], check=True, stdout=subprocess.DEVNULL) print("Offline-Images geprüft und geladen. Nächster Schritt: plan.") return + if args.command == "adopt-updater": + # Install the matching service before releasing the legacy updater guard. + subprocess.run(["bash", str(ROOT / "scripts/selfhost-update-service-install.sh"), "--directory", args.directory], check=True) + raise SystemExit(subprocess.run([sys.executable, str(ROOT / "scripts/selfhost-update.py"), "adopt-storage", + "--directory", args.directory]).returncode) command = [sys.executable, str(ROOT / "scripts/selfhost-storage-migrate.py"), args.command, "--directory", args.directory] if args.command == "plan": command += ["--tools-image", manifest["toolsImage"]] diff --git a/scripts/update/tests/host_storage_integration.py b/scripts/update/tests/host_storage_integration.py new file mode 100644 index 00000000..a4dd80d8 --- /dev/null +++ b/scripts/update/tests/host_storage_integration.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +"""Real migration -> adoption -> two updates, including a backup restore, on synthetic data. + +Requires local storage test images and registry:2; never contacts a FEDEO instance. +Release HTTP/health responses are fixtures; Compose, registry, SDK, PostgreSQL, +backup, restore and provider validation use real processes and containers. +""" +import copy +import importlib.util +import json +from pathlib import Path +import subprocess +import tarfile +import tempfile + +ROOT = Path(__file__).resolve().parents[3] +spec = importlib.util.spec_from_file_location('storage_fixture', ROOT / 'scripts/storage/tests/host_integration.py') +fixture = importlib.util.module_from_spec(spec) +spec.loader.exec_module(fixture) +m, docker = fixture.m, fixture.docker +u = m.u + + +class Http: + version = '3.4.0' + + def json(self, url): + return {'status': 'ok', 'product': 'FEDEO', 'version': self.version, 'revision': 'a' * 40} + + +class Releases: + def __init__(self, original, images): + self.original, self.images = original, images + + def download(self, version, destination): + config = copy.deepcopy(self.original) + for name, image in self.images.items(): + config['services'][name]['image'] = image['repository'] + ':fixture' + config['services']['frontend']['entrypoint'] = ['node'] + config['services']['frontend']['command'] = ['-e', 'setInterval(()=>{},1000)'] + for name in ('traefik', 'node-exporter'): + config['services'][name]['image'] = json.loads(docker('image', 'inspect', 'node:22-alpine'))[0]['Id'] + config['services']['backend']['environment']['FEDEO_VERSION'] = version + path = destination / 'fixture.json' + u.write_json(path, m.literal(config)) + rendered = docker('compose', '-f', path, 'config', '--format', 'yaml') + (destination / 'docker-compose.selfhost.yml').write_text(rendered) + for name in ('.env.example', 'selfhost-setup.sh', 'CHANGELOG.md'): + (destination / name).write_text('# Synthetic update fixture\n') + manifest = {'schemaVersion': 1, 'version': version, 'commit': 'a' * 40, + 'images': list(self.images.values()), 'migrations': []} + u.write_json(destination / 'release-manifest.json', manifest) + return manifest + + +def run(): + root, project = fixture.fixture() + registry = project + '-registry' + registry_id = None + work = Path(tempfile.mkdtemp(prefix='fedeo-storage-update-images-')) + try: + config = m.resolved(u.read_json(root / 'docker-compose.yml')) + config['services']['backend']['environment'].update(ENCRYPTION_KEY='fixture-key', COOKIE_SECRET='fixture-$-cookie', JWT_SECRET='fixture-jwt') + config['services']['frontend']['environment'] = {'NUXT_PUBLIC_API_BASE': 'https://fixture.example.test/backend'} + u.write_json(root / 'docker-compose.yml', m.literal(config)) + docker('compose', '-p', project, '-f', root / 'docker-compose.yml', 'up', '-d', '--pull', 'never', '--wait', '--wait-timeout', '90') + fixture.seed(root, project) + # Include the stopped Matrix orphan seen on the first real instance. + (root / 'matrix/postgres').mkdir(parents=True) + (root / 'matrix/postgres/retained.txt').write_text('retired Matrix') + retained = docker('create', '--pull', 'never', '--restart=no', '--network', project + '-internal', + '--label', 'com.docker.compose.project=' + project, + '--label', 'com.docker.compose.service=matrix-db', + '-v', str(root / 'matrix/postgres') + ':/var/lib/postgresql/data', 'node:22-alpine') + migration = m.Migration(root, 'fedeo-storage-tools:pilot') + with migration.engine.lock(): + migration.plan() + migration.migrate(True) + migration.activate() + print(json.dumps({'phase': 'migration_complete', 'evidence': str(root)}), flush=True) + for name in u.CORE_SERVICES: + migration.wait_ready(docker('compose', '-p', project, '-f', root / 'docker-compose.yml', 'ps', '-q', name)) + http = Http() + updater = u.Updater(root, http=http) + assert updater.adopt_storage()['status'] == 'adopted' + print(json.dumps({'phase': 'storage_adopted'}), flush=True) + + registry_id = docker('run', '-d', '--pull', 'never', '--name', registry, '-p', '127.0.0.1::5000', 'registry:2') + port = json.loads(docker('inspect', registry_id))[0]['NetworkSettings']['Ports']['5000/tcp'][0]['HostPort'] + repository = 'localhost:' + port + '/fedeo-update-fixture' + (work / 'Dockerfile').write_text('FROM fedeo-storage-tools:pilot\n' + 'RUN node -e "const fs=require(\'fs\');const p=JSON.parse(fs.readFileSync(\'package.json\'));' + 'p.scripts={migrate:\'node --version\'};fs.writeFileSync(\'package.json\',JSON.stringify(p))"\n' + 'ENTRYPOINT ["node"]\nCMD ["-e", "setInterval(()=>{},1000)"]\n') + docker('build', '-t', repository + ':fixture', work) + docker('push', repository + ':fixture') + digest = json.loads(docker('image', 'inspect', repository + ':fixture'))[0]['RepoDigests'][0].split('@')[1] + images = {name: {'name': name, 'repository': repository + '-' + name, 'digest': digest} for name in ('backend', 'frontend')} + for image in images.values(): + docker('tag', repository + ':fixture', image['repository'] + ':fixture') + docker('push', image['repository'] + ':fixture') + # Copy health, networking and SDK settings from the original fixture, but start the new image's migration script. + updater.releases = Releases(config, images) + for version in ('3.4.0', '3.5.0'): + http.version = version + checked = updater.execute('check', version) + assert checked['plan']['storageProvider'] == 'seaweedfs' + assert not checked['plan']['requiresComposeAdoption'] + result = updater.execute('apply', version, expected_fingerprint=checked['plan']['approvalFingerprint']) + assert result['status'] == 'succeeded' + assert not migration.inspect(migration.state['containers']['minio']['id'])['State']['Running'] + old_matrix = migration.inspect(retained) + assert not old_matrix['State']['Running'] and old_matrix['HostConfig']['RestartPolicy']['Name'] == 'no' + backup = Path(result['backup']) + with tarfile.open(backup / 'files.tar') as archive: + assert archive.extractfile('matrix/postgres/retained.txt').read() == b'retired Matrix' + assert 'storage/config/s3.json' in archive.getnames() + assert 'minio' not in archive.getnames() + metadata = u.read_json(backup / 'complete.json') + for name, expected in metadata['sha256'].items(): + assert u.file_sha(backup / name) == expected + print(json.dumps({'phase': 'update_complete', 'version': version, 'backup': str(backup)}), flush=True) + + # Restore the *updater's* cold archive, not the original migration backup. + restore = root / '.updates/update-restore-test' + restore.mkdir() + subprocess.run(['tar', '-xpf', str(backup / 'files.tar'), '-C', str(restore)], check=True) + store = updater.storage['service'] + restore_name = project + '-update-restore' + try: + docker('run', '-d', '--pull', 'never', '--name', restore_name, '--network', project + '-internal', + '--label', 'com.docker.compose.project=' + project, + '-v', str(restore / 'storage/seaweedfs') + ':/data', '-v', str(restore / 'storage/config') + ':/etc/fedeo:ro', + '-e', 'FEDEO_S3_CONFIG_SHA256=' + updater.storage['configSha256']['s3.json'], + '--entrypoint', store['entrypoint'][0], store['image'], *store['entrypoint'][1:], *store['command']) + migration.helper('restore-verify', endpoint='http://' + restore_name + ':9000') + finally: + docker('rm', '-f', restore_name) + print(json.dumps({'result': 'passed', 'restoredUpdaterBackup': str(backup), 'evidence': str(root)}), flush=True) + except BaseException: + print('Failure evidence: ' + str(root), flush=True) + for path in sorted((root / '.updates').glob('*/commands.log')): + print(str(path) + '\n' + path.read_text()[-5000:], flush=True) + raise + finally: + ids = docker('ps', '-aq', '--filter', 'label=com.docker.compose.project=' + project).split() + if ids: + docker('rm', '-f', '-v', *ids) + if registry_id: + docker('rm', '-f', '-v', registry_id) + docker('network', 'rm', project + '-internal') + + +if __name__ == '__main__': + run() diff --git a/scripts/update/tests/test_service.py b/scripts/update/tests/test_service.py index c006c7a7..61b06cad 100644 --- a/scripts/update/tests/test_service.py +++ b/scripts/update/tests/test_service.py @@ -70,6 +70,16 @@ class UnixConnection(http.client.HTTPConnection): class ServiceTests(unittest.TestCase): + def test_storage_fields_are_sanitized_and_new_phases_remain_compatible(self): + plan = s.public_plan({'storageProvider': 'seaweedfs', 'storageImageRetained': True, + 'storageBackupPaths': ['/private/storage'], 'service': {'secret': 'hidden'}}) + self.assertEqual(plan['storageProvider'], 'seaweedfs') + self.assertTrue(plan['storageImageRetained']) + self.assertNotIn('private', json.dumps(plan)) + self.assertNotIn('hidden', json.dumps(plan)) + self.assertEqual(s.public_state({'phase': 'checking_storage'})['phase'], 'validating') + self.assertEqual(s.public_state({'phase': 'storage_adopted'})['phase'], 'resolved') + def setUp(self): self.temp = tempfile.TemporaryDirectory() self.root = Path(self.temp.name) diff --git a/scripts/update/tests/test_storage_provider.py b/scripts/update/tests/test_storage_provider.py new file mode 100644 index 00000000..267c837c --- /dev/null +++ b/scripts/update/tests/test_storage_provider.py @@ -0,0 +1,278 @@ +"""Provider handover and update failure tests using real Compose parsing.""" +import copy +import json +from pathlib import Path +import subprocess +import tarfile +import unittest + +from test_updater import ROOT, Runner, u +import test_updater as base + + +class StorageRunner(Runner): + def __init__(self, log, http): + super().__init__(log, http) + self.storage = None + self.old_minio = None + self.db_image = 'sha256:' + 'd' * 64 + + def run(self, args, **kwargs): + if args[:2] == ['docker', 'inspect'] and args[2] in ('objectstore', 'minio', 'retained-minio', 'db'): + self.calls.append(args) + if args[2] == 'db': + return json.dumps([{'Image': self.db_image, 'Config': {'Labels': {'com.docker.compose.service': 'db'}}, + 'State': {'Running': True, 'Health': {'Status': 'healthy'}}}]) + return json.dumps([self.storage if args[2] == 'objectstore' else self.old_minio]) + if args[:3] == ['docker', 'image', 'inspect'] and u.IMAGE_ID.fullmatch(args[3]): + self.calls.append(args) + return args[3] if '--format' in args else json.dumps([{'Id': args[3], 'Size': 1000}]) + if args[:3] == ['docker', 'image', 'save']: + self.calls.append(args) + Path(args[args.index('-o') + 1]).write_bytes(b'preserved storage image') + return '' + if args[:2] == ['docker', 'compose']: + if 'ps' in args and args[-1] in ('objectstore', 'traefik', 'node-exporter'): + self.calls.append(args) + return args[-1] + if 'stop' in args and 'objectstore' in args: + self.storage['State']['Running'] = False + if 'start' in args or 'up' in args: + if 'objectstore' in args or ('up' in args and '--no-deps' not in args): + self.storage['State']['Running'] = True + result = super().run(args, **kwargs) + if 'ps' in args and 'backend\nfrontend' in result: + result += '\nobjectstore' + return result + + +class StorageTests(unittest.TestCase): + setUp = base.UpdateTests.setUp + tearDown = base.UpdateTests.tearDown + called = base.UpdateTests.called + run_update = base.UpdateTests.run_update + + def migrated(self): + self.runner = StorageRunner(self.root / 'log', self.http) + self.updater.runner_factory = lambda log: self.runner + self.job = self.root / '.updates/storage-migration' + self.job.mkdir() + (self.root / 'storage/config').mkdir(parents=True) + (self.root / 'storage/seaweedfs').mkdir() + for name in ('s3.json', 'start-seaweedfs.sh'): + (self.root / 'storage/config' / name).write_text('protected-' + name) + (self.root / 'storage/seaweedfs/data').write_bytes(b'complete volume data') + original = u.compose_strings(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']))) + u.write_json(self.job / 'original.json', original) + target = copy.deepcopy(original) + services = target['services'] + services.pop('minio'); services.pop('createbuckets') + services['db']['image'] = self.runner.db_image + services['backend']['environment'].update(AWS_REQUEST_CHECKSUM_CALCULATION='WHEN_REQUIRED', FEDEO_RUN_MIGRATIONS='false') + services['backend']['depends_on'] = {'db': {'condition': 'service_healthy'}, 'objectstore': {'condition': 'service_healthy'}} + weed = u.read_json(ROOT / 'scripts/storage/seaweedfs.json') + weed.update(image='sha256:' + 'e' * 64, container_name='fedeo-objectstore', restart='unless-stopped', + networks={'internal': {'aliases': ['minio']}}, user='0:0', + environment={'FEDEO_S3_CONFIG_SHA256': u.file_sha(self.root / 'storage/config/s3.json')}, + volumes=[{'type': 'bind', 'source': str(self.root / 'storage/seaweedfs'), 'target': '/data'}, + {'type': 'bind', 'source': str(self.root / 'storage/config'), 'target': '/etc/fedeo', 'read_only': True}]) + services['objectstore'] = weed + u.write_json(self.job / 'target.json', u.compose_strings(target, escape=True)) + (self.root / 'docker-compose.yml').write_bytes((self.job / 'target.json').read_bytes()) + network = original['networks']['internal']['name'] + self.runner.storage = {'Id': 'objectstore', 'Image': weed['image'], + 'Config': {'Labels': {'com.docker.compose.service': 'objectstore'}, + 'Env': ['FEDEO_S3_CONFIG_SHA256=' + weed['environment']['FEDEO_S3_CONFIG_SHA256']]}, + 'State': {'Running': True, 'Health': {'Status': 'healthy'}}, 'HostConfig': {}, + 'NetworkSettings': {'Networks': {network: {'Aliases': ['minio']}}}, + 'Mounts': [{'Type': 'bind', 'Source': v['source'], 'Destination': v['target'], 'RW': not v.get('read_only')} for v in weed['volumes']]} + self.runner.old_minio = {'Id': 'retained-minio', 'Image': 'image-minio', + 'Config': {'Labels': {'com.docker.compose.service': 'minio'}}, 'State': {'Running': False}, + 'HostConfig': {'RestartPolicy': {'Name': 'no'}}, 'NetworkSettings': {'Networks': {}}} + self.runner.local_images.update([weed['image'], self.runner.db_image]) + state = {'schemaVersion': 1, 'phase': 'complete', 'provider': 'seaweedfs', 'writesReleased': True, + 'version': '3.0.0', 'restoreVerifiedAt': u.now(), 'weedImage': weed['image'], 'network': network, + 'fingerprint': {'.env': u.file_sha(self.root / '.env')}, 'count': 1, 'bytes': 20, + 'containers': {name: {'id': name, 'image': 'image-' + name} for name in u.CORE_SERVICES | {'minio'}}} + state['containers']['db']['image'] = self.runner.db_image + state['containers']['minio']['id'] = 'retained-minio' + u.write_json(self.job / 'copy/verified.json', {'count': 1, 'bytes': 20, 'verifiedAt': u.now(), 'objects': [{}]}) + for name in ('restore-contract', 'acceptance', 'backend-acceptance'): + u.write_json(self.job / name / 'result.json', {'operations': ['PUT', 'HEAD', 'GET', 'DELETE', 'SigV4', 'presigned-24h', 'anonymous-denied', 'wrong-key-denied']}) + u.write_json(self.job / 'backup/complete.json', {}) + (self.job / 'backup/seaweedfs.tar').write_bytes(b'verified cold restore archive') + state['seaweedBackupSha256'] = u.file_sha(self.job / 'backup/seaweedfs.tar') + u.write_json(self.job / 'state.json', state) + u.write_json(self.root / '.updates/status.json', {'status': 'failed', 'phase': 'storage_migration', + 'job': str(self.job), 'recoveryRequired': True}) + return state + + def adopt(self): + self.migrated() + self.assertEqual(self.updater.adopt_storage()['status'], 'adopted') + self.assertFalse(self.updater.status()['recoveryRequired']) + self.runner.calls.clear() + + def test_adoption_is_read_only_for_services_and_idempotent(self): + self.adopt() + self.assertEqual(self.updater.adopt_storage()['status'], 'already_adopted') + self.assertFalse(self.called('stop')) + self.assertFalse(self.called('pull')) + result = self.updater.execute('check', '3.1.0') + self.assertEqual(result['plan']['storageProvider'], 'seaweedfs') + self.assertFalse(result['plan']['requiresComposeAdoption']) + self.assertEqual(result['plan']['storageBackupPaths'], ['storage']) + + def test_incomplete_migration_cannot_be_adopted_or_resolved(self): + state = self.migrated() + for fields in ({'phase': 'released'}, {'writesReleased': False}, {'restoreVerifiedAt': None}): + u.write_json(self.job / 'state.json', {**state, **fields}) + with self.assertRaisesRegex(u.UpdateError, 'vollständig'): + self.updater.adopt_storage() + self.assertTrue(self.updater.status()['recoveryRequired']) + with self.assertRaisesRegex(u.UpdateError, 'adopt-storage'): + self.updater.resolve('3.0.0') + with self.assertRaisesRegex(u.UpdateError, 'adopt-storage'): + self.run_update() + + def test_adoption_rejects_modified_backup_missing_contract_and_changed_environment(self): + self.migrated() + for path in (self.job / 'backup/seaweedfs.tar', self.job / 'backend-acceptance/result.json', self.root / '.env'): + content = path.read_bytes() + path.write_text('{}') + with self.assertRaises(u.UpdateError): + self.updater.adopt_storage() + self.assertTrue(self.updater.status()['recoveryRequired']) + path.write_bytes(content) + self.assertFalse((self.root / '.updates/storage-provider.json').exists()) + + def test_update_retains_storage_and_database_and_backs_up_all_storage(self): + self.adopt() + self.runner.real_tar = True + result = self.updater.execute('apply', '3.1.0') + self.assertEqual(result['status'], 'succeeded') + self.assertFalse(self.runner.old_minio['State']['Running']) + self.assertTrue(self.runner.storage['State']['Running']) + self.assertFalse(any('minio' in image or 'seaweed' in image or u.IMAGE_ID.fullmatch(image) for image in self.runner.downloads)) + self.assertFalse(any('createbuckets' in a for a in self.runner.calls if 'run' in a)) + backup = Path(result['backup']) + with tarfile.open(backup / 'files.tar') as archive: + self.assertEqual(archive.extractfile('storage/seaweedfs/data').read(), b'complete volume data') + self.assertIn('storage/config/s3.json', archive.getnames()) + self.assertNotIn('minio', archive.getnames()) + self.assertTrue((backup / 'storage-image.tar').is_file()) + self.assertTrue((backup / 'config/.updates/storage-provider.json').is_file()) + calls = self.runner.calls + contract = next(i for i, a in enumerate(calls) if u.S3_UPGRADE_CHECK in a) + self.assertLess(contract, next(i for i, a in enumerate(calls) if 'migrate' in a)) + # A later update also preserves the provider, with no new adoption required. + (self.root / '.env').write_bytes(u.set_version((self.root / '.env').read_bytes(), '3.0.0')) + target = u.read_json(self.root / 'docker-compose.yml') + target['services']['backend']['environment']['FEDEO_VERSION'] = '3.0.0' + u.write_json(self.root / 'docker-compose.yml', target) + u.write_json(self.root / '.updates/installed.json', {'composeSha256': u.file_sha(self.root / 'docker-compose.yml')}) + self.assertEqual(self.updater.execute('apply', '3.1.0')['status'], 'succeeded') + + def test_storage_drift_and_restarted_minio_block_before_maintenance(self): + self.adopt() + original = self.runner.old_minio['State'].copy() + self.runner.old_minio['State']['Running'] = True + with self.assertRaisesRegex(u.UpdateError, 'stillgelegt'): + self.run_update() + self.runner.old_minio['State'] = original + (self.root / 'storage/config/s3.json').write_text('changed') + with self.assertRaisesRegex(u.UpdateError, 'Konfiguration'): + self.run_update() + self.assertFalse(self.called('stop')) + + def test_storage_config_changed_during_pull_blocks_before_maintenance(self): + self.adopt() + self.runner.on_pull = lambda: (self.root / 'storage/config/s3.json').write_text('changed') + with self.assertRaisesRegex(u.UpdateError, 'während'): + self.run_update() + self.assertFalse(self.called('stop')) + + def test_backup_failure_restarts_seaweed_without_restarting_minio(self): + self.adopt() + self.runner.fail = lambda a: a[0] == 'tar' and '-cpf' in a + with self.assertRaises(u.UpdateError): + self.run_update() + self.assertFalse(self.updater.status()['recoveryRequired']) + start = next(a for a in self.runner.calls if 'start' in a) + self.assertIn('objectstore', start) + self.assertNotIn('minio', start) + + def test_storage_must_remain_stopped_during_cold_backup(self): + self.adopt() + self.runner.on_stop = lambda: self.runner.storage['State'].update(Running=True) + with self.assertRaisesRegex(u.UpdateError, 'kalten Sicherung'): + self.run_update() + self.assertFalse(self.called('migrate')) + + def test_target_s3_failure_blocks_database_migrations_and_retains_recovery(self): + self.adopt() + self.runner.fail = lambda a: u.S3_UPGRADE_CHECK in a + with self.assertRaises(u.UpdateError): + self.run_update() + self.assertTrue(self.updater.status()['recoveryRequired']) + self.assertFalse(self.called('migrate')) + self.assertTrue(self.updater.status()['storageProbeKey'].startswith('.fedeo-update-check/')) + self.runner.fail = None + self.assertEqual(self.updater.resolve('3.1.0')['status'], 'recovered') + self.assertTrue(self.called(u.S3_CLEANUP_CHECK)) + self.assertIsNone(self.updater.status()['storageProbeKey']) + + def test_symlink_and_changed_database_major_are_rejected(self): + self.adopt() + link = self.root / 'storage/seaweedfs/external' + link.symlink_to(self.root / 'minio', target_is_directory=True) + with self.assertRaisesRegex(u.UpdateError, 'Links'): + self.run_update() + link.unlink() + self.http.files['docker-compose.selfhost.yml'] = self.http.files['docker-compose.selfhost.yml'].replace(b'postgres:16', b'postgres:17') + self.http.refresh() + with self.assertRaisesRegex(u.UpdateError, 'PostgreSQL'): + self.run_update() + self.assertFalse(self.called('stop')) + + def test_literal_dollars_survive_generated_compose(self): + self.adopt() + value = 'secret-$FOO-$$-with-dollar' + config = u.compose_strings(u.read_json(self.root / 'docker-compose.yml')) + config['services']['backend']['environment']['COOKIE_SECRET'] = value + u.write_json(self.root / 'docker-compose.yml', u.compose_strings(config, escape=True)) + text = (self.root / '.env').read_text() + import re + (self.root / '.env').write_text(re.sub(r'(?m)^COOKIE_SECRET=.*$', "COOKIE_SECRET='" + value + "'", text)) + result = self.run_update() + self.assertEqual(result['status'], 'succeeded') + self.assertEqual(self.updater.candidate['services']['backend']['environment']['COOKIE_SECRET'], value) + + def test_standard_installer_cannot_overwrite_a_planned_or_migrated_instance(self): + import os + original = (self.root / 'docker-compose.yml').read_bytes() + (self.root / 'scripts').mkdir() + setup = self.root / 'scripts/selfhost-setup.sh' + setup.write_bytes((ROOT / 'scripts/selfhost-setup.sh').read_bytes()) + for relative in ('storage', '.updates/storage-provider.json', '.updates/storage-migration/state.json'): + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('migration marker') + result = subprocess.run(['bash', str(ROOT / 'scripts/selfhost-install.sh')], capture_output=True, text=True, + env={**os.environ, 'FEDEO_DIR': str(self.root), 'FEDEO_TTY_INPUT': '/dev/null'}) + self.assertEqual(result.returncode, 1) + self.assertIn('nicht überschreiben', result.stderr) + self.assertEqual((self.root / 'docker-compose.yml').read_bytes(), original) + for args in (['--force', '--no-start'], ['--uninstall', '--purge']): + result = subprocess.run(['bash', str(setup), *args], capture_output=True, text=True, + env={**os.environ, 'FEDEO_TTY_INPUT': '/dev/null'}) + self.assertEqual(result.returncode, 1) + self.assertIn('nicht überschreiben oder entfernen', result.stderr) + self.assertTrue((self.root / '.env').is_file()) + path.unlink() + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/update/tests/test_updater.py b/scripts/update/tests/test_updater.py index 2718de7b..b1b422e5 100644 --- a/scripts/update/tests/test_updater.py +++ b/scripts/update/tests/test_updater.py @@ -138,6 +138,8 @@ class Runner: return result.stdout if 'ps' in args: if args[:2] == ['docker', 'ps']: + if any(a.startswith('label=com.docker.compose.project=') for a in args): + return 'backend\nfrontend\ndb\nminio\ntraefik\nnode-exporter\ncreatebuckets\n' + '\n'.join(self.matrix_containers) return '' if args[-1] in ('backend', 'frontend', 'db', 'minio') or args[-1] in self.matrix_containers: return args[-1]