feat(updater): support updates after verified SeaweedFS migration

This commit is contained in:
2026-09-28 15:47:06 +02:00
parent 790f20b499
commit 2b947c5e8f
20 changed files with 1074 additions and 67 deletions

View File

@@ -36,6 +36,7 @@ const state = z.object({
const plan = state.omit({ status: true }).extend({ const plan = state.omit({ status: true }).extend({
updateAvailable: z.boolean(), requiresComposeAdoption: z.boolean(), updateAvailable: z.boolean(), requiresComposeAdoption: z.boolean(),
estimatedBackupBytes: z.number().nonnegative().finite().optional(), 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", retiredServices: z.array(z.enum(["matrix-db", "matrix-synapse", "matrix-redis", "matrix-element",
"matrix-livekit", "matrix-rtc-jwt", "matrix-turn", "matrix-well-known"])), "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, const status = z.object({ apiVersion: z.literal(1), acceptingRequests: z.boolean(), engine: state,
installed: state.pick({ version: true, revision: true }), request: job.optional(), installed: state.pick({ version: true, revision: true }), request: job.optional(),
capabilities: z.object({ requestLogs: z.boolean() }).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({ const logs = z.object({ requestId, logs: z.array(z.object({
source: z.enum(["service", "events", "commands"]), content: z.string().max(6000), truncated: z.boolean(), source: z.enum(["service", "events", "commands"]), content: z.string().max(6000), truncated: z.boolean(),

View File

@@ -1,4 +1,7 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { HeadBucketCommand } from "@aws-sdk/client-s3"
import { s3 } from "../utils/s3"
import { secrets } from "../utils/secrets"
type MetricSample = { type MetricSample = {
labels: Record<string, string> labels: Record<string, string>
@@ -10,9 +13,6 @@ const metricLinePattern = /^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{([^}]*)\})?\s+(-?(?:\
const nodeExporterUrl = () => const nodeExporterUrl = () =>
(process.env.NODE_EXPORTER_URL || "http://node-exporter:9100").replace(/\/+$/, "") (process.env.NODE_EXPORTER_URL || "http://node-exporter:9100").replace(/\/+$/, "")
const s3EndpointUrl = () =>
(process.env.S3_ENDPOINT || "").replace(/\/+$/, "")
const parseLabels = (value = "") => { const parseLabels = (value = "") => {
const labels: Record<string, string> = {} const labels: Record<string, string> = {}
const labelPattern = /(\w+)="((?:\\"|[^"])*)"/g const labelPattern = /(\w+)="((?:\\"|[^"])*)"/g
@@ -63,20 +63,17 @@ const serviceState = (ok: boolean, detail?: Record<string, any>) => ({
...detail, ...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 controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs) const timeout = setTimeout(() => controller.abort(), timeoutMs)
try { try {
const response = await fetch(url, { signal: controller.signal }) if (!client || !bucket) return serviceState(false, { error: "S3-Dateispeicher ist nicht eingerichtet" })
return serviceState(response.ok, { await client.send(new HeadBucketCommand({ Bucket: bucket }), { abortSignal: controller.signal })
httpStatus: response.status, return serviceState(true, { provider: process.env.FEDEO_STORAGE_PROVIDER === "seaweedfs" ? "seaweedfs" : "s3" })
url, } catch {
})
} catch (err: any) {
return serviceState(false, { return serviceState(false, {
url, error: controller.signal.aborted ? "S3-Prüfung hat das Zeitlimit überschritten" : "S3-Bucket nicht erreichbar oder Zugriff verweigert",
error: err?.message || "HTTP-Abfrage fehlgeschlagen",
}) })
} finally { } finally {
clearTimeout(timeout) clearTimeout(timeout)
@@ -116,7 +113,6 @@ export const buildSystemStatus = async (server: FastifyInstance) => {
const uname = nodeMetrics?.get("node_uname_info")?.[0]?.labels || null const uname = nodeMetrics?.get("node_uname_info")?.[0]?.labels || null
const databaseCheck = await server.db.execute("SELECT NOW() as now") const databaseCheck = await server.db.execute("SELECT NOW() as now")
const minioUrl = s3EndpointUrl()
return { return {
checkedAt: checkedAt.toISOString(), checkedAt: checkedAt.toISOString(),
@@ -160,9 +156,8 @@ export const buildSystemStatus = async (server: FastifyInstance) => {
url: nodeExporterMetricsUrl, url: nodeExporterMetricsUrl,
error: nodeExporterError, error: nodeExporterError,
}), }),
minio: minioUrl ? await checkHttp(`${minioUrl}/minio/health/live`) : serviceState(false, { // Preserve the response key for older clients; check the authenticated S3 API for every provider.
error: "S3_ENDPOINT ist nicht gesetzt", minio: await checkFileStorage(),
}),
}, },
} }
} }

View File

@@ -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 => { 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, let payload: any = { requestId: id, logs: [{ source: "commands", content: "pull access denied", truncated: true,
path: "/private/commands.log" }], private: "omitted" }; path: "/private/commands.log" }], private: "omitted" };

View File

@@ -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/);
});

View File

@@ -27,19 +27,20 @@ Die fachliche Abnahme erfolgt zusätzlich auf der konkreten Instanz.
und die unveränderte Anwendung wieder starten. und die unveränderte Anwendung wieder starten.
**Der Pilot ist noch kein neuer Standardinstaller und kein reguläres **Der Pilot ist noch kein neuer Standardinstaller und kein reguläres
FEDEO-Release.** Der bisherige Anwendungs-Updater kennt nur MinIO. Seine Aufträge FEDEO-Release.** Ältere Anwendungs-Updater kennen nur MinIO. Ihre Aufträge
bleiben nach der Umstellung bewusst über `recoveryRequired` gesperrt, bis ein bleiben nach der Umstellung bewusst über `recoveryRequired` gesperrt. Dieser
getesteter Provider-Adapter installiert wird. Diesen Zustand nicht mit dem alten 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 `resolve` oder durch Löschen der Statusdatei aufheben. Ein alter Installer oder
ein altes Release-Compose darf die umgestellte Installation nicht überschreiben. ein altes Release-Compose darf die umgestellte Installation nicht überschreiben.
FEDEO 3.3.0 kann im Systemstatus weiterhin den MinIO-spezifischen Health-Endpunkt 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 abfragen und dadurch den Dateispeicher als gestört anzeigen. Die tatsächliche
Funktionsprüfung erfolgt im Pilot über signierte S3-Operationen. Herstellerneutrale Funktionsprüfung erfolgt im Pilot über signierte S3-Operationen. Dieser
Oberflächenchecks, eine Migrationskarte in der Oberfläche, automatische Updates Entwicklungsstand prüft den angemeldeten S3-Bucket auch im Systemstatus; dafür
auf SeaweedFS und der Standardinstaller sind nachfolgende Arbeitspakete. ist anschließend ein Anwendungsrelease mit dieser Änderung erforderlich.
Wer diese Einschränkungen noch nicht im Betrieb tragen möchte, führt zunächst Der Speicherwechsel selbst bleibt eine eigene betreute Hostaktion. Der bisherige
nur die Vorprüfung durch und bleibt mit der Instanz auf MinIO. Standardinstaller wird durch die Migration nicht ersetzt.
### Warum das Pilotimage eine gezielte Korrektur enthält ### 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 MinIO-Daten, alte Container, Test-Restores und Offline-Images werden vom Pilot
nicht automatisch gelöscht. 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 ## Nachgewiesene Tests und ausstehende Freigaben
Der lokale S3-Test verwendet das offizielle MinIO-Release-Binary vom 7. September 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 aber künstliche Anwendungsprozesse. Rückkehr vor Schreibfreigabe sowie vollständige
Umschaltung mit Restore und gesperrter Rückkehr nach Schreibfreigabe wurden erfolgreich Umschaltung mit Restore und gesperrter Rückkehr nach Schreibfreigabe wurden erfolgreich
durchlaufen. Die fachliche Abnahme von FEDEO auf der durchlaufen. Die fachliche Abnahme von FEDEO auf der
konkreten Instanz, ein physischer Hostneustart während jeder Phase, das reguläre konkreten Instanz, ein physischer Hostneustart während jeder Phase und die
Anwendungsupdate auf SeaweedFS und die vollständige Wiederherstellung auf einem vollständige Wiederherstellung auf einem anderen Host sind damit noch nicht
anderen Host sind damit noch nicht nachgewiesen. Deshalb bleibt dieser Stand ein 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. betreuter Pilot; die allgemeinen Freigabekriterien des Konzepts gelten weiter.

View File

@@ -6,20 +6,75 @@
- Linux/amd64, Python ab 3.10, Docker Compose ab 2.24, `tar` und `du`. - 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. - 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. - 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. - 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. - 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. 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. 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 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 ## 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`. 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. 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. 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. 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. 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. 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. 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. 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. 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: 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 ```bash
npm test npm test
npm run commits:check 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 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. 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.

View File

@@ -47,7 +47,8 @@ const reload = () => window.location.reload()
<p class="text-sm text-muted">Aktualisiert die gesamte Selfhost-Instanz einschließlich Docker Compose und Datenbank. Vor Änderungen wird ein Backup erstellt.</p> <p class="text-sm text-muted">Aktualisiert die gesamte Selfhost-Instanz einschließlich Docker Compose und Datenbank. Vor Änderungen wird ein Backup erstellt.</p>
<div role="status" aria-live="polite" class="space-y-3"> <div role="status" aria-live="polite" class="space-y-3">
<UAlert v-if="message" :color="busy ? 'warning' : 'neutral'" variant="soft" :title="message" /> <UAlert v-if="message" :color="busy ? 'warning' : 'neutral'" variant="soft" :title="message" />
<UAlert v-if="recovery" color="error" title="Wiederherstellung auf dem Host erforderlich" description="Weitere Updates sind gesperrt. Den letzten Auftrag und die Backups auf dem Host prüfen." /> <UAlert v-if="recovery && overview?.storage?.migrationPending" color="warning" title="Speicherumstellung auf dem Host abschließen" description="Updates bleiben gesperrt, bis die geprüfte Migration an den neuen Host-Updater übergeben wurde. Die bestehende FEDEO-Version kann danach regulär aktualisiert werden." />
<UAlert v-else-if="recovery" color="error" title="Wiederherstellung auf dem Host erforderlich" description="Weitere Updates sind gesperrt. Den letzten Auftrag und die Backups auf dem Host prüfen." />
<UAlert v-if="job?.error" color="error" :title="job.error.message" /> <UAlert v-if="job?.error" color="error" :title="job.error.message" />
<UAlert v-if="job?.plan?.requiresComposeAdoption" color="warning" title="Compose-Datei zuerst auf dem Host übernehmen" description="Die bestehende Konfiguration benötigt eine manuelle Prüfung mit dem CLI-Updater. Anschließend hier erneut nach Updates suchen." /> <UAlert v-if="job?.plan?.requiresComposeAdoption" color="warning" title="Compose-Datei zuerst auf dem Host übernehmen" description="Die bestehende Konfiguration benötigt eine manuelle Prüfung mit dem CLI-Updater. Anschließend hier erneut nach Updates suchen." />
<p v-if="job || busy" class="text-sm font-medium">{{ statusLabel }}</p> <p v-if="job || busy" class="text-sm font-medium">{{ statusLabel }}</p>
@@ -58,6 +59,8 @@ const reload = () => window.location.reload()
<div><dt class="text-muted">Commit</dt><dd class="font-mono" :title="job.plan.revision">{{ job.plan.revision?.slice(0, 8) }}</dd></div> <div><dt class="text-muted">Commit</dt><dd class="font-mono" :title="job.plan.revision">{{ job.plan.revision?.slice(0, 8) }}</dd></div>
<div><dt class="text-muted">Geschätztes Backup</dt><dd>{{ backupSize }}</dd></div> <div><dt class="text-muted">Geschätztes Backup</dt><dd>{{ backupSize }}</dd></div>
</dl> </dl>
<p v-if="job?.plan?.storageProvider === 'seaweedfs'" class="text-sm text-muted">Dateispeicher: SeaweedFS. Die geprüfte Speicherversion bleibt erhalten und wird zusammen mit der Datenbank gesichert.</p>
<p v-else-if="job?.plan?.storageProvider === 'minio'" class="text-sm text-muted">Dateispeicher: MinIO. Eine Umstellung auf SeaweedFS erfolgt als eigener, geprüfter Wartungsschritt auf dem Host.</p>
<p v-if="job?.plan?.retiredServices.length" class="text-sm text-muted">Alte Matrix-Dienste werden stillgelegt; Daten und Container bleiben erhalten: {{ job.plan.retiredServices.join(', ') }}.</p> <p v-if="job?.plan?.retiredServices.length" class="text-sm text-muted">Alte Matrix-Dienste werden stillgelegt; Daten und Container bleiben erhalten: {{ job.plan.retiredServices.join(', ') }}.</p>
<p v-if="job?.requestId" class="break-all text-xs text-muted">Auftrag: {{ job.requestId }}</p> <p v-if="job?.requestId" class="break-all text-xs text-muted">Auftrag: {{ job.requestId }}</p>
<section v-if="job?.requestId && !accessDenied" aria-label="Update-Protokoll" class="space-y-3 rounded-lg border border-default p-3"> <section v-if="job?.requestId && !accessDenied" aria-label="Update-Protokoll" class="space-y-3 rounded-lg border border-default p-3">

View File

@@ -5,13 +5,15 @@ export type UpdateState = {
export type UpdateJob = UpdateState & { export type UpdateJob = UpdateState & {
progress?: UpdateState; error?: { code: string; message: string }; progress?: UpdateState; error?: { code: string; message: string };
plan?: { version: string; revision: string; previousVersion?: string; updateAvailable: boolean; 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 }[] } export type UpdateLogs = { requestId: string; logs: { source: 'service' | 'events' | 'commands'; content: string; truncated: boolean }[] }
type Overview = { type Overview = {
acceptingRequests: boolean; engine: UpdateState; request?: UpdateJob; acceptingRequests: boolean; engine: UpdateState; request?: UpdateJob;
installed: { version?: string }; backend: { version: string; revision: string }; installed: { version?: string }; backend: { version: string; revision: string };
capabilities?: { requestLogs: boolean }; capabilities?: { requestLogs: boolean };
storage?: { provider: 'seaweedfs'; migrationPending: boolean };
} }
type Submission = { command: 'check' | 'apply'; body: { requestId: string; version?: string; checkRequestId?: string } } type Submission = { command: 'check' | 'apply'; body: { requestId: string; version?: string; checkRequestId?: string } }

View File

@@ -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 => { test('an older polling response cannot erase a new submission whose response was lost', async t => {
let hold = false, release let hold = false, release
const f = await setup(t, async (path, options) => { const f = await setup(t, async (path, options) => {

View File

@@ -163,6 +163,16 @@ download_file() {
mv "$temp_file" "$target" 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() { download_selfhost_files() {
echo echo
echo "Zielverzeichnis: $FEDEO_DIR" echo "Zielverzeichnis: $FEDEO_DIR"
@@ -177,6 +187,8 @@ download_selfhost_files() {
$SUDO chown "$(id -u):$(id -g)" "$FEDEO_DIR" "$FEDEO_DIR/scripts" $SUDO chown "$(id -u):$(id -g)" "$FEDEO_DIR" "$FEDEO_DIR/scripts"
fi fi
guard_storage_migration
if [[ -d "$FEDEO_DIR/.git" ]]; then 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." 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 fi
@@ -210,6 +222,7 @@ Dieser Installer bringt die typische Serverstruktur mit:
EOF EOF
guard_storage_migration
install_base_packages install_base_packages
ensure_docker ensure_docker
download_selfhost_files download_selfhost_files

View File

@@ -467,6 +467,14 @@ uninstall_stack() {
} }
main() { 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 if [[ "$UNINSTALL" == "true" ]]; then
uninstall_stack uninstall_stack
return return

View File

@@ -54,8 +54,10 @@ def identifier(value):
def public_state(state): def public_state(state):
"""An allowlist, never raw command logs, exceptions, host paths, config or diff contents.""" """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"} result = {"status": state.get("status") if state.get("status") in STATUSES else "idle"}
if state.get("phase") in PHASES: # Host tools are installed before the application update; old backend enums must still parse us.
result["phase"] = state["phase"] 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"): for key in ("recoveryRequired", "maintenance", "mutationStarted"):
if key in state: if key in state:
result[key] = bool(state[key]) result[key] = bool(state[key])
@@ -81,6 +83,9 @@ def public_plan(plan):
if type(plan.get("estimatedBackupBytes")) is int: if type(plan.get("estimatedBackupBytes")) is int:
result["estimatedBackupBytes"] = plan["estimatedBackupBytes"] result["estimatedBackupBytes"] = plan["estimatedBackupBytes"]
result["retiredServices"] = sorted(set(plan.get("retiredServices", [])) & engine.LEGACY_MATRIX_SERVICES) 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 return result
@@ -254,6 +259,11 @@ class Controller:
installed = engine.read_json(updater.home / "installed.json", {}).get("manifest", {}) installed = engine.read_json(updater.home / "installed.json", {}).get("manifest", {})
result["installed"] = {k: v for k, v in public_state({"version": installed.get("version"), result["installed"] = {k: v for k, v in public_state({"version": installed.get("version"),
"revision": installed.get("commit")}).items() if k != "status"} "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: if self.active:
result["request"] = self.request_status(self.active) result["request"] = self.request_status(self.active)
return result return result

View File

@@ -1,11 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""FEDEO host-side update engine. Python 3.10+, Docker Compose 2.24+; no pip packages. """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. All commands operate on an existing local selfhost installation, never a git checkout.
""" """
import argparse import argparse
import contextlib import contextlib
import copy
import datetime import datetime
import difflib import difflib
import fcntl import fcntl
@@ -31,6 +32,103 @@ FILES = ("release-manifest.json", "docker-compose.selfhost.yml", ".env.example",
GIB = 1024 ** 3 GIB = 1024 ** 3
LEGACY_MATRIX_SERVICES = {"matrix-db", "matrix-synapse", "matrix-redis", "matrix-element", LEGACY_MATRIX_SERVICES = {"matrix-db", "matrix-synapse", "matrix-redis", "matrix-element",
"matrix-livekit", "matrix-rtc-jwt", "matrix-turn", "matrix-well-known"} "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): class UpdateError(Exception):
@@ -283,7 +381,7 @@ class Updater:
return self.runner.run(prefix + list(args), **kwargs) return self.runner.run(prefix + list(args), **kwargs)
def config(self, candidate): 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): def inspect(self, name):
return json.loads(self.runner.run(["docker", "inspect", name]))[0] return json.loads(self.runner.run(["docker", "inspect", name]))[0]
@@ -292,6 +390,166 @@ class Updater:
if shutil.disk_usage(directory).free < required: if shutil.disk_usage(directory).free < required:
raise UpdateError(f"Zu wenig freier Speicher auf {directory}: mindestens {required // GIB + 1} GiB erforderlich.") 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): def prepare(self, version, *, allow_current=False):
for name in (".env", "docker-compose.yml"): for name in (".env", "docker-compose.yml"):
path = self.root / name path = self.root / name
@@ -345,14 +603,51 @@ class Updater:
raise UpdateError(f"Release-Compose enthält kein eindeutiges {name}-Image.") raise UpdateError(f"Release-Compose enthält kein eindeutiges {name}-Image.")
atomic(self.job / "target.compose.yml", compose) atomic(self.job / "target.compose.yml", compose)
atomic(self.job / "target.env", set_version((self.root / ".env").read_bytes(), self.target)) 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.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() 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] self.containers = [self.inspect(container) for container in ids]
write_json(self.job / "original-containers.json", self.containers) 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 self.retired_containers = [c for c in self.containers
if c["Config"]["Labels"].get("com.docker.compose.service") in self.retired_services] 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): 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.") 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 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 c["State"]["Running"] and c["Config"]["Labels"].get("com.docker.compose.oneoff", "False").lower() != "true"]
if not {"backend", "frontend", "db", "minio"}.issubset(self.running): if not {"backend", "frontend", "db", self.storage_service}.issubset(self.running):
raise UpdateError("Backend, Frontend, Datenbank und MinIO müssen vor dem Update laufen.") raise UpdateError("Backend, Frontend, Datenbank und Dateispeicher müssen vor dem Update laufen.")
if not set(self.running).issubset(self.current["services"]): if not set(self.running).issubset(self.current["services"]):
raise UpdateError("Nicht konfigurierte Container dieses Compose-Projekts laufen noch.") raise UpdateError("Nicht konfigurierte Container dieses Compose-Projekts laufen noch.")
self.before = {str(p): sha(p.read_bytes()) for p in 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 [])]} [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", 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()) '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]) 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 + minio_size + GIB 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: if self.matrix_backup_paths:
self.backup_size += int(self.runner.run(["du", "-sb", str(self.root / "matrix")]).split()[0]) self.backup_size += int(self.runner.run(["du", "-sb", str(self.root / "matrix")]).split()[0])
self.check_space(self.backup_size, self.home) self.check_space(self.backup_size, self.home)
@@ -387,6 +688,8 @@ class Updater:
"releaseNotes": str(self.job / "CHANGELOG.md"), "requiresComposeAdoption": not self.managed, "releaseNotes": str(self.job / "CHANGELOG.md"), "requiresComposeAdoption": not self.managed,
"estimatedBackupBytes": self.backup_size, "services": list(self.candidate["services"]), "estimatedBackupBytes": self.backup_size, "services": list(self.candidate["services"]),
"retiredServices": self.retired_services, "retainedDataPaths": self.matrix_backup_paths, "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, "updateAvailable": update_available,
# Kept in private job records; the service never exposes configuration fingerprints. # Kept in private job records; the service never exposes configuration fingerprints.
"approvalFingerprint": sha(json.dumps({"manifest": self.manifest, "configuration": self.before, "approvalFingerprint": sha(json.dumps({"manifest": self.manifest, "configuration": self.before,
@@ -396,7 +699,7 @@ class Updater:
def validate_configs(self): def validate_configs(self):
old, new = self.current["services"], self.candidate["services"] 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 = [] differences = []
for label, services in (("Bestehende Konfiguration", old), ("Zielkonfiguration", new)): for label, services in (("Bestehende Konfiguration", old), ("Zielkonfiguration", new)):
missing, additional = required - set(services), set(services) - required missing, additional = required - set(services), set(services) - required
@@ -410,7 +713,7 @@ class Updater:
if details: if details:
differences.append(label + " (" + "; ".join(details) + ")") differences.append(label + " (" + "; ".join(details) + ")")
if differences: 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.") + ". ".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.retired_services = sorted(set(old) & LEGACY_MATRIX_SERVICES)
self.matrix_backup_paths = [] self.matrix_backup_paths = []
@@ -431,7 +734,10 @@ class Updater:
raise UpdateError(f"Container-Identität von {name} würde verändert.") raise UpdateError(f"Container-Identität von {name} würde verändert.")
if self.current.get("networks") != self.candidate.get("networks"): if self.current.get("networks") != self.candidate.get("networks"):
raise UpdateError("Docker-Netzwerke würden verändert.") 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", []) 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): 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.") raise UpdateError(f"{name} benötigt das lokale Standard-Datenverzeichnis.")
@@ -448,7 +754,7 @@ class Updater:
healthcheck = new[name].get("healthcheck", {}) 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"]: 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.") 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"]): 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.") raise UpdateError("MinIO und mc müssen auf feste Digests gepinnt sein.")
env = new["backend"].get("environment", {}) env = new["backend"].get("environment", {})
@@ -522,13 +828,21 @@ class Updater:
atomic(directory / "config" / name, (self.root / name).read_bytes()) atomic(directory / "config" / name, (self.root / name).read_bytes())
if self.override: if self.override:
atomic(directory / "config" / self.override.name, self.override.read_bytes()) 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) write_json(directory / "containers.json", self.containers)
self.compose(False, "exec", "-T", "db", "sh", "-ec", self.compose(False, "exec", "-T", "db", "sh", "-ec",
'exec pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom', output=directory / "database.dump") '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", "pg_restore", "--list", input_file=directory / "database.dump")
self.compose(False, "exec", "-T", "db", "sh", "-ec", self.compose(False, "exec", "-T", "db", "sh", "-ec",
'exec pg_dumpall -U "$POSTGRES_USER" --globals-only', output=directory / "globals.sql") '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(): if (self.root / "traefik/letsencrypt").is_dir():
paths.append("traefik/letsencrypt") paths.append("traefik/letsencrypt")
self.runner.run(["tar", "-cpf", str(directory / "files.tar"), "-C", str(self.root), *paths], timeout=7200) 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()) os.fsync(stream.fileno())
checksums[str(path.relative_to(directory))] = digest.hexdigest() checksums[str(path.relative_to(directory))] = digest.hexdigest()
self.verify_matrix_stopped() 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", 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"], "retiredServices": self.retired_services, "retainedContainerIds": self.state["retiredContainerIds"],
"matrixDataPaths": self.matrix_backup_paths, "sha256": checksums}) "matrixDataPaths": self.matrix_backup_paths, "sha256": checksums})
self.save("backed_up", backup=str(directory)) self.save("backed_up", backup=str(directory))
@@ -570,12 +890,18 @@ class Updater:
# Mutable infrastructure tags must still be refreshed before entering maintenance. # Mutable infrastructure tags must still be refreshed before entering maintenance.
for policy in ("missing", "always"): for policy in ("missing", "always"):
services = [name for name, service in self.candidate["services"].items() 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: if services:
self.compose(True, "pull", "--policy", policy, *services, timeout=3600) 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) self.check_space(self.backup_size, self.home)
if any(sha(Path(path).read_bytes()) != value for path, value in self.before.items()): 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.") 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) self.save("maintenance", recoveryRequired=True, maintenance=True, runningServices=self.running)
# Stop every writer and public entry point. The database stays up for pg_dump. # 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"]) 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 / ".env.example", (self.job / ".env.example").read_bytes())
atomic(self.root / "scripts/selfhost-setup.sh", (self.job / "selfhost-setup.sh").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) 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, "up", "--pull", "never", "-d", "--no-deps", "--wait", "--wait-timeout", "180", "db", self.storage_service, "node-exporter")
self.compose(True, "run", "--pull", "never", "--rm", "--no-deps", "createbuckets") 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.save("migrating", migrationContainer="fedeo-update-" + self.job.name)
self.compose(True, "run", "--pull", "never", "--rm", "--no-deps", "--name", self.state["migrationContainer"], self.compose(True, "run", "--pull", "never", "--rm", "--no-deps", "--name", self.state["migrationContainer"],
"-e", "FEDEO_RUN_MIGRATIONS=false", "--entrypoint", "npm", "backend", "run", "migrate") "-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.compose(True, "up", "--pull", "never", "-d", "--wait", "--wait-timeout", "600", timeout=900)
self.save("verifying") self.save("verifying")
self.verify_matrix_stopped(retired=True) self.verify_matrix_stopped(retired=True)
if self.storage:
self.validate_storage(self.candidate, self.storage)
for name, image in self.images.items(): for name, image in self.images.items():
container_id = self.compose(True, "ps", "--quiet", name).strip() container_id = self.compose(True, "ps", "--quiet", name).strip()
container = self.inspect(container_id) 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): def execute(self, command, version=None, accept_compose=False, *, expected_fingerprint=None, request_id=None):
with self.lock(): with self.lock():
previous = read_json(self.home / "status.json", {}) 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"): if previous.get("recoveryRequired"):
raise UpdateError("Ein vorheriges Update benötigt Wiederherstellung. status und docs/selfhost-updates.md prüfen; danach resolve ausführen.") 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]) 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.") raise UpdateError("Die manuell wiederhergestellte Version mit --version angeben.")
with self.lock(): with self.lock():
state = read_json(self.home / "status.json", {}) 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"): if not state.get("recoveryRequired"):
raise UpdateError("Kein offener Wiederherstellungsfall.") raise UpdateError("Kein offener Wiederherstellungsfall.")
self.job = Path(state["job"]) self.job = Path(state["job"])
@@ -664,11 +1004,16 @@ class Updater:
self.runner = self.runner_factory(self.job / "commands.log") 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) 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) 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") self.public = current["services"]["frontend"]["environment"]["NUXT_PUBLIC_API_BASE"].removesuffix("/backend")
if state.get("migrationContainer"): for key in ("migrationContainer", "storageCheckContainer"):
remaining = self.runner.run(["docker", "ps", "--all", "--quiet", "--filter", "name=^/" + state["migrationContainer"] + "$"]).strip() if state.get(key):
if remaining: remaining = self.runner.run(["docker", "ps", "--all", "--quiet", "--filter", "name=^/" + state[key] + "$"]).strip()
raise UpdateError("Den verbliebenen Migrationscontainer zuerst prüfen und manuell entfernen.") 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. # Older releases have /version and /health, but no frontend health endpoint.
version_info = self.http.json(self.public + "/backend/version") version_info = self.http.json(self.public + "/backend/version")
if version_info.get("product") != "FEDEO" or version_info.get("version") != 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": if self.http.json(self.public + "/backend/health").get("status") != "ok":
raise UpdateError("Das wiederhergestellte Backend ist nicht erreichbar.") raise UpdateError("Das wiederhergestellte Backend ist nicht erreichbar.")
self.http.get(self.public + "/") 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"] original_images = {c["Config"]["Labels"]["com.docker.compose.service"]: c["Image"]
for c in read_json(self.job / "original-containers.json", [])} for c in read_json(self.job / "original-containers.json", [])}
manifest = read_json(self.job / "release-manifest.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_id = self.compose(False, "ps", "--quiet", name).strip()
container = self.inspect(container_id) if container_id else {} container = self.inspect(container_id) if container_id else {}
container_state = container.get("State", {}) container_state = container.get("State", {})
@@ -702,9 +1053,7 @@ class Updater:
for original in read_json(self.job / "original-containers.json", []): for original in read_json(self.job / "original-containers.json", []):
if original.get("Id") not in state.get("retiredContainerIds", []): if original.get("Id") not in state.get("retiredContainerIds", []):
continue continue
name = original["Config"]["Labels"]["com.docker.compose.service"] restored = self.inspect(original["Id"])
container_id = self.compose(False, "ps", "--all", "--quiet", name).strip()
restored = self.inspect(container_id) if container_id else {}
if (restored.get("Image") != original["Image"] if (restored.get("Image") != original["Image"]
or restored.get("State", {}).get("Running") != original["State"]["Running"] or restored.get("State", {}).get("Running") != original["State"]["Running"]
or (original["State"]["Running"] and restored.get("State", {}).get("Health", {}).get("Status") not in (None, "healthy")) or (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) print("Python ab Version 3.10 wird benötigt.", file=sys.stderr)
return 1 return 1
parser = argparse.ArgumentParser(description=__doc__) 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("--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("--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") 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() result = updater.status()
elif args.command == "resolve": elif args.command == "resolve":
result = updater.resolve(args.version) result = updater.resolve(args.version)
elif args.command == "adopt-storage":
result = updater.adopt_storage()
else: else:
result = updater.execute(args.command, args.version, args.accept_compose_replacement) result = updater.execute(args.command, args.version, args.accept_compose_replacement)
print(json.dumps(result, ensure_ascii=False, indent=2)) print(json.dumps(result, ensure_ascii=False, indent=2))

View File

@@ -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 docker build -t fedeo-storage-tools:pilot scripts/storage
node scripts/storage/tests/integration.mjs node scripts/storage/tests/integration.mjs
python3 -B scripts/storage/tests/host_integration.py 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 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 künstliche Anwendungscontainer. Er ersetzt keine fachliche Abnahme von FEDEO
(PDF, WebDAV, Exporte, Mandantenrechte) auf der konkreten Instanz. (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. 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 Das größte Objekt wird auf Platte gespult; RAM-Verbrauch wächst nicht mit dessen
Größe. Multipart-Übernahmen und Sonderkonfigurationen werden abgelehnt. Größe. Multipart-Übernahmen und Sonderkonfigurationen werden abgelehnt.

View File

@@ -15,10 +15,10 @@ storage_tools="fedeo-storage-tools:pilot-${storage_tools_id#sha256:}"
storage_weed_local="fedeo-seaweedfs:pilot-${storage_weed_id#sha256:}" storage_weed_local="fedeo-seaweedfs:pilot-${storage_weed_id#sha256:}"
docker image tag "$storage_tools_id" "$storage_tools" docker image tag "$storage_tools_id" "$storage_tools"
docker image tag "$storage_weed_id" "$storage_weed_local" 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/"{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/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" docker image save -o "$storage_output/images.tar" "$storage_tools" "$storage_weed_local"
python3 - "$storage_output" "$storage_tools" "$storage_weed_local" "$storage_weed" <<'PY' python3 - "$storage_output" "$storage_tools" "$storage_weed_local" "$storage_weed" <<'PY'
import hashlib,json,pathlib,sys import hashlib,json,pathlib,sys

View File

@@ -20,7 +20,7 @@ def checksum(path):
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) 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("--directory", default="/opt/fedeo")
parser.add_argument("--writers-exclusive", action="store_true") parser.add_argument("--writers-exclusive", action="store_true")
args = parser.parse_args() args = parser.parse_args()
@@ -39,6 +39,11 @@ def main():
subprocess.run(["docker", "image", "inspect", image], check=True, stdout=subprocess.DEVNULL) subprocess.run(["docker", "image", "inspect", image], check=True, stdout=subprocess.DEVNULL)
print("Offline-Images geprüft und geladen. Nächster Schritt: plan.") print("Offline-Images geprüft und geladen. Nächster Schritt: plan.")
return 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] command = [sys.executable, str(ROOT / "scripts/selfhost-storage-migrate.py"), args.command, "--directory", args.directory]
if args.command == "plan": if args.command == "plan":
command += ["--tools-image", manifest["toolsImage"]] command += ["--tools-image", manifest["toolsImage"]]

View File

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

View File

@@ -70,6 +70,16 @@ class UnixConnection(http.client.HTTPConnection):
class ServiceTests(unittest.TestCase): 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): def setUp(self):
self.temp = tempfile.TemporaryDirectory() self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name) self.root = Path(self.temp.name)

View File

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

View File

@@ -138,6 +138,8 @@ class Runner:
return result.stdout return result.stdout
if 'ps' in args: if 'ps' in args:
if args[:2] == ['docker', 'ps']: 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 '' return ''
if args[-1] in ('backend', 'frontend', 'db', 'minio') or args[-1] in self.matrix_containers: if args[-1] in ('backend', 'frontend', 'db', 'minio') or args[-1] in self.matrix_containers:
return args[-1] return args[-1]