--- title: 'Backup & Restore' --- # Backup & Restore ## Overview Die Backup & Restore-API ermöglicht das Exportieren eines kompletten Projekts (Collections + Media) als ZIP-Datei sowie den Import auf dem gleichen oder einem anderen Server. **Nur Admin-User** können diese Endpunkte nutzen (Server-Token, Projekt-Token mit `allowBackup`, oder JWT mit AdminRole). --- ## Authentifizierung Dreistufiges Auth-Modell (identisch mit anderen Admin-Endpunkten wie `reload`): ### 1. Server-Level Admin Token ```yaml api: adminTokens: - token: "backup-agent-token" label: "ci-backup" permissions: - project.backup # Nur Backup/Restore # - project.* # Alle Projekt-Operationen inkl. Backup ``` Gesendet via `X-Admin-Token` Header oder `Authorization: Bearer `. ### 2. Projekt-Level Admin Token ```yaml # In der Projekt-Config (config.yml) admin: tokens: - token: "ADMINTOKEN" allowReload: true allowRegenerateSearch: true allowBackup: true # NEU: erlaubt Backup/Restore ``` Das Feld `allowBackup` steuert gezielt, ob dieser Token Backup/Restore verwenden darf. ### 3. JWT Login mit AdminRole User mit `role: 0` (AdminRole) können Backup/Restore ausführen. --- ## Endpunkte | Methode | Pfad | Beschreibung | |--------|------|-------------| | **GET** | `/_/:project/_/admin/backup` | Backup als ZIP herunterladen | | **GET** | `/_/:project/_/admin/backup/download/:file` | Temp-ZIP aus SSE-Download abholen | | **POST** | `/_/:project/_/admin/backup` | ZIP hochladen & analysieren → `restoreToken` (SSE via `?sse=1`) | | **POST** | `/_/:project/_/admin/backup/:restoreToken` | Wiederherstellung ausführen | | **DELETE** | `/_/:project/_/admin/backup/:restoreToken` | Temp-Daten manuell aufräumen | --- ## 1. Backup Download ```text GET /_/:project/_/admin/backup?collections=contacts,pages&sse=1 ``` | Query-Param | Default | Beschreibung | |-------------|---------|-------------| | `collections` | _(alle)_ | Komma-separierte Collection-Namen (selektiver Export) | | `sse` | `0` | `1` = Fortschritt als SSE-Stream, `0` = direktes ZIP | ### Direkter Download (`sse=0`) Response: `Content-Type: application/zip` mit folgender Struktur: ``` backup--.zip ├── backup-info.json ├── collections/ │ ├── .json # Array aller Dokumente │ └── ... └── media/ # Nur Dateien im konfigurierten uploadPath └── / └── / └── / └── ``` **Wichtig:** Es werden NUR Media-Dateien exportiert, die tatsächlich im konfigurierten `uploadPath` der Collection existieren. Fehlende Dateien werden übersprungen und geloggt. Die Datei `backup-info.json` enthält Metadaten, die beim Import zur Schema-Validierung verwendet werden: ```json { "type": "tibi-backup", "version": 1, "sourceNamespace": "mein-projekt", "createdAt": "2026-06-24T12:00:00Z", "collections": { "contacts": { "count": 42, "fields": ["email", "firstname", "lastname", "phone"] } } } ``` ### SSE-Modus (`sse=1`) Response-Type: `text/event-stream` mit folgenden Events: ``` event: progress data: {"phase":"init","totalCollections":3} event: result data: {"phase":"done","downloadUrl":"/_/:project/_/admin/backup/download/","size":52428800,"expiresAt":"..."} ``` Der Client erhält eine temporäre Download-URL (TTL: 5 Minuten) und ruft die ZIP anschließend separat ab: ```bash # Schritt 1: SSE-Download initiieren curl -H "X-Admin-Token: $TOKEN" \ "http://localhost:8080/api/v1/_/myproject/_/admin/backup?sse=1" # Schritt 2: ZIP vom temp-Link abholen curl -H "X-Admin-Token: $TOKEN" \ -o backup.zip \ "http://localhost:8080/api/v1/_/myproject/_/admin/backup/download/" ``` Der Download-Link ist **One-Time-Use** — ein zweiter Request erhält 404. --- ## 2. Backup Upload & Analyse (Phase 1) ```text POST /_/:project/_/admin/backup Content-Type: multipart/form-data ``` **Body:** Feld `file` = backup.zip | Query-Param | Default | Beschreibung | |-------------|---------|-------------| | `sse` | `0` | `1` = Fortschritt via SSE streamen | ### Direkter Modus (`sse=0`) **Response (200):** ```json { "restoreToken": "a1b2c3d4e5f6...", "sourceNamespace": "quelle-projekt", "createdAt": "2026-06-24T12:00:00Z", "expiresAt": "2026-06-24T13:00:00Z", "collections": { "contacts": { "documentCount": 42, "mediaFileCount": 5, "backupFields": ["email","firstname","lastname","phone"], "targetFields": ["email","firstname","lastname","phone","department"], "warnings": [ "target has new fields not in backup: [department]" ], "exists": true }, "legacy_old": { "documentCount": 7, "mediaFileCount": 0, "backupFields": ["data"], "targetFields": null, "warnings": ["collection \"legacy_old\" does not exist in target project"], "exists": false } } } ``` **In dieser Phase werden KEINE Daten geschrieben.** Das ZIP wird nur entpackt und analysiert. Der `restoreToken` referenziert das Temp-Verzeichnis für die nächste Phase. ### SSE-Modus (`sse=1`) Response-Type: `text/event-stream` mit folgenden Events: ``` event: progress data: {"phase":"init","restoreToken":"a1b2c3d4e5f6..."} event: progress data: {"phase":"save"} event: progress data: {"phase":"extract"} event: progress data: {"phase":"validate"} event: progress data: {"phase":"analyze","totalCollections":2} event: result data: {"phase":"done","restoreToken":"a1b2c3d4e5f6...","sourceNamespace":"quelle-projekt","createdAt":"2026-06-24T12:00:00Z","expiresAt":"2026-06-24T13:00:00Z","collections":{...}} ``` ### Schema-Kompatibilitäts-Prüfung Die Prüfung ist **tolerant** — sie warnt, blockiert aber nicht: | Situation | Verhalten | |-----------|-----------| | Backup & Ziel identisch | Kein Warning | | Backup hat Felder, Ziel nicht | Warning (MongoDB speichert sie trotzdem) | | Ziel hat Felder, Backup nicht | Warning (Dokumente bekommen null/leer) | | Collection existiert nicht im Ziel | `exists: false`, wird beim Restore geskippt | --- ## 3. Restore ausführen (Phase 2) ```text POST /_/:project/_/admin/backup/:restoreToken?collections=contacts&merge=true&sse=1 ``` | Query-Param | Default | Beschreibung | |-------------|---------|-------------| | `collections` | _(alle)_ | Welche Collections aus dem Backup importieren | | `merge` | `false` | `true` = Dokumente updaten, Orphans behalten; `false` = exakter 1:1-Abgleich (Orphans löschen) | | `sse` | `0` | `1` = Fortschritt via SSE streamen | ### Direkter Restore (`sse=0`) **Response (200):** ```json { "restoredCollections": ["contacts"], "skippedCollections": ["legacy_old"], "totalDocuments": 42, "mediaFiles": 5 } ``` ### Restore-Modi **`merge=false` (Default) — Exakter 1:1-Abgleich:** - Jedes Dokument aus dem Backup wird via atomarem Upsert (`ReplaceOne`) eingefügt oder ersetzt - Nach dem Import werden alle Dokumente gelöscht, die vor dem Restore existierten, aber **nicht** im Backup enthalten sind (Orphans) - Ergebnis: Die Collection ist eine exakte 1:1-Kopie des Backups — jedes Feld, jeder BSON-Typ - Kein Zeitfenster, in dem die Collection leer ist (kein Pre-Delete mehr) - Bricht der Import ab, sind bereits verarbeitete Docs korrekt, unverarbeitete unangetastet **`merge=true` — Überschreiben, Orphans behalten:** - Existierende Dokumente mit gleicher `_id` werden **ersetzt** (atomares Upsert) - Neue `_id`s werden **angelegt** - Dokumente im Ziel, die nicht im Backup sind, bleiben **erhalten** - Geeignet für partielle Updates / Migration einzelner Collections ### SSE-Modus (`sse=1`) ``` event: progress data: {"phase":"init"} event: progress data: {"phase":"collection","collection":"contacts","action":"importing"} event: result data: {"restoredCollections":["contacts"],"skippedCollections":[],"totalDocuments":42,"mediaFiles":5,"phase":"done"} ``` --- ## 4. Temp-Daten aufräumen ```text DELETE /_/:project/_/admin/backup/:restoreToken ``` Explizites Cleanup des Temp-Verzeichnisses. Antwort: `{"message":"ok"}`. **Automatisches Cleanup:** Ein Background-Job (5-Minuten-Takt) löscht Temp-Verzeichnisse, deren TTL abgelaufen ist: - **Restore-Temp-Dirs**: 1 Stunde TTL - **Download-ZIPs**: 5 Minuten TTL --- ## Typischer Workflow ### Szenario A: Projekt von Staging nach Production kopieren ```bash # 1. Auf Staging: Backup herunterladen curl -H "X-Admin-Token: $STAGING_TOKEN" \ -o staging-backup.zip \ "https://staging.example.com/api/v1/_/myproject/_/admin/backup" # 2. Auf Production: Backup hochladen & analysieren curl -H "X-Admin-Token: $PROD_TOKEN" \ -F "file=@staging-backup.zip" \ "https://prod.example.com/api/v1/_/myproject/_/admin/backup" # → {"restoreToken":"abc...","collections":{...}} # 3. Prüfung der Analyse-Warnings, dann Restore ausführen curl -H "X-Admin-Token: $PROD_TOKEN" \ -X POST \ "https://prod.example.com/api/v1/_/myproject/_/admin/backup/abc..." # → {"restoredCollections":["contacts","pages"],"totalDocuments":84,"mediaFiles":12} ``` ### Szenario B: Nur eine Collection aktualisieren (Selective Restore) ```bash # 1. Full Backup herunterladen (enthält alle Collections) curl -H "X-Admin-Token: $TOKEN" -o full-backup.zip "..." # 2. Upload & Analyse RESTORE_TOKEN=$(curl -H "X-Admin-Token: $TOKEN" -F "file=@full-backup.zip" "..." | jq -r .restoreToken) # 3. Nur "contacts" wiederherstellen curl -H "X-Admin-Token: $TOKEN" -X POST \ "http://.../_/myproject/_/admin/backup/$RESTORE_TOKEN?collections=contacts" ``` ### Szenario C: Mit Merge updaten (bestehende Daten erhalten) ```bash # merge=true: Backup-Docs ersetzen bestehende, neue hinzufügen, # aber Docs die nur im Ziel existieren bleiben erhalten curl -H "X-Admin-Token: $TOKEN" -X POST \ "http://.../_/myproject/_/admin/backup/$TOKEN?merge=true" ``` ### Szenario D: Mit SSE-Fortschritt (große Projekte) ```bash # Download mit Fortschrittsanzeige curl -H "X-Admin-Token: $TOKEN" -N \ "http://.../_/myproject/_/admin/backup?sse=1" # → event:progress + event:result mit downloadUrl # Restore mit Fortschrittsanzeige curl -H "X-Admin-Token: $TOKEN" -N -X POST \ "http://.../_/myproject/_/admin/backup/$TOKEN?sse=1" ``` --- ## ZIP-Struktur (Referenz) ``` backup--.zip ├── backup-info.json # Metadaten + Schema-Fingerprint ├── collections/ │ ├── contacts.json # Array aller Dokumente (mit _id) │ └── pages.json └── media/ # Nur Dateien im uploadPath └── contacts/ └── / └── / └── ``` Kein `project.json` oder `config.json` — das Projekt muss auf dem Zielsystem bereits existieren. --- ## Media-Erkennung Beim Export werden **nur** Dateien berücksichtigt, die: 1. Über ein `file`- oder `file[]`-Feld im Dokument referenziert sind 2. **Tatsächlich** im konfigurierten `uploadPath` existieren (`os.Stat` → OK) 3. Innerhalb des `uploadPath` liegen (Path-Traversal-Schutz) Algorithmus: ``` Für jedes Dokument einer Collection: Für jedes Feld vom Typ "file" oder "file[]": Relativer Pfad aus Feldwert extrahieren Absoluter Pfad = / os.Stat() → existiert → in ZIP aufnehmen Existiert nicht → skip, Log-Warnung ``` --- ## Sicherheit | Maßnahme | Beschreibung | |----------|-------------| | **Auth** | Server-Token, Projekt-Token, oder JWT AdminRole erforderlich | | **Path Traversal** | `filepath.Clean` + `strings.HasPrefix` bei ZIP-Extraktion und Media-Kopie | | **Temp-Files** | Automatischer Cleanup nach TTL, keine Akkumulation | | **One-Time Download** | SSE-Download-Links sind nach einmaligem Abruf ungültig | | **Media außerhalb uploadPath** | Pfade außerhalb des konfigurierten `uploadPath` werden ignoriert | --- ## Fehlerbehandlung | HTTP-Status | Ursache | |-------------|---------| | **400** | Keine Datei hochgeladen, ungültiges ZIP, fehlende `backup-info.json`, falscher Typ/Version | | **403** | Fehlende Berechtigung (`project.backup` oder `allowBackup`) | | **404** | `restoreToken` nicht gefunden oder abgelaufen, Projekt nicht gefunden | | **410** | Download-Link abgelaufen (nur bei `/download/:file`) | | **500** | Interner Fehler (DB-Lesefehler, ZIP-Erstellung fehlgeschlagen) | --- ## Temp-Datei-Management ``` Upload (Phase 1) → /tmp/tibi-backup// (TTL: 1h) Restore (Phase 2) → importiert, dann gelöscht DELETE /:restoreToken → sofort gelöscht TTL-Ablauf → Background-Cleanup (alle 5 Min) SSE Download → /tmp/tibi-backup-download/.zip (TTL: 5 Min) GET /download/:file → ausgeliefert + sofort gelöscht ``` --- ## Konfiguration ### Server-Level Token (config.yml) ```yaml api: adminTokens: - token: "backup-token" label: "backup-agent" permissions: - project.backup ``` `project.*` (Wildcard) inkludiert automatisch `project.backup`. ### Projekt-Level Token ```yaml admin: tokens: - token: "PROJEKT-TOKEN" allowBackup: true ``` ### Keine Projekt-Config-Änderung nötig Der Backup/Restore-Vorgang benötigt keine Änderungen an der Projekt-Config. Collections, Felder, uploadPath etc. werden aus der bestehenden Konfiguration gelesen. --- ## Implementierung ### Dateien | Datei | Inhalt | |-------|--------| | `internal/controller/admin_backup.go` | Alle Handler (Download, Analyze, Restore, Cleanup, SSE, Temp-Stores) | | `internal/controller/admin_backup_auth_test.go` | 12 Auth-Tests | | `internal/controller/admin_backup_analyze_test.go` | 8 Analyze- & Download-Tests | | `internal/controller/admin_backup_restore_test.go` | 9 Restore-Tests | | `internal/controller/admin_backup_integration_test.go` | 3 SSE/E2E-Tests | | `internal/controller/setup.go` | Routen-Registrierung | | `internal/models/project.go` | `AdminToken.AllowBackup` Feld | | `cmd/server/main.go` | `StartBackupCleanupJob()` Aufruf | ### Test-Statistik **33 Tests** (alle grün): - 12 Auth-Tests (Server-Token, Projekt-Token, JWT, Berechtigungen) - 6 Analyze-Tests (ZIP-Validierung, Schema-Diffs, unbekannte Collections) - 2 Download-Tests (direkt, selektiv) - 9 Restore-Tests (Import, Merge, Selective, Media-Copy, Cleanup) - 3 SSE/E2E-Tests (SSE-Download, DownloadFile, SSE-Restore) - 1 E2E-Test (skipped — Mock-Projekt-API-Limitierung) Keine Regression auf bestehenden Admin-Tests (`TestAdminReload`, `TestAdminAuthReload` etc.). --- ## Betrieb: Speicherverhalten seit Streaming-Umbau (2026-10) Erstellung und Wiederherstellung von Snapshots und Backups laufen in **O(Batch)** statt O(Collection): Keyset-Pagination (`_id $gt`, Sort `_id`), Datei-/ZIP-Streaming, sequenziell pro Collection. RAM ist keine Grenze mehr — auch 10-GB-Collections (z. B. Outdoorworks: `productVariant` 91k × ~117 KB mit `_search`-Bloat) laufen mit konstantem Heap. ### Batch-Konstanten (`internal/controller/snapshot_stream.go`) | Konstante | Wert | Wirkung | |---|---|---| | `SnapshotBatchDocs` | 500 | Docs pro DB-Batch und Restore-Batch | | `SnapshotBatchBytes` | 8 MiB | reserviert (Byte-Cap, greift pro großem Einzeldoc) | | `SnapshotBufSize` | 64 KiB | `bufio`-Puffer für Snapshot-Dateien | | `OrphanMapThreshold` | 200.000 | darunter In-RAM-Orphan-Set, darüber Temp-Collection in Ziel-DB | | `orphanChunkSize` | 500 | `$in`-Chunks für Orphan-Deletes | ### Empfehlungen - `GOMEMLIMIT` setzen (z. B. `GOMEMLIMIT=512MiB` im Container): begrenzt GC-Druck, Streaming braucht es nicht — aber es schützt vor Nachbar-Last. - Jeder Snapshot-Run loggt `snapshot : done documents=… heapGrowthMB=…` (`heapGrowthMB` = Heap-Wachstum über den Lauf, kein Peak; Peak ist per Konstruktion batch-begrenzt). - Retention `maxCount` behält die **neuesten** Einträge (Bugfix 2026-10-15: zuvor wurden fälschlich die neuesten gelöscht). ### Nachweise (Tests) - `TestExportStreaming_PeakScalesFlat` / `TestStreamRestore_PeakScalesFlat`: 10× Docs ⇒ kein 10× Peak (Kontrollmessung Alt-Pfad liegt ein Mehrfaches darüber). - E2E Roundtrips (Mock + Real-Mongo): Snapshot NonMerge/Merge, Backup Restore-Matrix, Download-Pipelines. - OOM-Fixture: `testdata/outdoorworks-oom/` (echte `_search`-Form, Stats, `dump-full.sh` für den 8-GB-Voll-Dump nach `/WM_Dev/.cortex/mcp-tmp/`).