Files
tibi-docs/src/content/docs/server/21-backup-restore.md
T

16 KiB
Raw Blame History

title
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

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 <token>.

2. Projekt-Level Admin Token

# 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

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-<namespace>-<timestamp>.zip
├── backup-info.json
├── collections/
│   ├── <collection>.json       # Array aller Dokumente
│   └── ...
└── media/                      # Nur Dateien im konfigurierten uploadPath
    └── <collection>/
        └── <docId>/
            └── <fieldName>/
                └── <filename>

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:

{
  "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/<token>","size":52428800,"expiresAt":"..."}

Der Client erhält eine temporäre Download-URL (TTL: 5 Minuten) und ruft die ZIP anschließend separat ab:

# 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/<token>"

Der Download-Link ist One-Time-Use — ein zweiter Request erhält 404.


2. Backup Upload & Analyse (Phase 1)

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):

{
  "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)

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):

{
  "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 _ids 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

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

# 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)

# 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)

# 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)

# 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-<namespace>-<timestamp>.zip
├── backup-info.json                # Metadaten + Schema-Fingerprint
├── collections/
│   ├── contacts.json               # Array aller Dokumente (mit _id)
│   └── pages.json
└── media/                          # Nur Dateien im uploadPath
    └── contacts/
        └── <documentId>/
            └── <fieldName>/
                └── <filename>

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 = <uploadPath> / <relativer 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/<restoreToken>/   (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/<token>.zip  (TTL: 5 Min)
GET /download/:file        → ausgeliefert + sofort gelöscht

Konfiguration

Server-Level Token (config.yml)

api:
  adminTokens:
    - token: "backup-token"
      label: "backup-agent"
      permissions:
        - project.backup

project.* (Wildcard) inkludiert automatisch project.backup.

Projekt-Level Token

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 <id>: 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/).