Initial: Tibi-Doku auf Starlight-Basis (Nova-Theme, Docker-Stack, Sync-Skripte)

This commit is contained in:
apairon committed 2026-10-16 11:43:04 +00:00
commit 58502aa3c8
78 files changed
+22860

No files matched your search

@@ -0,0 +1,534 @@
---
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 <token>`.
### 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-<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:
```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/<token>","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/<token>"
```
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-<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)
```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 <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/`).