Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
610719c316
96
.env.example
Normal file
96
.env.example
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
# Vorlage für die lokale Konfiguration von Syncova.
|
||||||
|
#
|
||||||
|
# Diese Datei enthält bewusst KEINE funktionsfähigen Zugangsdaten.
|
||||||
|
# Erzeuge deine persönliche .env mit einem Zufallspasswort über:
|
||||||
|
#
|
||||||
|
# make dev-env
|
||||||
|
#
|
||||||
|
# Die erzeugte .env ist von der Versionsverwaltung ausgeschlossen.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Allgemein
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Betriebsumgebung: development | test | demo | production
|
||||||
|
# Nur außerhalb von "production" sind Demo-Daten überhaupt zulässig.
|
||||||
|
SYNCOVA_ENV=development
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Datenbank (Control Plane)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SYNCOVA_DB_HOST=127.0.0.1
|
||||||
|
SYNCOVA_DB_PORT=5432
|
||||||
|
SYNCOVA_DB_NAME=syncova
|
||||||
|
SYNCOVA_DB_USER=syncova
|
||||||
|
|
||||||
|
# Es gibt bewusst keinen Standardwert. Ohne gesetztes Passwort startet kein Dienst.
|
||||||
|
SYNCOVA_DB_PASSWORD=
|
||||||
|
|
||||||
|
# TLS-Modus der Datenbankverbindung: disable | allow | prefer | require | verify-ca | verify-full
|
||||||
|
# Für den produktiven Betrieb ist mindestens "require" vorgesehen.
|
||||||
|
SYNCOVA_DB_SSLMODE=prefer
|
||||||
|
|
||||||
|
# Obergrenze gleichzeitiger Datenbankverbindungen.
|
||||||
|
SYNCOVA_DB_MAX_OPEN_CONNECTIONS=20
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTTP-API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Bindeadresse der API. Standardmäßig nur lokal erreichbar.
|
||||||
|
SYNCOVA_HTTP_LISTEN_ADDRESS=127.0.0.1:8080
|
||||||
|
|
||||||
|
# Für CORS zugelassene Herkünfte, kommasepariert.
|
||||||
|
# Eine Wildcard (*) ist nicht zulässig; in der Produktion ist HTTPS Pflicht.
|
||||||
|
SYNCOVA_HTTP_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
|
# Maximale Größe eines Request-Bodys in Bytes (Standard: 1 MiB).
|
||||||
|
SYNCOVA_HTTP_MAX_REQUEST_BODY_BYTES=1048576
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Logging
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Log-Level: debug | info | warn | error
|
||||||
|
SYNCOVA_LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Ausgabeformat: json | text
|
||||||
|
SYNCOVA_LOG_FORMAT=text
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Verschlüsselung
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Schlüssel für die verschlüsselte Ablage von Secrets (z. B. MFA-Secrets).
|
||||||
|
# Format: version:base64-32-byte, mehrere kommasepariert.
|
||||||
|
# Einen neuen Schlüssel erzeugt: make generate-key
|
||||||
|
#
|
||||||
|
# Achtung: Ohne diesen Schlüssel sind verschlüsselte Daten dauerhaft unlesbar.
|
||||||
|
# Es gibt bewusst keinen Standardwert.
|
||||||
|
SYNCOVA_ENCRYPTION_KEYS=
|
||||||
|
|
||||||
|
# Version, mit der neu verschlüsselt wird. Nur nötig, wenn mehrere Schlüssel
|
||||||
|
# hinterlegt sind (Schlüsselrotation).
|
||||||
|
# SYNCOVA_ENCRYPTION_CURRENT_KEY=v1
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Anmeldung und Sitzungen
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Lebensdauer des Zugriffstokens. Bewusst kurz, damit ein entwendetes Token
|
||||||
|
# schnell wertlos wird.
|
||||||
|
SYNCOVA_AUTH_ACCESS_TOKEN_TTL=15m
|
||||||
|
|
||||||
|
# Lebensdauer des Erneuerungstokens.
|
||||||
|
SYNCOVA_AUTH_REFRESH_TOKEN_TTL=12h
|
||||||
|
|
||||||
|
# Zeitfenster zwischen Passwortprüfung und zweitem Faktor.
|
||||||
|
SYNCOVA_AUTH_MFA_CHALLENGE_TTL=5m
|
||||||
|
|
||||||
|
# Fehlversuche bis zur Kontosperre und Dauer der Sperre.
|
||||||
|
SYNCOVA_AUTH_MAX_FAILED_LOGIN_ATTEMPTS=5
|
||||||
|
SYNCOVA_AUTH_LOCKOUT_DURATION=15m
|
||||||
|
|
||||||
|
# Fehlversuche je MFA-Herausforderung.
|
||||||
|
SYNCOVA_AUTH_MAX_MFA_ATTEMPTS=5
|
||||||
166
.github/workflows/ci.yml
vendored
Normal file
166
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
# Continuous Integration für Syncova.
|
||||||
|
#
|
||||||
|
# Die Pipeline muss jede Änderung prüfen, bevor sie in den Hauptzweig gelangt
|
||||||
|
# (SYNCOVA_IMPLEMENTATION_PLAN.md Phase 0).
|
||||||
|
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
# Ein laufender Durchlauf wird abgebrochen, sobald ein neuerer Commit erscheint.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
# Standardmäßig nur Lesezugriff: die Pipeline braucht keine Schreibrechte.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: '1.26'
|
||||||
|
NODE_VERSION: '22'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backend:
|
||||||
|
name: Backend (Go)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Die Tests laufen gegen eine echte PostgreSQL-Instanz, nicht gegen eine Attrappe.
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: syncova_test
|
||||||
|
POSTGRES_USER: syncova_test
|
||||||
|
# Nur für diesen kurzlebigen CI-Container; die Datenbank ist von außen nicht erreichbar.
|
||||||
|
POSTGRES_PASSWORD: ci-only-ephemeral-password
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U syncova_test -d syncova_test"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Formatierung prüfen
|
||||||
|
run: |
|
||||||
|
UNFORMATTED_FILES="$(gofmt -l apps/api packages migrations)"
|
||||||
|
if [ -n "$UNFORMATTED_FILES" ]; then
|
||||||
|
echo "Nicht formatierte Dateien gefunden:"
|
||||||
|
echo "$UNFORMATTED_FILES"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Statische Analyse
|
||||||
|
run: |
|
||||||
|
# node_modules ausschliessen: NPM-Pakete koennen eigenen Go-Code enthalten.
|
||||||
|
go vet $(go list ./... | grep -v '/node_modules/')
|
||||||
|
|
||||||
|
- name: Modulabhängigkeiten prüfen
|
||||||
|
run: |
|
||||||
|
go mod tidy
|
||||||
|
# Eine Änderung an go.mod/go.sum bedeutet: die Abhängigkeiten sind nicht eingecheckt.
|
||||||
|
git diff --exit-code go.mod go.sum
|
||||||
|
|
||||||
|
- name: Tests mit Race-Detector
|
||||||
|
env:
|
||||||
|
SYNCOVA_ENV: test
|
||||||
|
SYNCOVA_DB_HOST: 127.0.0.1
|
||||||
|
SYNCOVA_DB_PORT: '5432'
|
||||||
|
SYNCOVA_DB_NAME: syncova_test
|
||||||
|
SYNCOVA_DB_USER: syncova_test
|
||||||
|
SYNCOVA_DB_PASSWORD: ci-only-ephemeral-password
|
||||||
|
SYNCOVA_DB_SSLMODE: disable
|
||||||
|
# Nur für diesen CI-Lauf; schützt keine echten Daten.
|
||||||
|
SYNCOVA_ENCRYPTION_KEYS: 'v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
|
||||||
|
run: go test -race -coverprofile=coverage.out $(go list ./... | grep -v '/node_modules/')
|
||||||
|
|
||||||
|
- name: Migrationen gegen echte Datenbank prüfen
|
||||||
|
env:
|
||||||
|
SYNCOVA_ENV: test
|
||||||
|
SYNCOVA_DB_HOST: 127.0.0.1
|
||||||
|
SYNCOVA_DB_PORT: '5432'
|
||||||
|
SYNCOVA_DB_NAME: syncova_test
|
||||||
|
SYNCOVA_DB_USER: syncova_test
|
||||||
|
SYNCOVA_DB_PASSWORD: ci-only-ephemeral-password
|
||||||
|
SYNCOVA_DB_SSLMODE: disable
|
||||||
|
# Nur für diesen CI-Lauf; schützt keine echten Daten.
|
||||||
|
SYNCOVA_ENCRYPTION_KEYS: 'v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
|
||||||
|
run: |
|
||||||
|
# Der vollständige Zyklus belegt, dass Migrationen umkehrbar sind.
|
||||||
|
go run ./apps/api/cmd/syncova-migrate up
|
||||||
|
go run ./apps/api/cmd/syncova-migrate status
|
||||||
|
go run ./apps/api/cmd/syncova-migrate down
|
||||||
|
go run ./apps/api/cmd/syncova-migrate up
|
||||||
|
|
||||||
|
- name: Binaries bauen
|
||||||
|
run: go build $(go list ./... | grep -v '/node_modules/')
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
name: Frontend (React/TypeScript)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: apps/web
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: apps/web/package-lock.json
|
||||||
|
|
||||||
|
- name: Abhängigkeiten installieren
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Statische Analyse
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
- name: Auslieferungs-Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
security:
|
||||||
|
name: Sicherheitsprüfungen
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Bekannte Schwachstellen in Go-Abhängigkeiten
|
||||||
|
run: |
|
||||||
|
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
|
govulncheck $(go list ./... | grep -v '/node_modules/')
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: apps/web/package-lock.json
|
||||||
|
|
||||||
|
- name: Bekannte Schwachstellen in NPM-Abhängigkeiten
|
||||||
|
working-directory: apps/web
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
# Nur hohe und kritische Befunde brechen den Lauf ab.
|
||||||
|
npm audit --audit-level=high
|
||||||
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Lokale Konfiguration mit echten Zugangsdaten
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Build-Ergebnisse
|
||||||
|
/bin/
|
||||||
|
/dist/
|
||||||
|
apps/web/dist/
|
||||||
|
|
||||||
|
# Versehentlich im Wurzelverzeichnis gebaute Programme.
|
||||||
|
#
|
||||||
|
# "go build ./apps/api/cmd/syncova-repo" ohne -o legt das Programm hier ab. Es
|
||||||
|
# gehoert nicht in die Versionsverwaltung — und faellt beim Einchecken kaum auf,
|
||||||
|
# weil der Name wie eine Quelldatei aussieht.
|
||||||
|
/syncova-api
|
||||||
|
/syncova-migrate
|
||||||
|
/syncova-admin
|
||||||
|
/syncova-repo
|
||||||
|
/syncova-dr
|
||||||
|
/syncova-bench
|
||||||
|
/syncova-proxmox
|
||||||
|
/syncova-agent
|
||||||
|
|
||||||
|
# Abhängigkeiten
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Test- und Coverage-Artefakte
|
||||||
|
*.out
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Editor- und Betriebssystemdateien
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Zertifikate und Schlüssel gehören niemals in die Versionsverwaltung
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
81
CHANGELOG.md
Normal file
81
CHANGELOG.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# Änderungen
|
||||||
|
|
||||||
|
## V1 — 14. August 2026
|
||||||
|
|
||||||
|
Die erste Fassung. Sie sichert Dateisysteme unter Linux und Windows sowie
|
||||||
|
Gäste eines Proxmox-VE-Verbunds, prüft die Wiederherstellbarkeit und weist sie
|
||||||
|
nach.
|
||||||
|
|
||||||
|
### Was sie kann
|
||||||
|
|
||||||
|
**Sichern und Wiederherstellen.** Inhaltsabhängige Blockfindung, Deduplizierung
|
||||||
|
auch über verschlüsselte Bestände hinweg, zstd, AES-256-GCM, Streaming-Pipeline
|
||||||
|
mit Gegendruck. Zusatzsicherungen tragen ein vollständiges Manifest — ein
|
||||||
|
Restore liest genau eine Datei, es gibt keine Kette aufzulösen. Wiederherstellung
|
||||||
|
mit Vorabprüfung, Prüfpunkt und Fortsetzung nach Abbruch.
|
||||||
|
|
||||||
|
**Ein Repository, das ohne die Anlage auskommt.** Inhaltsadressierte Blöcke,
|
||||||
|
atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten. Fällt der
|
||||||
|
Control-Server samt Datenbank aus, lässt sich beides aus dem Repository
|
||||||
|
zurückholen.
|
||||||
|
|
||||||
|
**Vertrauen wird nachgewiesen, nicht behauptet.** Fünf Prüfarten bis zum
|
||||||
|
tatsächlichen Wiederherstellungstest, eine Einstufung, die nur mit
|
||||||
|
durchgeführtem Test auf `recoverable` steigt, und eine Bewertung, in die
|
||||||
|
Unbekanntes niemals als gut eingeht.
|
||||||
|
|
||||||
|
**Löschschutz mit gemessener Durchsetzungsstufe.** Was das Betriebssystem
|
||||||
|
nachweislich verhindert, wird gemeldet — nicht, was die Einstellung verspricht.
|
||||||
|
Dazu Legal Hold, Fristverlängerung ohne Verkürzungsmöglichkeit und
|
||||||
|
Aufbewahrungsregeln.
|
||||||
|
|
||||||
|
**Oberfläche, Kennzahlen, Meldungen, Berichte, Security Center** und eine
|
||||||
|
Ransomware-Heuristik, die meldet und niemals selbst handelt.
|
||||||
|
|
||||||
|
### Was sie ausdrücklich nicht kann
|
||||||
|
|
||||||
|
- **Kapazitätsprognose.** Die Kennzahl erscheint mit Begründung statt mit einer
|
||||||
|
Null.
|
||||||
|
- **Kopie an einen zweiten Ort (Backup Copy).** Nicht umgesetzt; das Security
|
||||||
|
Center führt den Bereich als ungeprüft und rechnet ihn nicht ein.
|
||||||
|
- **Changed Block Tracking bei Proxmox.** Proxmox gibt geänderte Blöcke nicht
|
||||||
|
über die REST-API heraus. Eine Zusatzsicherung eines Gasts spart deshalb
|
||||||
|
Platz, aber keine Lesezeit.
|
||||||
|
- **Erweiterte Attribute, POSIX-ACLs und SELinux-Kontexte.** Gehen bei einer
|
||||||
|
Sicherung verloren.
|
||||||
|
- **Harte Verknüpfungen** werden aufgelöst: Der Inhalt kommt vollständig
|
||||||
|
zurück, die Verknüpfung nicht.
|
||||||
|
- **VMware, Hyper-V, Kubernetes, M365, Object Storage, Synthetic Full.** Nicht
|
||||||
|
Teil dieser Fassung.
|
||||||
|
|
||||||
|
### Was gebaut, aber nicht auf echter Hardware gefahren wurde
|
||||||
|
|
||||||
|
Diese Punkte sind vollständig umgesetzt und gegen Nachbauten geprüft. Was fehlt,
|
||||||
|
ist die Ausführung auf der jeweiligen Plattform — und bis dahin gelten sie
|
||||||
|
nicht als freigegeben:
|
||||||
|
|
||||||
|
- **Der Windows-Dienst.** Er übersetzt für Windows und ist `vet`-sauber, wurde
|
||||||
|
aber nie geladen. Der Kommandozeilenweg und die gesamte Auftragsausführung
|
||||||
|
sind plattformunabhängig nachgewiesen.
|
||||||
|
- **Die systemd-Einheit.** Inhaltlich korrigiert, nie auf einem Linux-System
|
||||||
|
geladen.
|
||||||
|
- **Proxmox.** Entdecken, Sichern, Prüfen und bitgenaues Zurückschreiben laufen
|
||||||
|
durch — gegen einen Nachbau der API. **Ob eine wiederhergestellte Maschine
|
||||||
|
startet, ist ungeprüft.**
|
||||||
|
- **Der SSH-Zugriffsweg auf Proxmox-Knoten.** Die Fingerabdruckprüfung ist
|
||||||
|
getestet, eine echte Verbindung gab es nie.
|
||||||
|
|
||||||
|
### Eingefrorene Verträge
|
||||||
|
|
||||||
|
Ab dieser Fassung sind API (102 Endpunkte), Migrationen, Backup-Format und
|
||||||
|
Repository-Protokoll festgeschrieben. Jede Abweichung schlägt in einer Prüfung
|
||||||
|
an; Einzelheiten in `docs/release-candidate.md`.
|
||||||
|
|
||||||
|
### Bekannte Grenzen
|
||||||
|
|
||||||
|
- Das Manifest liegt vollständig im Speicher — rund 200 Byte je Blockverweis,
|
||||||
|
also etwa 1,5 GiB bei 10 TB Quelldaten.
|
||||||
|
- Bei vielen kleinen Dateien begrenzt `fsync` den Durchsatz auf rund 100 Dateien
|
||||||
|
je Sekunde. Das ist der Preis des Commit-Protokolls und kein Fehler.
|
||||||
|
- Weitergeleitete IP-Header werden ignoriert; hinter einem Reverse Proxy steht
|
||||||
|
im Auditprotokoll dessen Adresse.
|
||||||
595
CLAUDE.md
Normal file
595
CLAUDE.md
Normal file
@ -0,0 +1,595 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Status dieses Repositories
|
||||||
|
|
||||||
|
**Phase 0–23 sind implementiert. Bei Phase 7 (Proxmox) fehlt nur noch die Ausführung auf echter Hardware** — der Sicherungs- und Wiederherstellungsweg eines Gasts läuft vollständig durch, aber kein Test belegt, dass die wiederhergestellte Maschine **startet**. **Bei Phase 5 und 6 bleibt nur die Ausführung auf echten Systemen offen:** Der Windows-Dienst und die systemd-Einheit sind geschrieben und übersetzen, wurden aber nie geladen. Vorhanden sind Control-Plane-API, Health-Engine, Logging, PostgreSQL mit Migrationsframework, Argon2id-Anmeldung, TOTP-MFA mit Replay-Schutz, RBAC (7 Rollen / 34 Berechtigungen), Append-only-Audit, Brute-Force-Schutz, Login-UI, Dev-Umgebung und CI. Dazu die Repository Engine: inhaltsadressierte Chunks mit Deduplizierung, atomares Commit-Protokoll, Integritätsscan, Katalog-Wiederaufbau ohne Datenbank, gehärteter Modus. Dazu das versionierte Container-Format mit Export/Import und die Backup Engine mit inhaltsabhängigem Chunking, Deduplizierung trotz Verschlüsselung, zstd, AES-256-GCM und Streaming-Pipeline. Dazu der Agent mit Aufnahme, Betriebstoken, Lebendmeldung, Dateierfassung, Voll- und Zusatzsicherung, Wiederherstellung sowie der **Auftragsübermittlung vom Server** (Phase 5): Der Agent holt Aufträge ab, führt sie aus und meldet Fortschritt und Ergebnis zurück. **Die Windows-Dienstanbindung ist vollständig geschrieben (svc.Run mit Steuerbefehlen), übersetzt für Windows und ist `vet`-sauber — aber nie ausgeführt worden.** `make cross-build` prüft die Übersetzbarkeit für windows/amd64, linux/amd64 und linux/arm64 bei jedem Lauf. Dazu die Provider-Schnittstelle und der Proxmox-Provider samt Bestandsverwaltung, beiden Zugriffswegen auf die Sicherungsarchive (Dateizugriff und SSH), Sicherung eines Gasts über den Scheduler und Wiederherstellung auf einen Knoten — **dessen verpflichtender E2E-Meilenstein bleibt jedoch offen, weil kein echter Verbund zur Verfügung stand**, siehe `docs/proxmox.md`. Dazu Scheduler und Ausführungsschleife (Phase 8), die Recovery Engine mit Vorabprüfung, Prüfpunkt und Fortsetzung (Phase 9) sowie Prüfung und Recovery Assurance mit fünf Prüfarten, automatischem Wiederherstellungstest, Einstufung und Bewertung (Phase 10). Dazu Unveränderlichkeit mit gemessener Durchsetzungsstufe, Aufbewahrungsschutz, Legal Hold und Aufbewahrungsregeln (Phase 11). Dazu die Weboberfläche mit Übersicht, Wiederherstellungspunkten, Bestandslisten und benannten Lücken (Phase 12) sowie Kennzahlen mit zwölf Diagrammen über sieben Zeiträume (Phase 13) und das Meldungswesen mit zehn geprüften Regeln, Selbstauflösung sowie Zustellung per E-Mail und Webhook (Phase 14). Dazu das Security Center mit acht geprüften Bereichen, vollständigen Befunden und Verlauf der Bewertung (Phase 15) sowie die Ransomware-Heuristik mit sechs Signalen gegen einen robusten Basiswert je Kette, die meldet und niemals handelt (Phase 16). Dazu neun Berichte in CSV, JSON und selbst geschriebenem PDF (Phase 17) und Disaster Recovery mit Konfigurationssicherung im Repository, deren vier Szenarien real durchgespielt sind (Phase 18). Dazu die Härtung mit Abhängigkeits-, Statik- und Geheimnisprüfung, elf gefahrenen Angriffsarten, SSRF-Schutz, Zielverzeichnisprüfung, allgemeinem Ratenbegrenzer und TLS (Phase 19). Dazu die Leistungsmessung mit sieben gefahrenen Szenarien und benannten Messgrenzen (Phase 20) sowie das Chaos Testing mit neun Störungen gegen eine prüfbare Definition von „kontrolliert" (Phase 21). Dazu die eingefrorenen Verträge für API, Migrationen, Backup-Format und Repository-Protokoll, der erstmals gefahrene Upgrade- und Rollback-Test über Bestandsdaten sowie der vollständige Durchlauf mit Disaster-Recovery-, Security- und Performance-Review (Phase 22). Dazu das Auslieferungspaket für drei Zielplattformen samt Oberfläche, Migrationen und Dokumentation, die fünf fehlenden Auslieferungsdokumente und die Änderungsliste (Phase 23).
|
||||||
|
|
||||||
|
Die vier Spezifikationsdokumente sind die verbindliche Quelle der Wahrheit:
|
||||||
|
|
||||||
|
| Datei | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| `PROMPT.md` | Master-Prompt (152 Abschnitte, deutsch) — Produktphilosophie, Technologiewahl, Funktionsumfang, verbindliche Entwicklungsregeln |
|
||||||
|
| `SYNCOVA_ARCHITECTURE.md` | Servicezuschnitt, Provider-/Agent-Architektur, Backup-Format, Repository-Commit-Protokoll |
|
||||||
|
| `SYNCOVA_DATABASE.md` | Vollständiges PostgreSQL-Schema (Tabellen, FKs, empfohlene Indizes) |
|
||||||
|
| `SYNCOVA_API.md` | REST-API-Vertrag unter `/api/v1` (Response-Hülle, Endpunkte, Idempotenz, Pagination) |
|
||||||
|
| `SYNCOVA_IMPLEMENTATION_PLAN.md` | Phasen 0–23 mit Exit-Kriterien und Akzeptanz-Testmatrix |
|
||||||
|
|
||||||
|
Bei Widersprüchen gilt: `PROMPT.md` ist das Produktziel, die drei `SYNCOVA_*`-Dokumente sind dessen technische Konkretisierung.
|
||||||
|
|
||||||
|
## Befehle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev-env # .env mit zufälligem DB-Passwort UND Verschlüsselungsschlüssel
|
||||||
|
make dev-up # startet PostgreSQL (braucht laufenden Docker-Daemon)
|
||||||
|
make migrate-up # wendet Migrationen an
|
||||||
|
make create-admin USERNAME=admin # erster Administrator (kein Standardkonto im Code)
|
||||||
|
make run-api # API auf 127.0.0.1:8080
|
||||||
|
make web-install # einmalig; danach: make web-dev (Oberfläche auf :5173)
|
||||||
|
make check # alle Prüfungen wie in der CI
|
||||||
|
make help # alle Ziele
|
||||||
|
```
|
||||||
|
|
||||||
|
Einzelnen Go-Test ausführen: `go test -race -run TestLoadRejectsWildcardCORSOrigin ./packages/platform/config`
|
||||||
|
Einzelnen Frontend-Test: `cd apps/web && npx vitest run src/api/client.test.ts`
|
||||||
|
|
||||||
|
**Wichtig für Go-Aufrufe:** niemals `./...` verwenden — `apps/web/node_modules` enthält Fremd-Go-Code, der sonst in Tests, Lint und Schwachstellenprüfung landet. Das Makefile filtert ihn über `GO_PACKAGES` heraus; bei direkten Aufrufen `$(go list ./... | grep -v '/node_modules/')` nutzen.
|
||||||
|
|
||||||
|
Tests in `packages/platform/config` rufen zuerst `isolateEnvironment(t)` auf. Das ist notwendig, weil das Makefile die lokale `.env` exportiert und die Tests sonst je nach Entwicklungsrechner unterschiedlich ausgingen.
|
||||||
|
|
||||||
|
**Beim Testen von Servern:** `go run` startet ein Kindbinary — ein `kill` auf die Shell-PID trifft nur den Wrapper und lässt den Port belegt. Für Tests `make build` nutzen und `./bin/syncova-api` direkt starten.
|
||||||
|
|
||||||
|
## Was Syncova ist
|
||||||
|
|
||||||
|
Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform für Proxmox VE, Windows, Linux, physische Systeme sowie Dateien/Ordner. VMware, Hyper-V, Kubernetes, M365 sind **nicht** Teil von V1, die Architektur muss sie aber später aufnehmen können.
|
||||||
|
|
||||||
|
Zentrales Produktprinzip, das nahezu jede Designentscheidung erklärt:
|
||||||
|
|
||||||
|
> Ein Backup gilt erst als vertrauenswürdig, wenn Integrität geprüft und Wiederherstellbarkeit nachgewiesen wurde.
|
||||||
|
|
||||||
|
## Technologie-Stack (vorgegeben)
|
||||||
|
|
||||||
|
- **Backend:** Go — Control Plane, Backup Engine, Agents, Repository Service, API, Scheduler, Monitoring. Rust nur später für begründete Low-Level-Komponenten.
|
||||||
|
- **Frontend:** React + TypeScript, responsive (Desktop/Laptop/Tablet), Dark Mode.
|
||||||
|
- **Datenbank:** PostgreSQL, ausschließlich Control Plane. Migrationstool: golang-migrate oder Atlas.
|
||||||
|
- **Kommunikation:** REST extern, gRPC intern wo sinnvoll, WebSocket/SSE für Live-Status (`/api/v1/events/stream`).
|
||||||
|
- **Deployment:** Linux-Server / VM / Backup-Appliance; Paketierung so, dass Container später möglich sind.
|
||||||
|
|
||||||
|
## Repository-Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/
|
||||||
|
api/ cmd/{syncova-api, syncova-migrate, syncova-admin, syncova-repo, syncova-dr, syncova-bench, syncova-proxmox}, internal/httpapi
|
||||||
|
web/ React + TypeScript (src/api, components, features, navigation, styles, types)
|
||||||
|
agent/ cmd/syncova-agent (Dienstadapter je Plattform)
|
||||||
|
worker/ leer — ab Phase 4
|
||||||
|
packages/
|
||||||
|
platform/ config, logging, database, health, crypto, totp (Querschnitt)
|
||||||
|
auth/ Identität, Sitzungen, RBAC, MFA
|
||||||
|
audit/ Append-only-Protokoll sicherheitsrelevanter Handlungen
|
||||||
|
backupexecutor/ verbindet die Ausführungsschleife mit der Backup Engine
|
||||||
|
jobs/ Sicherungsaufträge: Modell, Prüfung, PostgreSQL-Speicher, Schleife
|
||||||
|
providers/ herstellerneutrale Virtualisierungsschnittstelle + proxmox/
|
||||||
|
hypervisor/ eingerichtete Virtualisierungsumgebungen: Zugangsdaten, Bestand, Gastwiederherstellung
|
||||||
|
ransomware/ statistische Auffälligkeitserkennung: Basiswert, sechs Signale
|
||||||
|
reports/ neun Berichte, formatunabhängiges Modell, CSV/JSON/PDF
|
||||||
|
disasterrecovery/ Konfigurationssicherung im Repository, Wiederaufbau der Control Plane
|
||||||
|
platform/netguard/ Sperre interner Zieladressen (SSRF)
|
||||||
|
platform/secretscan/ Geheimnissuche im Quellbestand
|
||||||
|
benchmark/ Messmodell mit Ressourcenerfassung
|
||||||
|
chaos/ Definition und Prüfung kontrollierter Störungsergebnisse
|
||||||
|
recovery/ Vorabprüfung, Sitzungen mit Prüfpunkt, Wiederherstellungsschleife
|
||||||
|
scheduler/ Zeitpläne, Wartungsfenster, Warteschlange, Wiederholungen
|
||||||
|
repository/ Chunk-Ablage, Manifeste, Commit-Protokoll, Katalog, Integritätsscan
|
||||||
|
verification/ Prüfmaschine, Wiederherstellungstest, Einstufung, Bewertung
|
||||||
|
retention/ Aufbewahrungsregeln, Vorschau, Anwendung, Löschschutz
|
||||||
|
metrics/ Zeitreihen, Diagrammkatalog, Kennzahlenerfassung
|
||||||
|
alerting/ Regelwerk, Auswertung, Meldungen, Zustellung
|
||||||
|
security/ Sicherheitsprüfungen, Befunde, Security Score
|
||||||
|
backupformat/ portabler Container: Header, Abschnitte, Footer, Versionsverhandlung
|
||||||
|
backupengine/ Chunking, Kompression, Verschlüsselung, Pipeline, Wiederherstellung
|
||||||
|
agent/ Dateierfassung, Client, Betriebsschleife (plattformunabhängig)
|
||||||
|
agentregistry/ Serverseitige Agent-Verwaltung: Aufnahme, Tokens, Sperre
|
||||||
|
agenttasks/ Auftragsvermittlung zwischen Server und Agent (Pull, Backup und Restore)
|
||||||
|
migrations/ eingebettete SQL-Migrationen (embed.FS), 000001–000013 + checksums.txt (eingefroren)
|
||||||
|
scripts/ build-release.sh + Test auf Vollständigkeit der Auslieferung
|
||||||
|
deployment/ docker-compose für die lokale Umgebung
|
||||||
|
docs/ development.md, architecture.md, security.md, repository.md,
|
||||||
|
backup-format.md, backup-engine.md, agent.md, proxmox.md,
|
||||||
|
scheduler.md, recovery.md, verification.md, ransomware.md, immutability.md,
|
||||||
|
reports.md, disaster-recovery.md, hardening.md, performance.md, chaos.md,
|
||||||
|
agent-tasks.md, agent-installation.md, linux-agent.md, release-candidate.md,
|
||||||
|
installation.md, recovery-runbook.md, security-guide.md, api.md, troubleshooting.md,
|
||||||
|
web-ui.md, metrics.md, alerting.md, security-center.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`packages/` darf `apps/` **nie** importieren — nur so können Agent und Worker später dieselben Querschnittsdienste nutzen. Fachliche Pakete (`backup/`, `repository/`, `crypto/`, `providers/`) kommen ab Phase 2 unter `packages/` dazu.
|
||||||
|
|
||||||
|
## Architektur — die nicht-offensichtlichen Zusammenhänge
|
||||||
|
|
||||||
|
**Die Schichtung ist eine Abhängigkeitsregel, kein Diagramm.** Der Datenpfad lautet:
|
||||||
|
|
||||||
|
```text
|
||||||
|
API → Control/Scheduler → Backup Orchestrator → Provider|Agent → Backup Engine → Repository Service → Storage
|
||||||
|
```
|
||||||
|
|
||||||
|
Provider- und Agent-spezifischer Code darf **niemals** in die Backup Engine gelangen. Proxmox liegt hinter dem generischen `VirtualizationProvider`-Interface (`Connect`, `ListVMs`, `GetVMDisks`, `CreateSnapshot`, `ReadChangedBlocks`, `RestoreVM`, …); ein VMware-/Hyper-V-Provider muss sich ohne Änderung der Engine ergänzen lassen.
|
||||||
|
|
||||||
|
**PostgreSQL ist nicht die Quelle der Wahrheit für Backup-Daten.** Die DB hält Konfiguration, Jobs, Metadaten-Referenzen und Chunk-*Index*-Einträge — niemals Chunk-Payloads. Das Repository muss selbstbeschreibend und ohne die DB rekonstruierbar sein: `Attach Repository → Discover Format → Scan Manifests → Validate Chains → Rebuild Catalog → Restore`. Jede Designentscheidung, die diese Rebuild-Fähigkeit bricht, ist falsch — auch wenn sie bequemer ist.
|
||||||
|
|
||||||
|
**Der Commit ist ein Protokoll, kein Insert.** Session anlegen → Chunks schreiben → Manifest schreiben → Manifest verifizieren → Completion-Marker atomar setzen → Katalog aktualisieren → Erfolgszustand veröffentlichen. Ein Backup ohne gültigen Completion-Marker ist unvollständig, unabhängig davon, was in der DB steht.
|
||||||
|
|
||||||
|
**Die Backup-Pipeline ist streaming mit Backpressure:** `Read → Chunk → Hash → Dedup → Compress → Encrypt → Write → Manifest → Commit → Verify`. Vollständige Backups dürfen nie in den RAM geladen werden; zwischen den Stufen gehören begrenzte Worker-Pools, Checkpoints, Cancellation und Retry mit begrenztem exponentiellem Backoff.
|
||||||
|
|
||||||
|
**Backup-Format ist versioniert** (Header / Manifest / Chunk-Index / verschlüsselte Chunks / Footer mit Manifest-Hash und Completion-Marker) und muss Versionsverhandlung unterstützen. Breaking Changes ausschließlich über Formatversion.
|
||||||
|
|
||||||
|
## Verbindliche Entwicklungsregeln
|
||||||
|
|
||||||
|
Diese stammen aus `PROMPT.md` §§137–141 und `SYNCOVA_IMPLEMENTATION_PLAN.md` §28 und sind nicht verhandelbar:
|
||||||
|
|
||||||
|
1. Niemals ein abgeschlossenes Backup vortäuschen; Teilfehler heißen `PARTIAL FAILURE`, nie `SUCCESS`.
|
||||||
|
2. Integritätsfehler niemals verbergen — keine stillen Fehler, jeder Fehler wird klassifiziert (transient, permanent, integrity, auth, repository, source, network, configuration, security).
|
||||||
|
3. Backup-Payloads niemals in PostgreSQL.
|
||||||
|
4. Secrets niemals in Logs, Fehlermeldungen, API-Responses, Frontend oder Klartext-Datenbanken.
|
||||||
|
5. Autorisierung immer serverseitig prüfen; Frontend-Berechtigungen sind reine Anzeige.
|
||||||
|
6. Destruktive Aktionen niemals still — immer auditieren, ggf. Step-up-MFA.
|
||||||
|
7. Keine Fake-Features: Nicht Implementiertes wird als „Not implemented" gezeigt oder deaktiviert. Keine Mock-Daten, keine erfundenen Statistiken in Production.
|
||||||
|
8. Kein Backup-Feature ohne zugehörigen Recovery-Test freigeben.
|
||||||
|
9. Vor dem Code: Architektur, Abhängigkeiten, Datenmodelle, APIs, Sicherheitsrisiken, Plan — dann erst implementieren.
|
||||||
|
|
||||||
|
**Definition of Done** (§131): implementiert, Unit-Tests, ggf. Integrationstests, Fehlerbehandlung, Logging, Security geprüft, UI falls user-facing, API falls relevant, Dokumentation, Migration falls DB betroffen, Monitoring falls relevant, Recovery getestet falls Backup/Recovery betroffen.
|
||||||
|
|
||||||
|
## Bereits getroffene Implementierungsentscheidungen
|
||||||
|
|
||||||
|
Diese Punkte sind im Code verankert und sollten nicht ohne Grund umgeworfen werden:
|
||||||
|
|
||||||
|
- **Ein Datenbanktreiber für alles.** Verbindungspool *und* Migrationsläufe nutzen `pgx`. Würde man `golang-migrate` die Verbindung selbst öffnen lassen, käme dessen `lib/pq` zum Einsatz, das andere SSL-Modi kennt (`sslmode=prefer` schlägt dort fehl) — dieselbe Konfiguration funktionierte dann je nach Kommando oder nicht.
|
||||||
|
- **Die Antworthülle ist maßgeblich, nicht der HTTP-Status.** `GET /api/v1/health` liefert bei kritischem Zustand 503 *mit* vollständigem `data`-Bericht: Monitoring schlägt an, die Oberfläche kann trotzdem anzeigen, was kaputt ist. Der Frontend-Client wertet deshalb `error` vs. `data` aus, nicht `response.ok`.
|
||||||
|
- **Liveness prüft keine Abhängigkeiten.** Sonst löste eine kurz nicht erreichbare Datenbank einen Prozessneustart aus und verschlimmerte den Ausfall. Nur Readiness bewertet kritische Komponenten.
|
||||||
|
- **Fail Secure in der Health Engine:** eine Prüfung ohne gemeldeten Status, eine Zeitüberschreitung oder ein Panic gelten als `critical`, niemals als gesund.
|
||||||
|
- **Migrationen laufen nie beim Dienststart.** `syncova-api` prüft nur, ob der Schemastand zur Programmversion passt, und verweigert sonst den Start. `down` geht immer nur einen Schritt und verlangt in der Produktion `SYNCOVA_MIGRATE_CONFIRM_DOWN=yes`.
|
||||||
|
- **Secret-Redaction hängt an `slog.HandlerOptions.ReplaceAttr`** — dem einzigen Ort, an dem sie nicht vergessen werden kann. Verbindungszeichenketten gehen nur über `RedactedConnectionString()` nach außen.
|
||||||
|
- **Correlation ID vom Client wird nur übernommen, wenn sie eine gültige UUID ist**; sonst wird sie verworfen, damit keine fremden Zeichenketten in die Logs gelangen.
|
||||||
|
|
||||||
|
### Phase 1 (Identität und Sicherheit)
|
||||||
|
|
||||||
|
- **Opake Tokens statt JWT** — Grund ist die sofortige Widerrufbarkeit; ein JWT bliebe nach Sperre oder Passwortänderung bis zum Ablauf gültig. Gespeichert wird nur der SHA-256-Hash (Argon2id wäre hier nutzlos: 256 Bit Zufall lassen sich nicht erraten).
|
||||||
|
- **TOTP ist selbst implementiert**, verifiziert gegen alle zehn offiziellen RFC-6238-Testvektoren. Der Replay-Schutz (`last_used_time_step`) verhindert, dass ein abgefangener Code im selben 30-Sekunden-Fenster erneut gilt.
|
||||||
|
- **Auth und Berechtigung sind untrennbar** in `protectedHandler` verbunden — ein Endpunkt lässt sich nicht versehentlich ohne Berechtigungsprüfung einbinden.
|
||||||
|
- **Audit ist append-only per Datenbank-Trigger**, nicht nur per Anwendungslogik. Eine Nil-UUID wird vor dem Insert zu NULL — sonst verletzte der System-Akteur der Erstinbetriebnahme den Fremdschlüssel und das Ereignis ginge verloren.
|
||||||
|
- **Weitergeleitete IP-Header werden ignoriert.** Sie sind fälschbar; im Audit stünde sonst eine beliebige Adresse. Erst wenn feststeht, welchem Proxy zu trauen ist, darf sich das ändern.
|
||||||
|
- **Schein-Passwortprüfung bei unbekanntem Konto**, damit die Antwortzeit keine gültigen Anmeldenamen verrät.
|
||||||
|
- **Mitgelieferte Rollen sind unveränderlich**; eine Änderung verschöbe die Bedeutung bestehender Zuweisungen.
|
||||||
|
- **Der letzte Administrator ist geschützt** gegen Löschung, Deaktivierung und Rollenentzug.
|
||||||
|
- **Kein Standardkonto im Code.** Erster Administrator ausschließlich über `syncova-admin create-admin`.
|
||||||
|
|
||||||
|
### Phase 2 (Repository Engine)
|
||||||
|
|
||||||
|
- **Prüffrage für jede Designentscheidung:** Bliebe das Repository nutzbar, wenn Control Server und Datenbank ersatzlos verschwinden? `syncova-repo rebuild` baut den Katalog allein aus den Manifesten auf.
|
||||||
|
- **Chunk-Kennung ist der SHA-256-Inhaltshash.** Daraus folgen Deduplizierung ohne Index und Integritätsprüfung ohne Zusatzdaten. Kennungen werden vor jeder Pfadbildung validiert (Path Traversal).
|
||||||
|
- **Schreiben ist vierstufig:** Temp-Datei im Zielverzeichnis → `fsync` Datei → `rename` → `fsync` Verzeichnis. Ohne Schritt 2 und 4 überlebt eine Datei den Stromausfall unvollständig.
|
||||||
|
- **Das Backup wird erst mit dem Manifest sichtbar** (Schritt 5 von 7). Vorher liegen nur Chunks herum, die ein späterer Lauf wiederverwendet — es entsteht nie ein halbes, scheinbar gültiges Backup.
|
||||||
|
- **Manifest-Kennzahlen stammen immer aus der Session**, nie vom Aufrufer.
|
||||||
|
- **Der Katalog ist nur ein Beschleuniger** und wird bei Verlust, Beschädigung oder fremder Repository-Kennung stillschweigend neu gebaut.
|
||||||
|
- **Prune verlangt die Schreibsperre und bricht bei unlesbarem Manifest ab** — sonst würde auf unvollständiger Grundlage gelöscht.
|
||||||
|
- **Sperren werden nie automatisch gelöst**; `BreakLock` ist ein bewusster manueller Eingriff.
|
||||||
|
- **`DeduplicationRatio()` liefert zwei Werte** (Wert + ob definiert). Bei vollständiger Deduplizierung existiert kein endliches Verhältnis; ein stilles `0` stellte den besten Fall als den schlechtesten dar. Für Anzeigen `SavingsPercentage()` nutzen.
|
||||||
|
|
||||||
|
### Phase 3 (Backup-Format)
|
||||||
|
|
||||||
|
- **Der Footer steht am Ende — das ist der ganze Trick.** Ein abgeschnittener Container hat keinen und wird dadurch zuverlässig als unvollständig erkannt. Erst `Close()` macht einen Container gültig.
|
||||||
|
- **Jeder Abschnitt trägt Typ, Flags, Länge und Prüfsumme.** Eine spätere Version darf Abschnitte ergänzen; eine ältere überspringt unbekannte — **außer** sie tragen `FlagRequired`, dann wird die Verarbeitung verweigert statt Vollständigkeit vorzutäuschen.
|
||||||
|
- **`FlagDeferredDigest` löst das Streaming-Problem:** ein als Datenstrom geschriebener Abschnitt kennt seine Prüfsumme erst am Ende, der Kopf ist da längst geschrieben. Statt Rückspulen (was Pipes und Netzwerkziele ausschlösse) bleibt der Kopf-Digest leer; gesichert wird über Chunk-Hashes im Verzeichnis **und** die Gesamtprüfsumme im Footer.
|
||||||
|
- **Die Gesamtprüfsumme im Footer deckt auch einen ausgetauschten Abschnitt samt passender Einzelprüfsumme auf.** Zusätzlich prüft der Leser die Abschnittszahl — ein entfernter Abschnitt fällt damit auf.
|
||||||
|
- **Export bricht bei fehlendem oder beschädigtem Chunk ab**, statt einen lückenhaften Container zu erzeugen. Das Kommando löscht die Zieldatei dann wieder.
|
||||||
|
- **Import macht das Manifest erst nach allen Prüfungen sichtbar** — ein fehlgeschlagener Import hinterlässt allenfalls Chunks, nie ein scheinbar gültiges Backup.
|
||||||
|
|
||||||
|
### Phase 4 (Backup Engine)
|
||||||
|
|
||||||
|
**Deduplizierung und Verschlüsselung zugleich hat drei Fallen — alle drei sind gelöst, zwei davon erst nach einem fehlgeschlagenen Test:**
|
||||||
|
|
||||||
|
1. **Die Chunk-Kennung ist der Hash des Klartextes**, nicht des Geheimtextes. Sonst fänden zwei gleiche Ursprungsblöcke nie zusammen. Abgelegt wird die transformierte Form unter dieser Kennung.
|
||||||
|
2. **Der Datenschlüssel gehört zum Repository, nicht zum Backup** (`metadata/data-key.json`). Ein Schlüssel je Backup machte Deduplizierung über Backupgrenzen unmöglich — ein späteres Backup verwiese auf Blöcke mit fremdem Schlüssel. *Diesen Fehler hat ein Test aufgedeckt.*
|
||||||
|
3. **Die GCM-Nonce wird deterministisch aus dem Klartext abgeleitet** (`HMAC(nonceKey, plaintext)[:12]`). Ein Zähler ergäbe für denselben Klartext jedes Mal anderen Geheimtext. Eine Nonce wiederholt sich nur bei identischem Klartext — dann ist der Geheimtext ohnehin gleich; der gefährliche GCM-Fall tritt nicht ein.
|
||||||
|
|
||||||
|
Weiteres:
|
||||||
|
|
||||||
|
- **`Chunker.Next()` räumt den vorigen Block erst beim nächsten Aufruf** aus dem Puffer. Würde er das sofort tun, überschriebe das Nachrücken genau den ausgelieferten Bereich — der Aufrufer erhielte stillschweigend verfälschte Daten. *Auch das hat ein Test aufgedeckt.*
|
||||||
|
- **`ChunkReference.StoredDigest`** erlaubt es dem Integritätslauf, verschlüsselte Blöcke **ohne Schlüssel** zu prüfen.
|
||||||
|
- **Reihenfolge: erst komprimieren, dann verschlüsseln.** Umgekehrt liesse sich nichts mehr verkleinern.
|
||||||
|
- **Blöcke, die sich nicht verkleinern liessen, werden unkomprimiert abgelegt** (Marker-Byte). Bei Bildern und Archiven kostete Kompression sonst Platz.
|
||||||
|
- **Messungen nur mit inkompressiblen Daten.** Ein früherer Lauf zeigte 1021-fache Kompression — die Testdaten waren periodisch und die Zahl damit wertlos.
|
||||||
|
- **Bekannte Grenze: das Manifest liegt vollständig im Speicher** (~200 Byte je Blockverweis, also ~1,5 GiB bei 10 TB). Siehe `docs/backup-engine.md`.
|
||||||
|
|
||||||
|
### Phase 5 (Agent) — teilweise
|
||||||
|
|
||||||
|
**Wichtig: Das Exit-Kriterium „ein Windows-System kann gesichert und wiederhergestellt werden" ist NICHT erfüllt.** Der Agent wurde auf macOS entwickelt; `service_windows.go` ist ungeprüft und lässt den Agent im Vordergrund laufen. Auch die Backup-Ausführung durch den Agent fehlt noch — er ist derzeit ein angemeldeter Beobachter.
|
||||||
|
|
||||||
|
- **Zwei streng getrennte Tokenarten:** Aufnahme-Token (einmalig, 1 h, nur zur Registrierung) und Betriebstoken (dauerhaft, nur agentspezifische Rechte, einzeln widerrufbar). Real geprüft: Agent-Token gegen `/users`, `/roles`, `/audit-events` → 401; Benutzer-Token gegen `/agents/heartbeat` → 401.
|
||||||
|
- **Der Agent bestimmt seinen Namen nicht selbst** — er steht im Aufnahme-Token, sonst könnte er sich als anderes System ausgeben.
|
||||||
|
- **Netzunterbrechung:** Wartezeit wächst 5 s → max. 5 min; Logmeldung nur beim ersten und jedem zehnten Ausfall. Ein **abgelehntes Token** beendet den Agent dagegen — es behebt sich nicht durch Warten.
|
||||||
|
- **Erfassungsprobleme brechen den Lauf nicht ab, werden aber nie verschwiegen** (Exit-Status ≠ 0). Rechtefehler gesondert ausgewiesen.
|
||||||
|
- **Symlinks werden erfasst, aber nicht verfolgt** (Schleifengefahr, fremde Daten). Pfade immer mit Schrägstrich.
|
||||||
|
|
||||||
|
### Phase 5 — Auftragsübermittlung an Agenten
|
||||||
|
|
||||||
|
**Der Agent holt ab; der Server drückt nicht.** Ein Agent steht hinter einer Firewall, oft hinter NAT, und ist vom Server aus nicht erreichbar — jedenfalls nicht ohne eingehende Portfreigabe auf jedem gesicherten System. Die Verbindung geht immer vom Agenten aus, in derselben Richtung wie seine Lebendmeldung.
|
||||||
|
|
||||||
|
- **Fund — der Server sperrte das Repository für seinen eigenen Agenten.** Phase 8 öffnet es einmal je Lauf und hält die Schreibsperre; bei einer Delegation wartete der Server mit gehaltener Sperre darauf, dass der Agent hineinschreibt. Der Agent meldete `REPOSITORY_UNREACHABLE`, während das Repository einwandfrei dalag. Jetzt wird es nur geöffnet, wenn mindestens eine Quelle **serverseitig** gesichert wird (`hasServerSideSource`); `openRepository` ist in Auflösen und Öffnen getrennt.
|
||||||
|
- **Der Agent braucht Schreibzugriff auf das Repository.** Der Auftrag enthält den Pfad. Auf einem gemeinsamen Server ist das der lokale, bei getrennten Maschinen eine Freigabe. Ein Streaming-Protokoll zum Server wäre die Alternative und ist bewusst nicht gebaut: Es verdoppelt den Datenweg und macht den Control-Server zum Engpass jeder Sicherung.
|
||||||
|
- **Der Lauf wartet synchron auf den Agenten.** Nur einzustellen und den Lauf als erfolgreich zu vermerken ergäbe einen grünen Lauf, während der Agent noch arbeitet — oder bereits gescheitert ist.
|
||||||
|
- **Ein Teilfehler bleibt ein Teilfehler, auf drei Ebenen:** Der Agent meldet ihn als solchen, der Server berichtigt ein widersprüchliches Ergebnis (`TaskResult.Normalize`), und die Datenbank lehnt „erfolgreich mit übergangenen Objekten" per CHECK ab.
|
||||||
|
- **Ein Fehler ist eine Antwort.** Der Agent meldet auch im Fehlerfall zurück — wer schweigt, lässt den Lauf bis zur Frist hängen. Die Rückmeldung nutzt `context.WithoutCancel`, sonst ginge sie beim Beenden des Agenten verloren, obwohl das Ergebnis feststeht.
|
||||||
|
- **Verschlüsselung wird nie stillschweigend weggelassen.** Verlangt der Auftrag sie und fehlt dem Agenten das Schlüsselmaterial, wird abgelehnt statt unverschlüsselt ausgeführt.
|
||||||
|
- **Ein Auftrag je Agent** (Teilindex), **verwaiste Aufträge werden freigegeben, nicht wiederholt** (`AGENT_LOST`, transient — der Agent könnte bereits Blöcke geschrieben haben), **Fortschritt ist zugleich Lebendmeldung** und wird auf fünf Sekunden gedrosselt.
|
||||||
|
- **Ein fremder und ein abgeschlossener Auftrag ergeben dieselbe Antwort** — die Unterscheidung verriete, welche Auftragskennungen existieren.
|
||||||
|
- **Fund — der Agent liess sich fuer Windows gar nicht uebersetzen.** `unix.Statfs` gibt es dort nicht (`repository/health.go`, `recovery/validation.go`). Phase 5 war nie testbar — nicht wegen fehlender Hardware, sondern weil der Code nicht baute. Beide Stellen sind jetzt plattformgetrennt (`capacity_unix.go`/`capacity_windows.go`, `freespace_unix.go`/`freespace_windows.go`); `make cross-build` haengt in `make check` und faengt das in Sekunden.
|
||||||
|
- **Die Windows-Dienstanbindung ist jetzt echt.** Vorher ein Platzhalter, der nur im Vordergrund lief. Jetzt `svc.IsWindowsService()` zur Erkennung, `svc.Run` mit Handler, Zustandsmeldungen (StartPending → Running → StopPending → Stopped) und Behandlung von Stop, Shutdown und Interrogate. Ohne Dienstkontext laeuft derselbe Aufruf im Vordergrund — so dient er fuer Probelauf und Dienstbetrieb.
|
||||||
|
- **Wiederherstellung über den Agenten** (`task_type = 'restore'`) ist umgesetzt. Die drei Huerden gegen versehentliches Ueberschreiben liegen beim Server; der Agent prueft dagegen den **Zielpfad gegen die Systemverzeichnisse seines eigenen Systems** — das kann der Server nicht, er kennt sie nicht. Ein Windows-Agent hat andere Systempfade als ein Linux-Server.
|
||||||
|
- **Fund — der Auftragsinhalt einer Wiederherstellung kam nie beim Agenten an.** `claimedTaskResponse` trug nur das Sicherungsfeld; der Agent bekam einen leeren Repositorypfad und meldete folgerichtig „nicht erreichbar".
|
||||||
|
- **Eine unbekannte Auftragsart wird gemeldet, nicht geraten.** Sie als Sicherung zu behandeln waere die bequeme und gefaehrliche Wahl: Der Server bekaeme ein Ergebnis fuer etwas anderes, als er beauftragt hat.
|
||||||
|
- Nachgewiesen: Auftrag über die API angelegt, vom Agenten abgeholt und ausgeführt (3.500.014 Byte), Wiederherstellungspunkt in der Control Plane, Quelle gelöscht, **vom Agenten** bitgenau zurückgeholt. Wiederherstellung nach `/etc` vom Agenten abgewiesen (`RESTORE_TARGET_FORBIDDEN`, nichts angelegt). Siehe `docs/agent-tasks.md` und `docs/agent-installation.md`.
|
||||||
|
|
||||||
|
### Phase 6 — Unix-Eigenheiten
|
||||||
|
|
||||||
|
**Ein Socket ist kein Fehler.** Bis hierher galt jedes Erfassungsproblem als übergangenes Objekt und machte den Lauf zum Teilfehler — auch ein Socket. Auf einem Linux-System ist das ein Dauerzustand: In `/var/run` und `/tmp` liegen ständig Sockets. Wer `/var` sichert, bekäme bei **jedem** Lauf einen Teilfehler, und nach einer Woche klickt niemand mehr einen an.
|
||||||
|
|
||||||
|
- **`DiscoveryProblem.IsUnsupportedType` trennt Vermerk von Datenverlust.** Nicht lesbare Datei → Teilfehler (dort fehlen Daten). Socket, FIFO, Gerätedatei → Vermerk, kein Fehler (sie *gehören* nicht ins Backup). `IsPartialFailure()` zählt nur noch `DataLossProblemCount()`; auch der strenge Modus bricht nur bei echtem Verlust ab.
|
||||||
|
- **Verschwiegen wird trotzdem nichts.** `Summary()` nennt übergangene Sonderobjekte auch im Erfolgsfall, und die Meldung benennt den Typ in Worten — „prw-r--r--" sagt einem Betreiber nichts.
|
||||||
|
- **Eine FIFO wird nie gelesen.** Ein Leseversuch ohne Schreiber blockiert für immer; die Erfassung entscheidet anhand des Typs, bevor sie öffnet. Ein Test mit Zeitgrenze hält das fest.
|
||||||
|
- **Fund — die systemd-Einheit hätte nicht funktioniert.** Drei Fehler: `--state` bekam ein **Verzeichnis** statt einer Datei (der Agent hielte sich für registriert und liefe ohne Token); `ReadWritePaths` fehlte das **Repository** (bei `ProtectSystem=strict` hätte er die Quelle gelesen und dann keinen Block ablegen können — der Fehler erst am Ende des Laufs); `RestrictAddressFamilies` fehlte **AF_UNIX** (mit systemd-resolved schlägt die Namensauflösung fehl, während das Netz einwandfrei arbeitet).
|
||||||
|
- **Bekannte Grenze — harte Verknüpfungen werden aufgelöst.** Der Inhalt kommt vollständig zurück und liegt dank Deduplizierung nur einmal im Repository; die Verknüpfung geht verloren (Quelle 2 Verknüpfungen, Ziel 1). Umsetzbar über Inode-Erfassung und `link()` beim Zurückschreiben, griffe aber ins Manifestformat ein.
|
||||||
|
- Nachgewiesen mit Quellverzeichnis aus Dateien, Hardlink, Symlink, FIFO, Unix-Socket und gesperrtem Verzeichnis: Sicherung **erfolgreich** (kein Teilfehler wegen Sonderdateien), Wiederherstellung bitgenau, Symlink und Rechte erhalten, Sonderdateien korrekt nicht im Ziel. Siehe `docs/linux-agent.md`.
|
||||||
|
|
||||||
|
### Vertikale Scheibe (§27) — geschlossen
|
||||||
|
|
||||||
|
`syncova-agent backup` und `restore` verbinden Erfassung, Engine und Repository. Real nachgewiesen: sichern → prüfen → Quelle löschen → wiederherstellen → bitgenau identisch inkl. Rechten und Symlinks.
|
||||||
|
|
||||||
|
- **Verzeichnisse und Symlinks haben `Reader == nil`** und durchlaufen die Pipeline nicht — sie tragen nur Metadaten, gehören aber ins Manifest (sonst gehen leere Verzeichnisse und Rechte verloren).
|
||||||
|
- **Wiederherstellungsreihenfolge:** Verzeichnisse → Dateien → Symlinks → **Verzeichnisrechte zuletzt, von innen nach außen**. Ein nur lesbares Verzeichnis liesse sich sonst nicht mehr befüllen.
|
||||||
|
- **`validateManifestPath` schützt vor Pfadausbruch.** Ein Manifest kann aus einem fremden Repository stammen; `../../etc/passwd` schriebe sonst irgendwohin.
|
||||||
|
- **Ein nicht leeres Zielverzeichnis wird abgelehnt** (`ErrTargetNotEmpty`), Überschreiben verlangt `--overwrite`.
|
||||||
|
- **Dateien entstehen unter temporärem Namen und werden per `rename` sichtbar** — ein Abbruch hinterlässt keine halbe Datei unter dem echten Namen.
|
||||||
|
- **Ein Lauf mit übergangenen Objekten ist `IsPartialFailure()`** und liefert Exit-Status ≠ 0. `Summary()` sagt dann „TEILWEISE FEHLGESCHLAGEN".
|
||||||
|
|
||||||
|
### Phase 6 (Linux Agent) — Zusatzsicherung
|
||||||
|
|
||||||
|
**Der Gewinn einer Zusatzsicherung ist Zeit, nicht Speicher.** Ein zweiter Volllauf über unveränderte Daten legt dank Deduplizierung ohnehin 0 Byte ab. Gespart wird Lesen, Hashen, Komprimieren, Verschlüsseln: gemessen 190,7 MiB/1 815 ms gegen 4,8 MiB/133 ms bei einer geänderten von 41 Dateien. Wer das verwechselt, hält Zusatzsicherungen für überflüssig.
|
||||||
|
|
||||||
|
- **Das Manifest einer Zusatzsicherung ist vollständig**, nicht differenziell: unveränderte Objekte tragen die Blockverweise des Elternbackups. Deshalb liest ein Restore genau *ein* Manifest — es gibt keine Kette aufzulösen und keine zu zerreißen —, und das Löschen eines alten Backups kann ein neueres nicht beschädigen. Der Preis ist die Manifestgröße; sie wächst mit dem Bestand, nicht mit der Änderungsmenge.
|
||||||
|
- **`ReusedChunks` auf `BackupSource` prüft die Existenz jedes Blocks**, bevor es ihn übernimmt. Ohne die Prüfung entstünde ein Manifest, das sich als vollständig ausgibt, während seine Daten fehlen — auffallen würde das erst bei der Wiederherstellung.
|
||||||
|
- **Die Zeitstempel-Falle:** Eine Datei, die *während* des Elternlaufs geschrieben wurde, sieht bei sekundengenauer Auflösung unverändert aus. Deshalb gilt jedes Objekt als geändert, dessen Zeitstempel nicht **vor** `parentManifest.StartedAt` liegt. Im Zweifel wird gelesen.
|
||||||
|
- **`BytesReused` steht neben `BytesProcessed`, nicht darin.** Ein deduplizierter Block wurde gelesen und gehasht, ein übernommener nicht einmal geöffnet; ihn mitzuzählen ergäbe eine Leseleistung, die es nie gab.
|
||||||
|
- **Eine Zusatzsicherung ohne Elternbackup schlägt fehl** statt still zur Vollsicherung zu werden. Der Aufrufer glaubte sonst, eine Kette fortzuschreiben, und hätte eine neue begonnen.
|
||||||
|
- **Löschungen werden namentlich genannt.** Ein Backup, das eine verschwundene Datei stillschweigend weglässt, verwehrt genau die Beobachtung, für die man Backups anlegt.
|
||||||
|
- **`deployment/syncova-agent.service` ist auf macOS geschrieben und ungeprüft** — wie `service_windows.go`. `systemd-analyze verify` gibt es hier nicht.
|
||||||
|
|
||||||
|
**Wichtiger Fund an der Nahtstelle Phase 2/4:** Der Integritätsscan verglich den Hash der *gespeicherten* Form gegen die Kennung — die aber den *Klartext* beschreibt. Bei verschlüsselten Repositories meldete er dadurch **jeden** Chunk als beschädigt. Er nutzt jetzt `ChunkReference.StoredDigest` (`UniqueChunkReferences()` statt `UniqueChunkIdentifiers()`). Ein Prüfwerkzeug, das grundlos Alarm schlägt, wird bald nicht mehr ernst genommen.
|
||||||
|
|
||||||
|
### Phase 7 (Proxmox-Provider) — gebaut, Meilenstein auf echter Hardware offen
|
||||||
|
|
||||||
|
**Die Kette läuft durch: Auftrag → Provider → vzdump → Archiv als Datenstrom → Backup Engine → Repository → zurück auf den Knoten → `qmrestore`.** Ein Test belegt den Rundlauf bitgenau gegen einen Nachbau der API. **Ungeprüft bleibt der entscheidende Schritt: ob die wiederhergestellte Maschine startet** — dafür stand kein Proxmox-Verbund zur Verfügung. Bis dahin ist der Provider nicht freigegeben; der Ablauf für das Produktivsystem steht in `docs/proxmox.md`.
|
||||||
|
|
||||||
|
- **Die API-Lücke bestimmt die ganze Bauart:** Proxmox kann eine Sicherung anstoßen, aber die entstandene Datei nicht herausgeben — es gibt keinen REST-Endpunkt für Datenträger- oder Archivinhalte, und zum Zurückspielen nimmt die API ausschließlich eine **Volumenkennung** entgegen. Deshalb zwei Nähte: `ArchiveTransport` (lesen) und `ArchiveWriter` (schreiben), je umgesetzt als `LocalArchiveTransport` (Dateizugriff/Freigabe) und `SSHArchiveTransport`. **Bewusst zwei Schnittstellen statt einer:** Ein Weg kann lesend eingerichtet sein, ohne schreiben zu dürfen — wer nur sichert, braucht das Schreibrecht nicht.
|
||||||
|
- **Ohne hinterlegten Wirtsschlüssel keine SSH-Verbindung.** Einen Schalter „Wirtsschlüssel egal" gibt es nicht: Ein Transport, der jeden annimmt, macht aus einem Zwischenangriff eine Einladung — der Angreifer lieferte dann das Archiv, das Syncova für ein Backup hält.
|
||||||
|
- **Fund — `normalizeFingerprint` durfte nicht wiederverwendet werden.** Der bestehende für TLS macht Kleinbuchstaben; bei einem Hexwert richtig, bei dem Base64-Wert von `ssh-keygen -l` **falsch**. Ein kleingeschriebener Fingerabdruck passte auf keinen Schlüssel mehr, und die Verbindung schlüge mit einer Meldung fehl, die nach einem Angriff aussieht. Eigener `normalizeSSHFingerprint`.
|
||||||
|
- **Die Volumenkennung kommt vom Knoten und wird geprüft.** `backup/../../../etc/shadow` läse sonst eine beliebige Datei des **Syncova-Servers** ins Backup. Umgekehrt beim Zurückschreiben: Ein Archivname mit Pfadanteil schriebe die Datei irgendwohin auf den **Proxmox-Knoten**, mit den Rechten des Anmeldekontos.
|
||||||
|
- **Das Archiv wird beim `Close()` des Datenstroms vom Knoten entfernt.** Früher zu löschen zöge dem Leser die Datei unter den Füßen weg; gar nicht zu löschen füllte den Proxmox-Speicher bei jeder Sicherung mit einer zweiten, unverwalteten Kopie, für die keine Aufbewahrungsregel gilt. `KeepArchiveOnNode` ist der ausdrückliche Ausweg.
|
||||||
|
- **Zurückgeschrieben wird unter Zwischennamen, umbenannt erst beim Schließen** (wie beim Ablegen eines Blocks in Phase 2). Ein abgebrochener Transfer hinterlässt kein Archiv, das Proxmox für vollständig hält. Beim SSH-Weg steckt im `Close()` zusätzlich die Auswertung des Rückgabewerts der Gegenseite — voller Speicher ist der häufigste Fall.
|
||||||
|
- **Ein Gast liegt als zwei Objekte im Manifest:** `guest/disk-image.vma` und `guest/configuration.json`. Der Pfad des Abbilds ist **fest** und nicht aus dem vzdump-Dateinamen abgeleitet — der trägt einen Zeitstempel, und ein wechselnder Manifestpfad machte jede Deduplizierung über Backupgrenzen hinweg unmöglich (die Engine vergleicht über den Pfad).
|
||||||
|
- **Die Konfiguration wird vor dem Archiv gelesen.** Danach hieße: nach einem stundenlangen vzdump — und scheiterte sie dann, wäre der ganze Lauf umsonst gewesen.
|
||||||
|
- **„Inkrementell" heißt bei einem Gast das Gegenteil von Phase 6.** Dort ist der Gewinn Zeit; hier liest vzdump jedes Mal die ganze Maschine, und gespart wird ausschließlich **Platz** durch die Deduplizierung. Wer das verwechselt, plant seinen Nachtbetrieb falsch.
|
||||||
|
- **Eine Platte mit `backup=0` macht den Lauf zum Teilfehler.** Nicht weil etwas schiefging, sondern weil die Wiederherstellung sonst eine unvollständige Maschine liefert, die jemand für vollständig hält.
|
||||||
|
- **Der Bestand ist eine Momentaufnahme.** Ein fehlender Gast wird als `missing_since` vermerkt, nie gelöscht: Er könnte abgeschaltet oder verschoben sein, und eine gelöschte Zeile nähme die Zuordnung zu vorhandenen Backups mit — die man genau dann braucht, wenn die Maschine weg ist. Ein zweiter Lauf verschiebt den Zeitpunkt nicht.
|
||||||
|
- **Der Verbundbezug einer Quelle steht als CHECK in der Datenbank.** Bei genau einem Verbund ließe er sich raten — bei zweien sicherte der Lauf die falsche Maschine.
|
||||||
|
- **Zugangsdaten verschlüsselt, `LoadCredentials` getrennt von `GetCluster`.** Wer einen Verbund nur anzeigt, soll die Geheimnisse gar nicht erst im Speicher haben. Real geprüft: Das Token steht nirgends im Klartext in der Zeile.
|
||||||
|
- **`SetArchiveTransport` ist nachträglich, nicht Teil der Optionen.** Der SSH-Weg braucht den Provider selbst, um eine Speicherkennung in einen Pfad aufzulösen — beides im Konstruktor zu verlangen ergäbe eine Henne-Ei-Lage.
|
||||||
|
- **Ein nicht erreichbarer Verbund ist ein 503, kein 500** — und `last_seen_at` wird nur bei Erfolg fortgeschrieben. Bei jedem Versuch zu setzen machte aus „zuletzt erreicht" ein „zuletzt versucht", und ein seit Wochen toter Verbund sähe frisch aus. `unauthorized` ist von `unreachable` getrennt: Die Abhilfe ist eine völlig andere.
|
||||||
|
- **`ReadChangedBlocks` meldet `ErrNotSupported`.** Die QEMU-Schmutzbitmap gibt es nur über das PBS-Protokoll oder QMP. Eine leere Bereichsliste wäre der bequeme Weg und der schlimmste: die Sicherung hielte jede Platte für unverändert.
|
||||||
|
- **Alles ist asynchron.** Ändernde Aufrufe antworten mit einer UPID; wer sie für das Ergebnis hält, meldet einen Snapshot als angelegt, bevor er existiert. Der Statusendpunkt ist **knotenbezogen** (Knoten steckt in der UPID), und **Fehler stehen im Exit-Status, nicht im HTTP-Status**. Bei Fehlschlag wird das Aufgabenprotokoll mit abgerufen — der Exit-Status ist meist nur ein Satz.
|
||||||
|
- **`WaitForTask` hat bewusst keine Zeitgrenze** (vzdump über TB läuft Stunden; eine Grenze bräche genau die großen Maschinen ab). Für Snapshots gilt das Gegenteil: 10 Minuten bedeuten, dass der Gastdienst hängt.
|
||||||
|
- **API-Token statt Ticket** — dauerhaft gültig, einzeln widerrufbar, eigene Rechte. Bei selbstsigniertem Zertifikat **Fingerabdruckbindung statt `InsecureSkipVerify`**: das ist strenger als eine CA-Prüfung, weil nur ein einziges Zertifikat gilt.
|
||||||
|
- **Plattenerkennung, drei Fallen:** `backup=0` nimmt eine Platte aus (wer das übergeht, hält eine unvollständige Maschine für vollständig); `media=cdrom` ist keine Platte; `unused0` ist eine abgehängte Platte **mit** Daten. Dazu: `efidisk0` ist 1 MiB groß und ohne sie startet UEFI nicht, und ein fehlendes `bios`-Feld bedeutet SeaBIOS.
|
||||||
|
- **Die Konsistenzstufe wird nie beschönigt.** `QuiesceGuest` ohne Gastdienst ergibt `crash_consistent` plus Warnung, nicht die verlangte Stufe.
|
||||||
|
- **Restore:** laufender Gast wird nie überschrieben, vorhandener nie ohne Zustimmung, gestartet wird nie unaufgefordert. Die **Plattenzuordnung wird bewusst nicht aus der gesicherten Konfiguration gesetzt** — sie verwiese auf den alten Ort und die Maschine startete nicht; das wird als Warnung ausgewiesen. Die MAC-Adresse dagegen muss erhalten bleiben (Lizenzen, DHCP-Reservierungen).
|
||||||
|
- **`RawConfiguration` wird wortgetreu mitgesichert.** Eine umgedeutete Fassung verlöre die Felder, die wir heute noch nicht kennen.
|
||||||
|
- Nachgewiesen gegen den API-Nachbau: 3 MiB Gastarchiv gesichert (Manifest mit Abbild **und** Konfiguration, ausgenommene Platte als Teilfehler ausgewiesen, Archiv vom Knoten entfernt), Quelle gelöscht, **bitgenau** zurückgeschrieben, MAC-Adresse erhalten, Gast nicht unaufgefordert gestartet. Real gegen den laufenden Dienst: `http://` abgelehnt, SSH ohne Wirtsschlüssel abgelehnt, Viewer 403 auf jeden schreibenden Zugriff, Löschschutz bei verwendetem Verbund (409), Audit-Eintrag geschrieben.
|
||||||
|
- **Offen für das Produktivsystem:** ob die wiederhergestellte Maschine **bootet**; der SSH-Weg (übersetzt und in seiner Fingerabdruckprüfung getestet, aber nie gegen einen echten Knoten gefahren); LXC; eine Oberfläche für Verbünde; Vergleich der Archivgröße gegen die Angabe von Proxmox.
|
||||||
|
|
||||||
|
### Phase 8 (Scheduler) — Kette geschlossen
|
||||||
|
|
||||||
|
**Auftrag → Scheduler → Backup Engine → Repository → Restore läuft durch.** Real nachgewiesen: 38 MiB über die API beauftragt, vom Scheduler gesichert, Integritätsscan sauber, Quelle gelöscht, aus der Zusatzsicherung bitgenau wiederhergestellt. Zweiter Lauf: 0,0 MiB in 0,06 s statt 38,1 MiB in 0,57 s.
|
||||||
|
|
||||||
|
Es fehlen: Quellen außer Dateisystemen, Aufbewahrung, Prüfung und Benachrichtigung.
|
||||||
|
|
||||||
|
- **`packages/backupexecutor` setzt `jobs.Executor` um.** Ohne ihn greift `NotImplementedExecutor` mit `EXECUTOR_NOT_CONFIGURED` — stillschweigender Erfolg wäre das gefährlichste Fake-Feature der Anlage: grüne Läufe bei leerem Repository.
|
||||||
|
- **Ein Executor ohne Schlüsselmaterial wird beim Einrichten abgelehnt**, nicht erst beim ersten Lauf um zwei Uhr nachts. `AllowUnencrypted` ist der ausdrückliche Ausweg, mit Warnung bei jedem Start.
|
||||||
|
- **Eine gescheiterte Quelle bricht den Lauf nicht ab**, zählt aber als übergangenes Objekt → Teilfehler. Scheitern alle: `ALL_SOURCES_FAILED`.
|
||||||
|
- **Das Repository wird einmal je Lauf geöffnet**, nicht je Quelle — sonst Wechselspiel um dieselbe Schreibsperre.
|
||||||
|
- **Die Backup-Kennung ist `run-<lauf-uuid>-<quelle>`.** Die Laufkennung hat Vorrang: Aus ihr lässt sich das Backup einem Lauf zuordnen, auch wenn die Datenbank verloren ging. Grenze 64 Zeichen (Kennung wird zum Dateinamen), Quellanteil wird gekürzt.
|
||||||
|
- **`RecordBackup` ist ein Verweis, keine Kopie.** Scheitert er, wird protokolliert statt geworfen: Das Backup liegt sicher im Repository, und ein „gescheitert" löste eine sinnlose Wiederholung aus.
|
||||||
|
- **Je Quelle und Repository genau eine Kette** — zwei Quellen in einer Kette machten die eigenständige Wiederherstellung unmöglich.
|
||||||
|
- **Fund:** Der Begrenzer wurde gebaut und protokolliert, aber beim Bau der Quellanfrage **nie gesetzt** — die Zeile „der lauf ist in der bandbreite begrenzt" erschien, während mit voller Geschwindigkeit gesichert wurde. Ein Feld, das gesetzt aussieht und nie ankommt, ist im Protokoll nicht zu erkennen. `TestExecutorAppliesJobBandwidthLimit` misst deshalb den Durchsatz statt die Konfiguration.
|
||||||
|
|
||||||
|
- **`jobs.Executor` hält die Schleife frei von Engine, Repository und Providern.** Ohne diese Naht liesse sich das Zusammenspiel von Zeitplan, Fenster, Nebenläufigkeit und Wiederholung nur mit echtem Repository prüfen — also praktisch gar nicht.
|
||||||
|
- **Übergangene Objekte machen einen Lauf zum Teilfehler, auch ohne gemeldeten Fehler.** Die Auswertungsreihenfolge in `evaluateExecution` ist festgelegt: Abbruch → Fehler → übergangene Objekte. Ein Abbruch wiegt am schwersten (ein „gescheitert" löste eine sinnlose Wiederholung aus).
|
||||||
|
- **Ein Teilfehler wird nicht wiederholt.** Die übergangenen Objekte wären beim nächsten Versuch dieselben; er verlangt einen Blick, keine Wiederholung.
|
||||||
|
- **Versäumte Läufe werden übersprungen, nicht nachgeholt.** Drei Tage Ausfall ergeben *einen* Lauf, nicht drei — die Daten von vorgestern gibt es nicht mehr. Die Zahl wird protokolliert.
|
||||||
|
- **Kein Drift:** Der nächste Zeitpunkt wird vom *geplanten* aus gerechnet, nicht vom tatsächlichen Beginn — und **beim Übernehmen** fortgeschrieben, nicht nach dem Lauf (sonst bliebe der Auftrag bei langem Backup fällig).
|
||||||
|
- **Lebendmeldung alle 30 s, Freigabe nach 5 min.** Stirbt ein Server mitten im Lauf, blockiert dessen `running`-Zeile den Auftrag über den Teilindex **dauerhaft** — ohne dass jemand einen Fehler sähe. Die Frist muss deutlich über dem Meldeabstand liegen, sonst liefe derselbe Auftrag zweimal; `LoopOptions.Validate()` lehnt das ab.
|
||||||
|
- **Ein verwaister Lauf wird als gescheitert vermerkt, nicht gelöscht** (`SCHEDULER_LOST`, Klasse `transient`, damit die Wiederholung greift).
|
||||||
|
- **SIGTERM bricht laufende Vorgänge ab**, statt sie zu Ende zu führen — sonst hinge der Neustart am längsten Backup. Das Ergebnis wird mit eigenem Kontext geschrieben, sonst bliebe die Zeile auf `running`.
|
||||||
|
- **`POST /jobs/{id}/run` antwortet 202, nicht 201:** Der Lauf ist eingereiht, die Sicherung hat nicht begonnen. Zweiter Anstoß bei laufendem Auftrag → **409**, nicht 500.
|
||||||
|
- **Fund:** `bytes_processed = $3` und `throughput_bps = ($3 / EXTRACT(...))` im selben UPDATE ließen PostgreSQL zwei Typen für denselben Parameter ableiten (`42P08`). Ohne den `::bigint`-Cast wäre **jeder** Lauf auf `running` hängen geblieben — der Fehler wurde nur geloggt, nicht geworfen.
|
||||||
|
|
||||||
|
- **Die Zeitumstellung ist der Punkt, an dem Scheduler stillschweigend danebenliegen.** Frühjahr: „täglich 02:30" existiert am Umstellungstag nicht — es wird der nächste gültige Zeitpunkt genommen, statt den Lauf ausfallen zu lassen. Herbst: 02:30 gibt es zweimal — der Auftrag läuft **einmal**, sonst entstünden zwei Ketten. Gegen die echten Termine 2026 in `Europe/Berlin` geprüft.
|
||||||
|
- **Kalendersuche mit `AddDate`, nie mit `Add(24h)`.** An Umstellungstagen hat ein Tag 23 oder 25 Stunden.
|
||||||
|
- **Ohne Zeitzone gilt UTC, nicht die Serverortszeit.** Sonst liefe dieselbe Konfiguration auf zwei Servern zu verschiedenen Zeiten.
|
||||||
|
- **Cron-Sonderregel:** Sind Tag *und* Wochentag eingeschränkt, gilt **ODER**. `0 0 13 * 5` heißt „am 13. oder freitags". Als UND umgesetzt liefe der Plan fast nie — und es fiele erst nach Monaten auf.
|
||||||
|
- **Ein durch ein Wartungsfenster verhinderter Lauf wird verschoben, nicht übergangen.** Dass ein Lauf verspätet ist, sieht man; dass er fehlt, nicht.
|
||||||
|
- **Ein Erlaubnisfenster für Auftrag A sperrt Auftrag B nicht** — sonst fielen alle Sicherungen aus, sobald irgendwo eines existiert.
|
||||||
|
- **Verhungerungsschutz mit Deckel:** 50 Punkte je Wartestunde, höchstens 100. Ohne Alterung verhungert ein niedrig eingestufter Auftrag für immer; ohne Deckel überholte ein alter jede kritische Sicherung.
|
||||||
|
- **Ein Teilfehler erfüllt keine Abhängigkeit.** Wer eine Datenbank sichert und danach das Anwendungsverzeichnis, will nicht das Verzeichnis zu einer halben Datenbank.
|
||||||
|
- **Wiederholt wird nur bei transient/network/repository/source.** Ein Anmeldefehler behebt sich nicht durch Warten; ein Integritätsfehler wird nur später bemerkt. Unbekannte Fehler gelten als **dauerhaft** — der umgekehrte Standard verdeckte die Ursache.
|
||||||
|
- **Backoff-Streuung wirkt nur nach unten**, sonst wäre der Deckel keiner. Gerechnet in Gleitkomma: Ein Schieben um >62 Stellen machte aus langer Wartezeit eine negative.
|
||||||
|
- **Ein Bandbreitenbegrenzer je Lauf**, geteilt über alle Quellen und Arbeiter — sonst ein Vielfaches der Rate. Token-Bucket statt Zeitfenster (das erlaubt an der Grenze die doppelte Rate). `100Mbit` ≠ `100MB`.
|
||||||
|
- **Begrenzt wird das Lesen von der Quelle**, nicht das Schreiben ins Repository. Wegen des Gegendrucks der Pipeline bindet dieser eine Punkt die gesamte Last. Gemessen: 19,1 MiB in 0,37 s ohne Grenze gegen 18,17 s bei 1 MiB/s.
|
||||||
|
- **Der Teilindex `backup_job_runs_single_active_idx` + `FOR UPDATE SKIP LOCKED`** verhindern, dass zwei Control-Server denselben Auftrag starten. Mit vier gleichzeitigen Servern geprüft.
|
||||||
|
- **Regeln als CHECK in der Datenbank**, etwa `files_skipped = 0 OR status <> 'succeeded'`. Im Code müsste jede Stelle sie einhalten — eine vergisst es. Alle acht gegen die echte DB geprüft.
|
||||||
|
- **Ein RPO, den der Zeitplan nicht einhalten kann, wird beim Anlegen abgelehnt.** Der Betreiber glaubte sonst, vier Stunden zu verlieren, während täglich gesichert wird.
|
||||||
|
- **`syncova-migrate force <n>`** rettet nach abgebrochener Migration. Es führt **kein SQL aus**, sondern behauptet einen Stand — daher Bestätigung über `SYNCOVA_MIGRATE_CONFIRM_FORCE` und Ablehnung auf sauberer Datenbank.
|
||||||
|
|
||||||
|
### Phase 9 (Recovery Engine)
|
||||||
|
|
||||||
|
**Die Vorabprüfung ist der Kern, nicht das Zurückschreiben.** `POST /restores/validate` schreibt nichts und stellt fest, ob eine Wiederherstellung gelingen *kann* — insbesondere, ob **jeder benötigte Block noch da ist**. Ein Manifest allein belegt nur, dass jemand einmal etwas gesichert hat.
|
||||||
|
|
||||||
|
- **Die Prüfung läuft zweimal:** beim Anlegen und erneut unmittelbar vor dem Schreiben. Der gespeicherte Bericht kann Tage alt sein; in der Zwischenzeit kann ein Block verschwinden.
|
||||||
|
- **Der Befund nennt die betroffene Datei**, nicht nur eine Zahl. Übersprungene Blockprüfung erscheint als Hinweis im Bericht — sonst hielte man einen halben Nachweis für einen ganzen.
|
||||||
|
- **Drei Hürden vor dem Überschreiben:** Kennzeichen `overwrite_existing`, eigene Berechtigung `restores.overwrite` (nicht in `restores.execute` enthalten) und `confirm_overwrite`, das den **Zielpfad wörtlich wiederholt**. Ein versehentlich gesetztes Kennzeichen in einem Skript reicht damit nicht aus. Läuft der Schalter ins Leere, entfällt die Bestätigung — ein Ritual ohne Anlass gewöhnt das Wegklicken an.
|
||||||
|
- **Der Prüfpunkt hält einen einzigen Pfad**, weil das Manifest fest sortiert ist. Er entsteht erst, **nachdem** die Datei vollständig und umbenannt am Platz liegt — ein Prüfpunkt auf eine halbe Datei wäre schlimmer als keiner. Geschrieben höchstens alle paar Sekunden, sonst eine Million Datenbankschreibvorgänge bei einer Million Dateien.
|
||||||
|
- **Bei einer Fortsetzung greift der Schutz gegen ein volles Ziel nicht** — dort liegt der bereits geschriebene Teil. Ihn über `overwrite_existing` auszuhebeln erlaubte zugleich das Überschreiben fremder Daten.
|
||||||
|
- **Kein automatischer Wiederholungsversuch.** Ein zweiter Lauf in ein halb gefülltes Ziel kann Daten beschädigen, die der erste bereits am Platz hatte. Auch verwaiste Aufträge werden nicht erneut eingereiht; die Sitzung bleibt offen.
|
||||||
|
- **Eine Wiederherstellung zur Zeit** (Standard) und ein Teilindex gegen zwei gleichzeitige in **dasselbe Ziel** — sie schrieben sich gegenseitig zu, und beide endeten erfolgreich.
|
||||||
|
- **Das Repository wird schreibgeschützt geöffnet:** Eine Wiederherstellung liest nur und muss nicht auf die Schreibsperre einer laufenden Sicherung warten.
|
||||||
|
- **Die Vorabprüfung braucht nur `restores.read`** — wer den Zustand der Backups beurteilen soll, muss sie ausführen können.
|
||||||
|
|
||||||
|
### Phase 10 (Verification und Recovery Assurance)
|
||||||
|
|
||||||
|
**Nur der Wiederherstellungstest ist ein Nachweis.** Manifest-, Block- und Kettenprüfung sind Indizien — gute, aber Indizien. Deshalb wiegt er in der Bewertung am schwersten (25 von 100) und hängt an einem eigenen Recht `verification.restore_test`: Er liest das gesamte Backup und schreibt es versuchsweise zurück.
|
||||||
|
|
||||||
|
- **Die Blockprüfung vergleicht gegen `ChunkReference.StoredDigest`,** nicht gegen die Chunk-Kennung — die beschreibt bei einem verschlüsselten Repository den Klartext. Derselbe Fehler wie beim Integritätsscan der Phase 2. Der Nebeneffekt ist der eigentliche Gewinn: Eine Integritätsprüfung läuft dadurch **ohne Datenschlüssel**.
|
||||||
|
- **Ein Befund löscht `last_verified_at` und `last_restore_test_at`.** Ohne diesen Schritt stand ein Backup nach Behebung des Schadens sofort wieder als `recoverable` da — auf Grundlage eines Tests, der **vor** dem Schaden lief. Im Nachweis aufgefallen.
|
||||||
|
- **Beschädigt heißt 0 %.** Ein Backup mit einem einzigen beschädigten Block kam auf 70 %, weil Aktualität, Verschlüsselung und ein früherer Test weiterhin zählten. „70 %" liest sich wie „weitgehend in Ordnung" — es ist aber nicht zu 70 % wiederherstellbar, sondern gar nicht. Ebenfalls im Nachweis aufgefallen; Regressionstest vorhanden und als fangend geprüft.
|
||||||
|
- **Unbekannt zählt nie als gut.** Jede Eingangsgröße trägt `is_known`; `missing_measurements` ist die Handlungsanweisung. `HasOffsiteCopy` ist fest `false`, weil es die Funktion nicht gibt — wohlwollend zu schätzen wäre die bequeme und falsche Entscheidung.
|
||||||
|
- **Zwei CHECK-Constraints stützen die Einstufung:** `recoverable` verlangt `last_restore_test_at`, `verified` verlangt `last_verified_at`. Die stärkste Aussage der Anlage lässt sich damit auch durch einen künftigen Codefehler nicht ohne Nachweis vergeben.
|
||||||
|
- **Eine bestandene Blockprüfung hebt auf `verified`, stuft aber nie von `recoverable` herab.** Ein auf einen Teilbaum beschränkter Restore-Test hebt gar nicht — er prüfte einen Teil, nicht das Backup.
|
||||||
|
- **Ein Fehlschlag der Prüfung ist kein Befund am Backup.** Repository nicht erreichbar → Auftrag `failed`, Einstufung unberührt. Ein Prüfwerkzeug, das grundlos Alarm schlägt, wird bald nicht mehr ernst genommen.
|
||||||
|
- **Die Bewertung wird bei jedem Aufruf neu berechnet,** nicht gelesen: Sie hängt am Alter der Messungen und veraltet von selbst.
|
||||||
|
- **Teilindex gegen zwei gleichzeitige Prüfungen desselben Backups** (409). Ohne Freigabe verwaister Prüfungen sperrte er das Backup dauerhaft.
|
||||||
|
- Vollständiger Lebenszyklus gegen den laufenden Dienst nachgewiesen: ungeprüft 35 % → geprüft 55 % → wiederherstellbar 90 % → ein Byte gekippt 0 % `corrupted` (Befund nennt die Datei) → repariert 55 % → Test wiederholt 90 %. Siehe `docs/verification.md`.
|
||||||
|
|
||||||
|
### Phase 11 (Immutability und Aufbewahrung)
|
||||||
|
|
||||||
|
**Die Durchsetzungsstufe wird gemessen, nicht behauptet.** `MeasureEnforcement` legt Probedateien an und versucht sie zu löschen; gemeldet wird nur, was das Betriebssystem nachweislich verhindert. `storage` (S3 Object Lock/WORM) existiert als Begriff und wird nie vergeben — es ist nicht umgesetzt.
|
||||||
|
|
||||||
|
- **`0400` verhindert kein Löschen.** Unter POSIX hängt das Entfernen am Schreibrecht des *Verzeichnisses*. Der ursprüngliche gehärtete Modus nannte das Löschschutz; real gemessen und als Regressionstest festgehalten. Den Schutz leistet das Unveränderlich-Kennzeichen (`UF_IMMUTABLE` via chflags, `FS_IMMUTABLE_FL` via ioctl).
|
||||||
|
- **Fund im Angriffsversuch (zweimal erfolgreich):** Ein geschütztes Manifest überlebte `rm -rf`, seine **Chunks nicht** — zurück blieb ein Backup, das sich für vollständig ausgibt und leer ist. Beim zweiten Versuch fehlten **Descriptor und Datenschlüssel**: Daten da, nicht deutbar, nicht entschlüsselbar. Geschützt sind jetzt Manifeste, Chunks, Schutzvermerke, Descriptor und Datenschlüssel — der Katalog bewusst nicht (nur Beschleuniger).
|
||||||
|
- **`Sys()` liefert `*syscall.Stat_t`, nicht `*unix.Stat_t`.** Die Typzusicherung auf den falschen schlug still fehl; das Kennzeichen wurde nie gesetzt, und die Messung meldete folgerichtig `advisory`. Nur weil sie misst statt behauptet, fiel es auf.
|
||||||
|
- **Ein gehärtetes Repository lässt sich nicht mit `rm -rf` entfernen** — auch nicht von `t.TempDir()`. Tests müssen den Schutz selbst freigeben. Eine rekursive Aufhebungsfunktion gibt es bewusst nicht.
|
||||||
|
- **Der Schutzvermerk liegt neben dem Manifest** (`<id>.hold.json`), nicht darin: Das Manifest ist über `ContentHash` versiegelt. Ein **unlesbarer** Vermerk gilt als Schutz — die umgekehrte Auslegung machte aus einer kaputten Datei einen Datenverlust.
|
||||||
|
- **Verlängern ja, verkürzen nie** — auch nicht für Administratoren. Ein Legal Hold verlangt eine Begründung; ohne sie traut sich später niemand, ihn aufzuheben.
|
||||||
|
- **`keep_last` schützt das letzte vorhandene Backup.** Ohne es löschte „7 Tage" bei einem drei Wochen nicht gesicherten System *jedes* Backup. Eine Regel ohne jede Haltevorgabe wird abgelehnt; eine ergänzte 1 wird in der Antwort ausgesprochen statt still eingesetzt.
|
||||||
|
- **Fund:** Der Schutz von Elternbackups hätte jede Aufbewahrung verhindert — in einer fortlaufenden Kette ist jedes Backup außer dem jüngsten ein Elternteil. Da Syncova-Manifeste vollständig sind (Phase 6), steht die Zusicherung jetzt im Manifest (`self_contained_restore`); ein fehlendes Feld bedeutet „unbekannt" und schützt weiter. Der Test löscht das Elternbackup und liest danach jeden Block des Kindes.
|
||||||
|
- **`backups.delete` und `immutability.manage` sind getrennt.** Wer aufräumen darf, darf keinen Schutz aufheben — das ist der Schritt, der einem Angreifer den Weg öffnet.
|
||||||
|
- **„Gelöscht, aber nichts frei" ist kein Fehler**, sondern Deduplizierung. Die Zusammenfassung sagt es, sonst erzeugt jede solche Ausgabe eine Rückfrage.
|
||||||
|
- Nachgewiesen: `rm -rf` gegen ein gehärtetes Repository → 15 verweigerte Löschungen, Backup danach vollständig. Siehe `docs/immutability.md`.
|
||||||
|
|
||||||
|
### Phase 12 (Weboberfläche)
|
||||||
|
|
||||||
|
**Der Umgang mit unfertigen Bereichen ist die Entscheidung dieser Phase.** Alle fünfzehn Seiten aus §14 erscheinen im Menü; unfertige tragen den Vermerk „noch nicht verfügbar" und führen auf eine Seite, die sagt, *was* fehlt und *wo dieselbe Auskunft heute steht*. Nur die fertigen zu zeigen verschwiege den Ausbaustand, leere Masken täuschten ihn vor.
|
||||||
|
|
||||||
|
- **Sieben von zehn Kennzahlen haben eine Datengrundlage.** Kritische Meldungen, Kapazitätsprognose und Security Score erscheinen mit Begründung statt mit einer Null — „0 kritische Meldungen" hieße „keine Probleme" und bedeutete „es wird nicht geprüft".
|
||||||
|
- **Keine Läufe sind nicht 100 %.** Ohne Lauf in sieben Tagen gibt es keine Erfolgsquote; die Kennzahl meldet `warning`. Ein Dashboard, das bei ausgefallener Sicherung grün zeigt, ist schlimmer als keines. Ein Teilfehler zählt nicht als Erfolg.
|
||||||
|
- **Nicht bezifferbar ist nicht null, und 0,0004 % ist nicht 0 %.** Ohne hinterlegte Kapazität gibt es keinen Prozentsatz; kleine Werte erscheinen als `< 0,1 %`, weil „null Prozent" wie „nichts abgelegt" liest.
|
||||||
|
- **Fund:** `legal_hold = false OR immutable_until > now()` liefert in der SQL-Dreiwertlogik **NULL**, sobald keine Frist gesetzt ist — nicht `false`. Ein NULL lässt sich nicht in ein `bool` lesen, und die gesamte Liste der Wiederherstellungspunkte schlug fehl. Die Regel steht jetzt als `protectionExpression` an genau einer Stelle.
|
||||||
|
- **Navigation über die History-API, keine Router-Bibliothek** (~50 Zeilen). **Betriebsfolge:** Das Bundle braucht einen SPA-Fallback (`try_files $uri /index.html`), sonst ergibt ein Neuladen auf `/recovery-points` einen 404.
|
||||||
|
- **Der Ladezustand wird abgeleitet, nicht im Effekt gesetzt.** Das Ergebnis trägt den Schlüssel seiner Anfrage; passt er nicht zum aktuellen, läuft sie noch. Ein `setState` im Effektkörper löste eine zweite Renderrunde aus (derselbe Lint-Fehler wie in Phase 8). Nebeneffekt: Beim Filterwechsel blitzt die Tabelle nicht auf.
|
||||||
|
- **Berechtigungen im Menü sind Anzeige, keine Sicherung.** Sie verhindern Sackgassen; geprüft wird auf dem Server.
|
||||||
|
- **Jede Fehleranzeige nennt `request_id`** — ohne sie bleibt „es hat nicht funktioniert".
|
||||||
|
- Siehe `docs/web-ui.md`.
|
||||||
|
|
||||||
|
### Phase 13 (Kennzahlen und Diagramme)
|
||||||
|
|
||||||
|
**Eine Lücke ist keine Null.** Ein Zeitfenster ohne Sicherungslauf hat *keinen* Durchsatz — nicht null Byte je Sekunde. Jeder Punkt trägt `has_value`; das selbst geschriebene SVG-Diagramm unterbricht die Linie, statt sie durch den Nullpunkt zu ziehen. Genau das beherrschen die gängigen Diagrammbibliotheken standardmäßig falsch.
|
||||||
|
|
||||||
|
- **Zehn von zwölf Reihen werden aus vorhandenen Tabellen aggregiert**, nicht doppelt gespeichert: Zwei Quellen für dieselbe Aussage laufen auseinander, und man merkt es erst, wenn jemand nachrechnet. `metric_samples` nimmt nur auf, was sonst verloren geht — Momentaufnahmen wie `used_bytes`, die überschrieben werden.
|
||||||
|
- **Fund:** `PostgreSQL wertet NaN = NaN als wahr`, anders als IEEE 754. Der übliche CHECK `value = value` ließ NaN durch; ein einziger NaN verseucht jede Summe der Reihe lautlos. Jetzt `value <> 'NaN'::float8`.
|
||||||
|
- **Fund:** `json:"value,omitempty"` ließ einen **gemessenen Wert von null** aus der Antwort verschwinden — `has_value: true` ohne `value`. Ein Feld, das je nach Wert da ist oder nicht, ist die unangenehmste Sorte Schnittstelle: Sie funktioniert fast immer.
|
||||||
|
- **Fund:** Ein Jahr geteilt durch sieben Tage ergibt 52,14 Fenster; abgerundet fielen die letzten ein bis sieben Tage heraus. Der Jahresverlauf zeigte **null Messungen**, obwohl am selben Tag Läufe stattfanden. `BucketCount()` rundet jetzt auf. Der Regressionstest griff zunächst nicht, weil er den Fensterbeginn mit `time.Truncate` (ab Unix-Epoche) statt ab Fensteranfang rechnete — wie die SQL-Abfrage.
|
||||||
|
- **Fund:** Das Kompressionsdiagramm war als verfügbar geführt und konnte nie Daten haben — `compressed_bytes` wird von keiner Stelle beschrieben. Jetzt als nicht verfügbar ausgewiesen. `encrypted_bytes` einzusetzen wäre falsch: Der Verschlüsselungsaufwand erschiene als schlechte Kompression.
|
||||||
|
- **Fund (aus Phase 12):** Das Repository-Widget prüfte auf die Zustände `offline` und `archived`, die das Schema nie kannte (nur `active`, `read_only`, `unavailable`, `maintenance`). Es meldete „Alle 5 Repositories sind erreichbar" bei vier nicht erreichbaren.
|
||||||
|
- **Der Durchsatz wird neu berechnet**, nicht aus `throughput_bps` gelesen: Die Spalte bleibt bei kurzen Läufen leer. Läufe unter einer Sekunde fallen heraus — dort bestimmt die Messungenauigkeit das Ergebnis.
|
||||||
|
- **Die Werteachse beginnt immer bei null.** Eine abgeschnittene Achse lässt kleine Schwankungen wie Einbrüche aussehen — der häufigste Weg, mit korrekten Zahlen etwas Falsches zu zeigen.
|
||||||
|
- **Speicherwachstum misst das Dateisystem, nicht das Repository** (statfs statt Durchlauf über Millionen Blöcke). Bei geteilter Ablage wächst die Kurve auch durch fremde Daten — das steht in der Beschreibung.
|
||||||
|
- Siehe `docs/metrics.md`.
|
||||||
|
|
||||||
|
### Phase 14 (Meldungen und Benachrichtigungen)
|
||||||
|
|
||||||
|
**Der Feind ist nicht der fehlende Alarm, sondern der Alarm, den niemand mehr liest.** Ein System, das jede Minute dieselbe Meldung erzeugt, wird nach drei Tagen weggeklickt — und dann fehlt die eine, auf die es ankam. Zwei Eigenschaften tragen alles:
|
||||||
|
|
||||||
|
- **Eine Ursache, eine Meldung.** Der Fingerabdruck entsteht aus Regel und Gegenstand, **nicht** aus dem Zeitpunkt; ein Teilindex `WHERE status IN ('open','acknowledged')` macht die Doppelung unmöglich. Die Regel steht in der Datenbank, nicht in der Anwendung: Zwischen Nachsehen und Schreiben passt ein zweiter Control-Server. Der wiederholte Befund erhöht einen Zähler — einmal ist ein Zwischenfall, zwanzigmal ein Zustand.
|
||||||
|
- **Meldungen lösen sich selbst auf.** Jede Regel liefert die **derzeit** zutreffenden Befunde; was fehlt, wird automatisch geschlossen. Ohne diesen Schritt steht nach zwei Wochen eine Liste erledigter Probleme da, und die aktuelle geht darin unter. Der Auswerter beschreibt den Ist-Zustand, statt Ereignisse zu zählen.
|
||||||
|
- **Bestätigen heißt nicht Erledigen.** Eine bestätigte Meldung bleibt offen und in der Liste; sonst verschwände der Zustand aus der Übersicht, obwohl er weiterbesteht.
|
||||||
|
- **`x = ANY(NULL)` ist niemals wahr.** Eine leere Befundliste — der Normalfall, sobald alles in Ordnung ist — muss als leeres Array übergeben werden, sonst löst sich nichts auf und die Meldung bleibt für immer stehen.
|
||||||
|
- **Die Ransomware-Regel heißt „Verdacht", nicht „erkannt".** Bricht die Deduplizierung ein, sieht das nach massenhafter Verschlüsselung aus — oder nach einem großen Update. Eine Anlage, die „Ransomware erkannt" meldet und danebenliegt, wird beim nächsten Mal ignoriert.
|
||||||
|
- **Die Zertifikatsregel wird nicht ausgewertet:** `agent_certificates` wird von keiner Stelle beschrieben, die Agenten weisen sich über Betriebstokens aus. Eine Regel, die dauerhaft schweigt, ist gefährlicher als keine — sie erweckt den Eindruck, es werde geprüft.
|
||||||
|
- **Nur neue Meldungen werden zugestellt**, aktualisierte nicht. Jeder Kanal hat eine Schwelle (Standard `high`): Ohne sie schaltet der Bereitschaftsdienst nach einer Woche die Benachrichtigungen ab, und dann kommt auch die kritische nicht mehr an.
|
||||||
|
- **Webhook verlangt HTTPS** (`allow_insecure` als ausdrücklicher Ausweg); einen Schalter „Zertifikat egal" gibt es nicht. Zustellung real gegen echten SMTP- und HTTP-Server geprüft, nicht gegen Attrappen.
|
||||||
|
- **Kanäle hängen am Einstellungsrecht, nicht am Meldungsrecht:** Wer Benachrichtigungen umleitet, kann erreichen, dass niemand mehr von einem Ausfall erfährt. Anlegen und Löschen werden auditiert.
|
||||||
|
- Das Dashboard-Widget „Kritische Meldungen" und die Seite „Meldungen" sind damit **verfügbar** — beide standen seit Phase 12 als benannte Lücke. Siehe `docs/alerting.md`.
|
||||||
|
|
||||||
|
### Phase 15 (Security Center)
|
||||||
|
|
||||||
|
**Jeder Befund trägt Schweregrad, Erklärung, betroffenes Objekt und Empfehlung** — der Plan (§17) verlangt genau das, und `Finding.Validate()` erzwingt es: Ein unvollständiger Befund wird abgelehnt, statt ausgeliefert zu werden. Ein Befund ohne Empfehlung ist eine Beunruhigung; er sagt, dass etwas nicht stimmt, und lässt den Betreiber damit allein.
|
||||||
|
|
||||||
|
- **Zehn Bereiche, zwei davon nicht prüfbar:** Kopie an einem zweiten Ort (Backup Copy nicht umgesetzt) und Zertifikate der Agenten (`agent_certificates` wird von keiner Stelle beschrieben — dieselbe Lage wie bei der Meldungsregel aus Phase 14).
|
||||||
|
- **Ein ungeprüfter Bereich geht nicht in die Rechnung ein** — weder positiv noch negativ. Als bestanden zu werten wäre Schönfärberei, als durchgefallen eine Behauptung. Neben der Prozentzahl steht deshalb immer `maximum_score` und die Liste der ungeprüften Bereiche; ab drei sagt die Zusammenfassung „Die Zahl ist eine Vermutung, keine Aussage."
|
||||||
|
- **Ein kritischer Befund deckelt die Einstufung auf `unzureichend`** — unabhängig von der Prozentzahl. Real geprüft: 88 von 100 Punkten mit einem kritischen Befund ergeben trotzdem `unzureichend`. Dieselbe Regel wie beim beschädigten Backup in Phase 10.
|
||||||
|
- **Das höchste Gewicht hat der Löschschutz (20).** Ohne ihn genügt ein kompromittiertes Konto, um alles zu vernichten; Verschlüsselung und MFA halten dann niemanden auf. Die Gewichte folgen dem Grundsatz: Was den Verlust verhindert, wiegt schwerer als was ihn erschwert.
|
||||||
|
- **Der Score wird bei jedem Aufruf neu berechnet**, gespeichert wird nur sein **Verlauf** als Reihe `security_score` in `metric_samples` (Phase 13). Das Dashboard-Widget liest die letzte Messung statt neu zu rechnen — sonst hinge die Übersicht an zehn Abfragen und `jobs` bekäme eine Abhängigkeit auf `security`. Das Alter der Zahl steht dabei.
|
||||||
|
- Nachgewiesener Zyklus: 35 % `unzureichend` (2 kritisch) → Repository gehärtet und gemessen → 40 % (1 kritisch) → MFA eingerichtet → 57 % `verbesserungsbedürftig`, 0 kritisch, belastbar. Die Einstufung wechselt genau beim letzten kritischen Befund, nicht bei einer runden Zahl.
|
||||||
|
- Damit ist auch das letzte Dashboard-Widget aus Phase 12 verfügbar: **9 von 10 Kennzahlen** haben eine Datengrundlage. Siehe `docs/security-center.md`.
|
||||||
|
|
||||||
|
### Phase 16 (Ransomware-Heuristik)
|
||||||
|
|
||||||
|
**Die Vorgabe steht in vier Worten: „Do not make destructive decisions automatically. Alert first."** Das Paket löscht nichts, sperrt nichts, hält nichts an. Der Grund ist nicht Vorsicht, sondern Erfahrung: Eine Heuristik, die selbsttätig handelt, macht aus jedem Fehlalarm einen Schaden — und ein Betriebssystem-Update sieht von außen aus wie ein Verschlüsselungsangriff.
|
||||||
|
|
||||||
|
- **Der Entropie-Indikator kostet nichts, weil die Engine ihn ohnehin bildet.** Ob ein Block sich komprimieren ließ, entscheidet `ChunkTransformer.Transform` bei jedem Block; bislang verschwand die Erkenntnis im Marker-Byte, jetzt gibt `Transform` sie zusätzlich zurück. Eine eigene Entropiemessung wäre derselbe Rechenaufwand ein zweites Mal. **Gezählt werden nur neue Blöcke** — ein deduplizierter wurde früher schon bewertet und verwässerte den Anteil genau im entscheidenden Lauf.
|
||||||
|
- **Median und mittlere absolute Abweichung, nicht Mittelwert und Standardabweichung.** Ein einziger Ausreißer ist genau der Fall, den wir erkennen wollen; würde er den Basiswert mitbestimmen, höbe er die Schwelle an, gegen die er gemessen wird. Bei fünf normalen Läufen (~100) und einem Angriff (50 000) läge der Mittelwert über 8000 — der nächste Angriff derselben Größe fiele nicht mehr auf.
|
||||||
|
- **Unter fünf Vergleichsläufen lautet die Einstufung `unknown`, ausdrücklich getrennt von `none`.** Wer nicht messen kann, hat nichts gemessen. Eine geratene Schwelle wäre schlechter als keine.
|
||||||
|
- **Backups aus der Zeit vor der Phase fließen nicht als Nullwerte ein.** Das zöge jeden Basiswert nach unten und ließe jeden neuen Lauf auffällig erscheinen; sie werden übersprungen.
|
||||||
|
- **Zwei auffällige Signale für `high`, nicht eines.** Ein einzelnes hat viele harmlose Ursachen; zwei zugleich sind das Muster massenhafter Verschlüsselung — viele geänderte Dateien **und** kaum noch komprimierbare Daten.
|
||||||
|
- **Fund:** Ein Test meldete „drei statt zwei Dateien" als Befund (50 % Anstieg bei Streuung null). Daher hat jedes Signal eine eigene **absolute Untergrenze**: 20 Objekte, 100 MiB, 10 Prozentpunkte. Zwanzig Dateien mehr sind ein Ereignis, zwanzig Bytes mehr sind Rauschen — eine gemeinsame Grenze gibt es nicht.
|
||||||
|
- **Die Erklärung nennt die Untergrenze, statt sie zu verschweigen.** „Der Wert stieg von 336 auf 3 004 350, das liegt unter der Größenordnung" liest sich wie ein Fehler des Werkzeugs — und wer dem Werkzeug einmal misstraut, liest auch den echten Befund nicht mehr.
|
||||||
|
- **Die Endungsverteilung ist auf 20 Einträge gedeckelt** (Sammelposten `(weitere)`, vom Signal ausgenommen). Ohne Deckel bliese ausgerechnet der Angriff mit seinen Zufallsendungen die JSONB-Spalte auf.
|
||||||
|
- **Nachweis in beide Richtungen**, weil ein Detektor, der auch jeden großen Arbeitstag meldet, nach einer Woche ignoriert wird: 200 neue komprimierbare Dokumente → `elevated`, 1 von 6 Signalen. 150 Dateien durch Zufallsdaten mit Endung `.locked` ersetzt und die Originale gelöscht → `HIGH`, 4 von 6, Anteil unkomprimierbarer Blöcke 0 % → 100 %. Siehe `docs/ransomware.md`.
|
||||||
|
- **Bekannte Grenzen:** Bei Quellen unter 100 MiB tragen die beiden Byte-Signale nichts bei. Ein über Wochen schleichender Angriff verschiebt den Basiswert mit sich — dagegen hilft nur die Unveränderlichkeit aus Phase 11.
|
||||||
|
|
||||||
|
### Phase 17 (Berichte)
|
||||||
|
|
||||||
|
**Ein Bericht ist das Dokument, das die Anlage verlässt.** Er landet in einer Tabellenkalkulation und in einem Prüfordner; was darin steht, wird Monate später ohne Rückfragemöglichkeit gelesen. Eine erfundene Null überlebt dort jede mündliche Erläuterung — deshalb trägt jede Kennzahl `is_known` und im CSV bleibt die Wertspalte leer mit Begründung in der Anmerkung. Eine leere Zelle lässt sich nicht versehentlich summieren; eine Null wird summiert, gemittelt und gezeichnet.
|
||||||
|
|
||||||
|
- **Das Modell ist formatunabhängig.** CSV, JSON und PDF sind drei Sichten auf dieselbe Struktur; kein Bericht weiß, in welchem Format er ausgegeben wird. Sonst müsste jeder der neun dreimal geschrieben werden — und beim zehnten vergisst jemand eines. Tabellenzellen sind bereits Text (sonst formatierten CSV und PDF dieselbe Zahl verschieden), **Kennzahlen dagegen Rohwerte** (eine Tabellenkalkulation soll rechnen können).
|
||||||
|
- **RPO lässt sich messen, RTO nicht.** In der RTO-Spalte steht eine Messung aus einer tatsächlich durchgeführten Wiederherstellung oder „nicht gemessen" — nie eine Hochrechnung aus Datenmenge und Durchsatz. Das wäre die bequemste Zahl des Berichts und die einzige, auf die sich im Ernstfall niemand verlassen könnte. Eine RTO-Vorgabe ohne Messung ist **unbekannt**, nicht erfüllt und nicht verletzt.
|
||||||
|
- **Ein Zustandsbericht bekommt keinen Zeitraum.** Die Belegung wird nicht historisiert; „vom letzten Dienstag" kann es nicht geben. Ein angefragter Zeitraum wird ignoriert — ihn anzunehmen und nicht auszuwerten wäre die freundlichste Art zu lügen.
|
||||||
|
- **Der Bericht für Prüfungen liefert Messwerte, keine Urteile** (PROMPT §73: keine Zertifizierung vortäuschen). Kein „erfüllt", kein Haken, keine Norm.
|
||||||
|
- **PDF ist selbst geschrieben** (Standardschriften, WinAnsi, eigene Helvetica-Breitentabelle). „Where practical" für nicht praktikabel zu erklären wäre bequem — ein Prüfbericht wird als PDF verlangt, nicht als CSV. Die Querverweistabelle ist der einzige Teil, dessen Fehler erst beim Empfänger auffällt; ein Test prüft jeden Verweis auf einen echten Objektbeginn.
|
||||||
|
- **Fund in der Sichtprüfung:** Ein gleichmäßiger Stauchfaktor kürzte „TEILWEISE FEHLGESCHLAGEN" zu „TEILWEISE FEHL…", weil daneben ein langer Pfad stand. Jetzt gibt nur die breiteste Spalte ab (gemeinsame Obergrenze statt Skalierung).
|
||||||
|
- **Fund im Nachweis:** „Längste Laufzeit: 0" stand als Messung da, während die mittlere korrekt unbestimmbar war — `COALESCE` liefert eine Null, und im Bericht sah sie aus wie ein Lauf in null Sekunden.
|
||||||
|
- **Fund vor dem Nachweis:** Die Abfrage nutzte `users.is_active` — eine Spalte, die es nie gab (das Schema führt `status`). Der Bericht wäre bei jedem Abruf gescheitert.
|
||||||
|
- **Jeder Abruf wird auditiert** (`REPORT_GENERATED`). Ein Bericht liest nur, ist aber ein **Datenexport**: Sicherheits- und Prüfbericht nennen die Schwachstellen in geordneter Form. Scheitert das Protokollieren, wird trotzdem ausgeliefert — anders als bei destruktiven Handlungen.
|
||||||
|
- **Dateien gehen über `downloadApiFile`, nicht `requestApi`.** Eine Datei trägt keine Antworthülle; der Fehlerfall dagegen schon — deshalb Inhaltstyp prüfen, sonst landet eine Fehlermeldung als „bericht.pdf" im Download-Ordner.
|
||||||
|
- Nachgewiesen: 27 von 27 Kombinationen erzeugt, PDF von CoreGraphics gerendert und angesehen, echte Wiederherstellung ausgeführt (RTO danach mit 36 ms belegt), Zeitraum ohne Läufe erzeugt keine 100-Prozent-Quote. Siehe `docs/reports.md`.
|
||||||
|
|
||||||
|
### Phase 18 (Disaster Recovery)
|
||||||
|
|
||||||
|
**Eine Konfigurationssicherung, die nur auf dem Control-Server liegt, ist beim Verlust des Control-Servers wertlos.** Server und Datenbank gehen typischerweise gemeinsam verloren; der einzige Ort, der das überlebt, ist das Repository. `syncova-dr export` schreibt die Control-Plane-Konfiguration nach `metadata/control-plane/`.
|
||||||
|
|
||||||
|
- **Der Sicherungssatz enthält kein einziges Geheimnis** — keine Passwort-Hashes, keine TOTP-Geheimnisse, keine Zugangsdaten. Bei den Benachrichtigungswegen fällt auch die **Konfiguration** heraus: Dort steht die Webhook-Adresse, und die trägt oft das Token im Pfad. Ein Feld einzeln zu schwärzen hieße, bei jedem neuen Kanaltyp erneut daran zu denken. Was bleibt, ist ein Merkzettel. Ein Test prüft die serialisierte Form, nicht die Struktur.
|
||||||
|
- **Die Liste der Auslassungen steht im Satz selbst.** Wer eine Anlage aus dem Nichts wiederherstellt, hat die Betriebsanleitung nicht dabei.
|
||||||
|
- **Nichts läuft von selbst wieder an:** Aufträge angehalten, Repositories „nicht erreichbar", Konten deaktiviert, Kanäle abgeschaltet, `last_verified_at` leer. Ein Zeitplan, der nachts von selbst anläuft, könnte auf ein halb wiederhergestelltes System schreiben.
|
||||||
|
- **Das Einspielen ist eine Transaktion.** Alles oder nichts — eine halb wiederhergestellte Anlage sieht arbeitsfähig aus und scheitert beim ersten Lauf. Im Nachweis real eingetreten; die Datenbank blieb leer.
|
||||||
|
- **`inspect` braucht keine Datenbank.** Nach einem Totalverlust will man zuerst sehen, was da ist.
|
||||||
|
- **Kennungen entstehen deterministisch (UUIDv5).** Eine zweite Übernahme desselben Repositorys ergibt dieselben Kennungen; mit Zufallswerten entstünden Dubletten.
|
||||||
|
- **Fund — die Manifest-Statistik war strukturell null.** Die Engine schrieb über `WriteTransformedChunk` unmittelbar am Repository und damit an der Zählung der Schreibsession vorbei; jedes Manifest trug `logical_bytes: 0`. Alle Tests blieben grün, weil keiner das Manifest auf seine Kennzahlen ansah. Aufgefallen erst, als das Repository **alleinige** Quelle war: Jedes Backup meldete Größe null. Die Engine schreibt jetzt über die Session, deduplizierte Blöcke werden mitvermerkt.
|
||||||
|
- **Fund — der Katalog-Neuaufbau scheiterte bei fehlendem Verzeichnis.** „Katalog verloren" heißt nicht immer „Datei gelöscht". Die bestehenden Tests löschten stets nur die Datei.
|
||||||
|
- **Fund — eine Fortsetzung meldete Teilfehler bei vollständigem Ergebnis.** Die Dateien zwischen Prüfpunkt und tatsächlichem Fortschritt lagen bereits am Platz (85 von 1500) und wurden übergangen. Sie stammen aus dem eigenen abgebrochenen Lauf und werden jetzt ersetzt — die Lockerung gilt ausschließlich bei einer Fortsetzung.
|
||||||
|
- **Fund — die beim Absturz geschriebene Datei blieb dauerhaft liegen** (1501 Dateien in einem Ziel für 1500). Eine Fortsetzung räumt jetzt auf, bevor sie beginnt.
|
||||||
|
- **Fund beim ersten Einspielversuch:** Ein leeres Musterfeld wurde zu NULL und brach an der häufigsten aller Quellen ab — der ohne Filter. Danach gleich der zweite: Die Spalten sind `jsonb`, nicht `text[]`.
|
||||||
|
- Nachgewiesen: `DROP DATABASE` → aus dem Repository wiederaufgebaut → Quelle gelöscht → bitgenau wiederhergestellt. `rm -rf indexes/` → Katalog baut sich selbst neu. SIGKILL bei 496 von 1500 Dateien → Freigabe als `SCHEDULER_LOST` → Fortsetzung → 1500 Dateien, 0 übersprungen, alle Prüfsummen stimmen. Siehe `docs/disaster-recovery.md`.
|
||||||
|
|
||||||
|
### Phase 19 (Härtung)
|
||||||
|
|
||||||
|
**Sicherheit wird nicht behauptet, sondern angegriffen.** Jeder der elf Angriffe lief gegen die laufende Anlage. Abgewehrt wurden: SQL Injection (pgx-Parameter), RBAC (sechs schreibende Zugriffe als Viewer → 403), Rechteausweitung, Authentifizierung, sofortiger Widerruf nach Kontosperre, XSS (Go maskiert `<` in JSON + `nosniff` + CSP), Pfadausbruch aus dem Manifest. Vier Angriffe kamen durch.
|
||||||
|
|
||||||
|
- **Fund — Wiederherstellung nach `/etc` galt als durchführbar.** Der Angriff braucht keine Lücke: Wer Backups zurückschreiben darf, schreibt nach `/etc/cron.d` oder in eine fremde `authorized_keys`. `recovery.TargetGuard` sperrt die Systemverzeichnisse des **laufenden** Systems; `SYNCOVA_RESTORE_ALLOWED_ROOTS` begrenzt weiter. Der Pfadvergleich läuft über die Trennung, nicht über `HasPrefix` — sonst gälte `/etchen` als Teil von `/etc`.
|
||||||
|
- **Fund — SSRF vollständig offen.** Webhook auf `169.254.169.254` (Metadatendienst der Cloud), `127.0.0.1:5432` (eigene Datenbank), privates Netz. `platform/netguard` prüft an **zwei** Stellen: beim Anlegen (der Betreiber sieht es sofort) und vor dem Verbindungsaufbau (ein Name kann zwischenzeitlich auf eine andere Adresse zeigen — der übliche Weg um eine einmalige Prüfung herum). Eingebettete IPv4-Adressen (`::ffff:127.0.0.1`) werden entpackt, **jede** aufgelöste Adresse geprüft. Auch der SMTP-Server ist ein Ziel.
|
||||||
|
- **Fund — Rate Limit nur beim Login.** 100 von 100 Anfragen liefen durch; teuer sind Bericht (PDF), Vorabprüfung (liest jeden Block) und Security Center (zehn Abfragen). Jetzt allgemeiner Begrenzer in der Middleware-Kette, Vorgabe 600/min. Nachgewiesen: 700 Anfragen → 594 × 200, 106 × 429.
|
||||||
|
- **Fund — kein TLS.** Jetzt über Zertifikatsdateien, mit TLS 1.3 nachgewiesen. **Eine halbe Konfiguration wird beim Start abgelehnt** (sonst startete der Dienst im Klartext, obwohl der Betreiber Verschlüsselung eingerichtet zu haben glaubt). Ohne TLS warnt der Start — bei Bindung an alle Schnittstellen in Großbuchstaben.
|
||||||
|
- **Fund — govulncheck meldete neun Schwachstellen der Go-Standardbibliothek** (Toolchain 1.26.1, behoben in 1.26.5). Nach dem Anheben: null.
|
||||||
|
- **Fund — die Repository-Wurzel war weltlesbar.** `MkdirAll` legt Elternverzeichnisse mit der umask an, während jedes Unterverzeichnis `0700` trug.
|
||||||
|
- **ST1005 ist die einzige abgeschaltete staticcheck-Regel.** Sie verlangt kleingeschriebene Fehlertexte ohne Satzzeichen — eine englische Konvention. Ein Teil der Texte geht unverändert an Anwender; die Regel zu befolgen hieße, Anwendermeldungen zu verstümmeln, damit ein Werkzeug schweigt.
|
||||||
|
- **Der Secret-Scanner ist selbst geschrieben und läuft als Test mit.** Er kennt Platzhalter (sonst meldet er jede Beispielkonfiguration) und gibt einen Fund **nie vollständig** aus — ein Scanner, der das Geheimnis ins Prüfprotokoll schreibt, hat es ein zweites Mal veröffentlicht. Ein zweiter Test prüft, dass er überhaupt anschlägt: Ein Scanner ohne greifende Muster ist von einem sauberen Bestand nicht zu unterscheiden. 14 Fundstellen im Bestand, alle erfundene Testwerte, jetzt mit `secretscan:erlaubt` gekennzeichnet.
|
||||||
|
- Siehe `docs/hardening.md`.
|
||||||
|
|
||||||
|
### Phase 20 (Leistungsmessung)
|
||||||
|
|
||||||
|
**Eine Zahl ohne Messbedingungen ist wertlos — und wird trotzdem zitiert.** Jede Messung trägt Maschine, Datenmenge, Datenbeschaffenheit und Einstellungen bei sich. `Result.Validate()` lehnt einen Durchsatz aus komprimierbaren Daten ab und einen Lauf unter einer halben Sekunde: Der Fehler aus Phase 4 (1021-fache Kompression bei periodischen Testdaten) steht jetzt im Modell, nicht in einer Anleitung.
|
||||||
|
|
||||||
|
- **Gemessen auf Apple M1, 8 Kerne, lokale SSD, inkompressible Daten, ohne Verschlüsselung:** große Quelle 151,9 MiB/s; zweiter Lauf über unveränderte Daten 662,8 MiB/s; vier gleichzeitige Aufträge 148,4 MiB/s; viele kleine Dateien 107 Dateien/s.
|
||||||
|
- **Der zweite Lauf ist viermal schneller** — unabhängige Bestätigung der Aussage aus Phase 6: Der Gewinn einer Zusatzsicherung ist Zeit, nicht Speicher.
|
||||||
|
- **Bei einer großen Quelle ist die Platte der Engpass, nicht die CPU** (0,7 von 8 Kernen ausgelastet). Mehr Arbeiter brächten nichts.
|
||||||
|
- **Die Streaming-Pipeline hält:** 256 MiB Quelle bei 256 MiB Speichergrenze, Höchststand 195 MiB.
|
||||||
|
- **Fund — 4 MiB Lesepuffer für jede 16-KiB-Datei.** Der Chunker legte ihn stets in Höchstblockgröße an; 4000 Dateien forderten exakt 16 GiB an. Mit `ChunkerOptions.ExpectedSize`: 411 MiB, 90 statt 1447 Bereinigungen, Rechenzeit von 5,44 s auf 1,27 s.
|
||||||
|
- **Der eigentliche Engpass bei kleinen Dateien ist `fsync`.** Direkt gemessen: 113 Dateien/s mit, 4722 ohne — Faktor 42. Die Anlage erreicht 107, ihr Eigenanteil liegt bei rund fünf Prozent. Das ist kein Fehler, sondern der Preis des Commit-Protokolls aus Phase 2. Den Verzeichnis-`fsync` zu bündeln wäre möglich und **wurde bewusst nicht getan**: Der Eingriff verändert die Haltbarkeitszusage und gehört für sich entschieden, nicht nebenbei in einer Messphase.
|
||||||
|
- **Zwei Funde am Messwerkzeug selbst.** Der zweite Aufruf scheiterte an einer vergebenen Backup-Kennung — ein Messwerkzeug, das sich nicht wiederholen lässt, ist keines. Und schlimmer: Bei bestehendem Repository maß „Eine große Quelle" beim zweiten Mal die Deduplizierung statt das Ablegen (641 statt 152 MiB/s), **unbemerkt** — beide Läufe lieferten plausible Zahlen. Aufgefallen allein an der fehlenden Zeile „Abgelegt".
|
||||||
|
- **Nicht gemessen und als solches ausgewiesen:** echte VM (kein Proxmox), langsames Repository (eine selbst vorgegebene Wartezeit misst nichts), Datenträger-IOPS (auf macOS nicht je Prozess auslesbar), Netzdurchsatz (nur lokale Ablage), Verschlüsselungsaufschlag, Repository-Konkurrenz. Siehe `docs/performance.md`.
|
||||||
|
|
||||||
|
### Phase 21 (Chaos Testing)
|
||||||
|
|
||||||
|
**„Every failure must produce a controlled result."** Kontrolliert heißt vier Dinge, und **drei von vier genügen nicht**: Der Fehler wird gemeldet, er ist klassifiziert, es bleibt kein sichtbares unvollständiges Backup zurück, der Zustand danach ist konsistent. Die dritte Bedingung wiegt am schwersten — ein abgebrochener Lauf, der ein halbes Backup als gültig hinterlässt, ist ein Datenverlust mit Zeitzünder.
|
||||||
|
|
||||||
|
- **Platte voll, mit echtem Dateisystem geprüft** (40 MiB per `hdiutil`, 120 MiB Quelle): Exit 1, klare Meldung, **null Manifeste**, keine halben Dateien. Das Backup wird erst mit dem Manifest sichtbar — es entsteht nie ein scheinbar gültiges.
|
||||||
|
- **Fund — volle Platte wurde als `source` klassifiziert und deshalb wiederholt.** Die Quelle ist aber in Ordnung; jeder Wiederholungslauf legte weitere Blöcke ab und **verschärfte** die Lage. Jetzt `REPOSITORY_FULL` mit Klasse `configuration` (nicht wiederholbar). Erkennung über `errors.Is(err, syscall.ENOSPC)`, nicht über den Meldungstext — ein Textvergleich bräche bei der ersten übersetzten Fehlermeldung, unbemerkt.
|
||||||
|
- **Fund — Datenbankausfall wurde als „Sitzung abgelaufen" gemeldet.** Die Tokenprüfung braucht die Datenbank; fällt sie aus, scheitert jede Prüfung. Der Betreiber meldet sich neu an, was ebenfalls scheitert, und sucht den Fehler bei der Anmeldung. Jetzt `SERVICE_UNAVAILABLE` mit dem Zusatz „Das ist kein Problem Ihrer Sitzung."
|
||||||
|
- **Datenbankausfall im Betrieb:** `/health/live` bleibt 200, `/health/ready` wird 503, der Dienst überlebt und ist 2 s nach Rückkehr der Datenbank wieder bereit — ohne Neustart. Genau das Verhalten aus Phase 0.
|
||||||
|
- **Manifest beschädigt:** Prüfung meldet mit Empfehlung, Wiederherstellung bricht ab (**0 Dateien im Ziel**), Katalog-Neuaufbau schließt es aus. Dass der Katalog es zunächst weiter anzeigt, ist richtig — er ist ein Beschleuniger; verbindlich sind die Manifeste.
|
||||||
|
- **Grenzen:** Netzverlust nicht neu ausgelöst (nur lokale Repositories; Nachweis aus Phase 5 und 18), Stromausfall simuliert (SIGKILL, kein Verlust des Plattenzwischenspeichers), Störungen einzeln statt kombiniert, kein Dauerlauf mit zufälligen Störungen. Siehe `docs/chaos.md`.
|
||||||
|
|
||||||
|
### Phase 22 (Release Candidate)
|
||||||
|
|
||||||
|
**Eingefroren heißt nicht „nicht mehr ändern", sondern „nur noch absichtlich".** Vier Verträge stehen in Dateien, die man anfassen muss, und in Tests, die jede Abweichung melden: `contract_routes.txt` (102 Endpunkte), `migrations/checksums.txt`, ein Container-Fixture und ein Repository-Fixture. Alle vier Prüfungen sind mit Mutationen als fangend nachgewiesen.
|
||||||
|
|
||||||
|
- **Der API-Vertrag wertet den Quelltext aus, nicht den gebauten Multiplexer.** `http.ServeMux` gibt seine Routen nicht heraus, und ein Test, der die bekannten Adressen anfragt, bemerkt eine **hinzugefügte** nicht — die häufigste Vertragsänderung überhaupt. Die geforderte Berechtigung steht mit in der Zeile: Eine stillschweigend gelockerte Prüfung ist die gefährlichste Änderung, die es gibt, weil der Endpunkt weiter funktioniert.
|
||||||
|
- **Eine ausgelieferte Migration darf sich nie wieder ändern.** Datenbanken, die sie angewandt haben, führen sie nicht erneut aus — die Änderung wirkt ausschließlich auf **neue** Installationen, und es entstehen zwei Schemata mit derselben Versionsnummer. Wer etwas ändern will, schreibt eine neue Migration.
|
||||||
|
- **Fixture-Dateien statt Rundlauftests.** Ein Rundlauf schreibt und liest mit demselben Code und bliebe grün, wenn sich beide Seiten gemeinsam ändern — genau der gefürchtete Fall. Neu erzeugen nur mit `SYNCOVA_WRITE_FIXTURE=ja`.
|
||||||
|
- **Der Upgrade-Test ist der einzige, den es nie gab.** Bisher wurde jedes Schema von Grund auf angelegt; das prüft ausschließlich die Neuinstallation. Eine Spalte mit `NOT NULL` ohne Vorgabewert läuft auf einer leeren Tabelle durch und scheitert auf einer gefüllten — als Mutation nachgewiesen. Eine Absicherung weist einen leeren Ausgangsbestand zurück: Ein Vergleich „vorher gleich nachher" ist auf leeren Tabellen immer erfüllt.
|
||||||
|
- **Rollback: genau ein Schritt, ältere Daten unberührt, Weg nach vorn offen.** Alle Rückrichtungen laufen auf einer **gefüllten** Datenbank bis Version 0 und wieder hoch. Was er nicht kann, steht ausdrücklich im Test: Daten in Tabellen, die es vorher nicht gab, sind danach weg. Ein Rollback ersetzt keine Sicherung der Datenbank.
|
||||||
|
- **Fund im Test selbst:** Die Rollback-Prüfung unterstellte, die Proxmox-Migration sei die letzte. Mit 000013 stimmte das nicht mehr, und der Test meldete einen Fehler, wo keiner war. Er sucht die Tabelle jetzt, statt eine Reihenfolge anzunehmen.
|
||||||
|
- **Fund im Durchlauf — ein Repository ließ sich über die API gar nicht anlegen.** `SYNCOVA_API.md` §8 verlangt sieben Endpunkte, vorhanden war die Liste. Jeder frühere Nachweis hatte die Zeile selbst per SQL eingetragen; deshalb fiel es nie auf. Die Anlage war über ihre eigene API nicht in Betrieb zu nehmen. `POST /repositories` **legt nichts an, sondern übernimmt**: Es öffnet das vorhandene Repository und liest dessen Kennung aus dem Descriptor.
|
||||||
|
- **Fund — der Ort eines Repositorys war nicht eindeutig.** Zwei Einträge auf dasselbe Verzeichnis ergäben Wettlauf um die Schreibsperre und doppelt gezählten Speicher. Migration `000013` setzt die Eindeutigkeit.
|
||||||
|
- **Der Integritätslauf über die API meldet einen Befund nicht als Fehler.** „Die Prüfung schlug fehl" und „das Repository ist beschädigt" sind zwei völlig verschiedene Lagen.
|
||||||
|
- Nachgewiesen: 30 MiB über die API gesichert, Integritätslauf sauber (69 Blöcke), Prüfung mit Wiederherstellungstest `clean` → `recoverable` 70 %, Quelle gelöscht, **61 von 61 Dateien bitgenau** zurück, Symlink und Rechte erhalten. Disaster Recovery auf leerer Datenbank: 11 Repositories, 12 Aufträge, 6 Konten — **0 aktive Aufträge, 0 aktive Konten**. Security 29/88 (`unzureichend`, 8 kritische Befunde — richtig für diese Umgebung). Leistung ohne Regression gegen Phase 20.
|
||||||
|
- **Akzeptanzmatrix: 32 von 38 Zeilen erbracht**, 2 nicht umgesetzt (Windows-Dienst, Capacity Forecast), 4 nur gegen Nachbauten. Alle offenen hängen an fehlender Hardware. Siehe `docs/release-candidate.md`.
|
||||||
|
|
||||||
|
### Phase 23 (V1 Release)
|
||||||
|
|
||||||
|
**Das Paket ist ein Verzeichnisbaum, kein Installationsprogramm.** Ein Betreiber soll sehen können, was er auspackt: `bin/`, `web/`, `migrations/`, `docs/`, `deployment/` — dazu `SHA256SUMS` **im** Paket, damit sich der Inhalt auch dann prüfen lässt, wenn nur er übertragen wurde. `make release` baut alle drei Zielplattformen.
|
||||||
|
|
||||||
|
- **`CGO_ENABLED=0`.** Ohne den Schalter bindet Go gegen die libc des Bausystems; der Start scheitert dann auf einer älteren Distribution mit einer Meldung über GLIBC, die niemand einem Backupprogramm zuordnet. Real geprüft: Die gebauten Programme laufen in einem leeren `debian:12-slim`.
|
||||||
|
- **macOS wird bewusst nicht ausgeliefert.** Dort wurde entwickelt, aber der Plan nennt es nicht als Ziel — und eine Plattform auszuliefern, für die es kein Betriebskonzept gibt, weckt Erwartungen, die niemand einlöst. Für Windows kommen Agent und `syncova-repo`, nicht der ganze Server: Programme ohne Betriebskonzept auszuliefern, nur weil sie übersetzen, ist dasselbe Versprechen in klein.
|
||||||
|
- **Fund — kein Programm beantwortete `--version` ohne vollständige Konfiguration.** Wer wissen will, welche Fassung auf einem Server liegt, hat in dem Moment womöglich keine Datenbank: frisch ausgepacktes Paket, laufende Störung. Alle acht Programme antworten jetzt vor dem Laden der Konfiguration, und zwar auf `version`, `--version` und `-version` — sich zu merken, welches Programm welche Schreibweise erwartet, ist niemandem zuzumuten.
|
||||||
|
- **Fund — `syncova-repo break-lock` stand in der Störungsdoku und existierte nicht.** `BreakLock` gab es im Kern, aber ohne Kommandozeile; bei einer hängenden Sperre wäre nur das Löschen der Datei von Hand geblieben. Jetzt vorhanden — es nennt zuerst **wer** die Sperre hält (Prozess, Rechner, Zeitpunkt) und verlangt `SYNCOVA_REPO_CONFIRM_BREAK_LOCK=ja`. Eine unlesbare Sperrdatei gilt als Sperre: Sie als „keine" zu melden wäre der gefährlichste Ausgang.
|
||||||
|
- **Jedes in der Doku genannte Kommando wurde gegen die Wirklichkeit gehalten.** Eine Anleitung, die nicht vorhandene Befehle nennt, ist schlimmer als keine — der Leser sucht dann den Fehler bei sich.
|
||||||
|
- **`scripts/release_test.go` prüft die Vollständigkeit** und läuft im gewöhnlichen Testlauf mit: alle zwölf Bestandteile aus §25, jedes gebaute Programm, alle drei Zielplattformen, `CGO_ENABLED=0`. Ein Programm, das gebaut, aber nicht ausgeliefert wird, fiele sonst erst beim Kunden auf.
|
||||||
|
- **Die Änderungsliste nennt das Nichtenthaltene zuerst.** Kapazitätsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute, harte Verknüpfungen — dazu getrennt davon, was gebaut, aber nie auf echter Hardware gefahren wurde.
|
||||||
|
|
||||||
|
## API-Konventionen
|
||||||
|
|
||||||
|
- Basis `/api/v1`, Bearer-Token, `X-Correlation-ID` auf jedem Request.
|
||||||
|
- Erfolg: `{ "data": …, "meta": { "request_id": … } }` — Fehler: `{ "error": { "code": "BACKUP_REPOSITORY_UNAVAILABLE", "message": …, "details": …, "request_id": … } }`. Fehlercodes sind sprechende SCREAMING_SNAKE_CASE-Konstanten.
|
||||||
|
- Pagination `?page=1&page_size=50`, Totals in `meta`.
|
||||||
|
- `Idempotency-Key` für: Job erstellen, Backup starten, Restore erstellen, Repository erstellen, destruktive Konfigurationsänderungen.
|
||||||
|
- RBAC-Rollen: Viewer, Backup Operator, Restore Operator, Security Administrator, Infrastructure Administrator, Auditor, Super Administrator.
|
||||||
|
|
||||||
|
## Datenbank-Konventionen
|
||||||
|
|
||||||
|
UUIDs als öffentliche IDs, alle Zeitstempel `TIMESTAMPTZ` in UTC, Fremdschlüssel verpflichtend, Soft-Delete wo Audit/Historie es verlangt, niemals Passwörter oder rohe Schlüssel speichern (nur Hashes bzw. `*_ref`/`*_ciphertext`). Jede Schemaänderung als versionierte Migration — der Produktionsstart darf das Schema nie stillschweigend verändern. Empfohlene Indizes stehen in `SYNCOVA_DATABASE.md` §17.
|
||||||
|
|
||||||
|
## Implementierungsreihenfolge
|
||||||
|
|
||||||
|
Die Phasenfolge ist bewusst gewählt: **nicht mit dem Dashboard beginnen.** Die erste produktionsreife vertikale Scheibe ist
|
||||||
|
|
||||||
|
```text
|
||||||
|
PostgreSQL → Control API → Repository → Backup Engine → Testquelle
|
||||||
|
→ Backup → Manifest → Integritätsprüfung → Restore
|
||||||
|
```
|
||||||
|
|
||||||
|
Erst danach kommen Agents (Phase 5/6) und der Proxmox-Provider (Phase 7). Der verpflichtende E2E-Meilenstein von Phase 7 lautet: VM entdecken → sichern → verifizieren → Test-VM löschen → wiederherstellen → booten → validieren. Jede Phase endet mit lauffähiger Software, automatisierten Tests, Doku, Security-Review und messbaren Exit-Kriterien.
|
||||||
|
|
||||||
|
Priorisierung: **P0** Backup Engine, Repository, Encryption, Integrity, Recovery, Immutability, Security, Proxmox-/Windows-/Linux-Backup — **P1** Web UI, Dashboard, Monitoring, Alerts, Statistiken, Verification, RBAC, MFA, API — **P2** erweiterte Reports, Capacity Forecast, Anomaly Detection, Object Storage, Backup Copy, Synthetic Full.
|
||||||
|
|
||||||
|
### Backup-Wizard (Phase 8, Oberfläche)
|
||||||
|
|
||||||
|
- **Die Logik liegt in `wizardModel.ts`, getrennt von der Maske.** Welcher Schritt vollständig ist und was in die Anfrage wandert, ist reine Berechnung — und damit ohne gerenderte Maske prüfbar.
|
||||||
|
- **Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern** — sonst wäre jeder Blick zurück ein Datenverlust. Ein noch nicht erreichter Schritt ist nicht anklickbar; er überspränge eine Prüfung.
|
||||||
|
- **Aufbewahrung, Prüfung und Benachrichtigung fragen nichts ab.** Sie erscheinen (der Plan nennt zehn Schritte), aber ohne Eingabefelder — eine Maske, die Werte sammelt, die niemand auswertet, ist ein vorgetäuschtes Funktionsversprechen. Stattdessen steht dort, was ohne diese Einstellung geschieht.
|
||||||
|
- **`accepts_backups` kommt vom Server.** Die Oberfläche müsste sonst wissen, welche Repository-Zustände schreibend sind. Gesperrte Ziele werden gezeigt und begründet, nicht weggelassen.
|
||||||
|
- **Verschlüsselung ist keine Wahl** — der Executor verweigert ohne Schlüssel den Dienst. Eine Schaltfläche zum Abschalten wäre eine Einstellung, die es nicht gibt.
|
||||||
|
- **Die Zeitzone kommt aus dem Browser.** Ohne Angabe rechnet der Server in UTC, und derselbe Auftrag liefe je nach Standort anders.
|
||||||
|
- **Fund:** `Exec` mit mehreren durch Semikolon getrennten Anweisungen führt pgx über das erweiterte Protokoll **nicht** aus — das Aufräumen der Executor-Tests lief ins Leere und ließ Zeilen in der Datenbank liegen. Jede Anweisung einzeln absetzen.
|
||||||
|
|
||||||
|
## UI-Konventionen
|
||||||
|
|
||||||
|
Semantische Farben ausschließlich für Status: Grün = Healthy, Gelb = Warning, Orange = High, Rot = Critical, Blau/Neutral = Information. Kein zusätzlicher Farbeinsatz zur Dekoration. Simple Mode und Advanced Mode werden getrennt gedacht; der Backup-Wizard führt in 10 Schritten von Name bis Create.
|
||||||
228
Makefile
Normal file
228
Makefile
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
# Makefile für die Entwicklung an Syncova.
|
||||||
|
#
|
||||||
|
# Überblick über alle Ziele: make help
|
||||||
|
|
||||||
|
SHELL := /bin/bash
|
||||||
|
|
||||||
|
# Verzeichnis für gebaute Binaries.
|
||||||
|
BIN_DIR := bin
|
||||||
|
|
||||||
|
# Compose-Datei der lokalen Entwicklungsumgebung.
|
||||||
|
COMPOSE_FILE := deployment/docker-compose.yml
|
||||||
|
|
||||||
|
# Datei mit der lokalen Konfiguration. Sie wird von "make dev-env" erzeugt.
|
||||||
|
ENV_FILE := .env
|
||||||
|
|
||||||
|
# Version, die in die Binaries eingebrannt wird.
|
||||||
|
# Ohne Git-Tag wird der Kurz-Hash des Commits verwendet.
|
||||||
|
BUILD_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.1.0-dev")
|
||||||
|
|
||||||
|
# Linker-Flags setzen die Version zur Bauzeit.
|
||||||
|
GO_LDFLAGS := -X main.buildVersion=$(BUILD_VERSION)
|
||||||
|
|
||||||
|
# Zu prüfende Go-Pakete.
|
||||||
|
#
|
||||||
|
# node_modules wird ausgeschlossen: einzelne NPM-Pakete bringen eigenen Go-Code
|
||||||
|
# mit, der sonst in Tests, Lint und Schwachstellenprüfung auftauchen würde.
|
||||||
|
GO_PACKAGES := $(shell go list ./... 2>/dev/null | grep -v '/node_modules/')
|
||||||
|
|
||||||
|
# Verzeichnisse mit eigenem Go-Quelltext (für gofmt, das keine Paketpfade kennt).
|
||||||
|
GO_SOURCE_DIRS := apps/api apps/agent packages migrations
|
||||||
|
|
||||||
|
# Alle Rezepte laufen mit den Variablen aus der .env, sofern vorhanden.
|
||||||
|
ifneq (,$(wildcard $(ENV_FILE)))
|
||||||
|
include $(ENV_FILE)
|
||||||
|
export
|
||||||
|
endif
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## Zeigt diese Übersicht
|
||||||
|
@echo "Syncova – verfügbare Ziele:"
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entwicklungsumgebung
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.PHONY: dev-env
|
||||||
|
dev-env: ## Erzeugt .env mit einem zufälligen Datenbankpasswort
|
||||||
|
@if [ -f $(ENV_FILE) ]; then \
|
||||||
|
echo "$(ENV_FILE) existiert bereits – es wird nicht überschrieben."; \
|
||||||
|
exit 0; \
|
||||||
|
fi
|
||||||
|
@cp .env.example $(ENV_FILE)
|
||||||
|
@# Das Passwort wird lokal zufällig erzeugt, damit es nirgends eingecheckt ist.
|
||||||
|
@GENERATED_PASSWORD="$$(openssl rand -base64 24 | tr -d '/+=' | head -c 32)"; \ # secretscan:erlaubt: erfundener Testwert
|
||||||
|
sed -i.bak "s|^SYNCOVA_DB_PASSWORD=.*|SYNCOVA_DB_PASSWORD=$$GENERATED_PASSWORD|" $(ENV_FILE); \
|
||||||
|
rm -f $(ENV_FILE).bak
|
||||||
|
@# Ohne Verschlüsselungsschlüssel startet kein Dienst; er wird deshalb
|
||||||
|
@# gleich miterzeugt. openssl liefert genau die geforderten 32 Byte.
|
||||||
|
@GENERATED_KEY="$$(openssl rand -base64 32)"; \
|
||||||
|
sed -i.bak "s|^SYNCOVA_ENCRYPTION_KEYS=.*|SYNCOVA_ENCRYPTION_KEYS=v1:$$GENERATED_KEY|" $(ENV_FILE); \
|
||||||
|
rm -f $(ENV_FILE).bak
|
||||||
|
@chmod 600 $(ENV_FILE)
|
||||||
|
@echo "$(ENV_FILE) wurde mit zufälligem Datenbankpasswort und Verschlüsselungsschlüssel erzeugt."
|
||||||
|
|
||||||
|
.PHONY: dev-up
|
||||||
|
dev-up: require-env ## Startet PostgreSQL und wartet auf Betriebsbereitschaft
|
||||||
|
@docker compose --env-file $(ENV_FILE) -f $(COMPOSE_FILE) up -d --wait
|
||||||
|
@echo "PostgreSQL ist bereit."
|
||||||
|
|
||||||
|
.PHONY: dev-down
|
||||||
|
dev-down: ## Stoppt die lokale Umgebung (Daten bleiben erhalten)
|
||||||
|
@docker compose -f $(COMPOSE_FILE) down
|
||||||
|
|
||||||
|
.PHONY: dev-reset
|
||||||
|
dev-reset: ## Stoppt die Umgebung und LÖSCHT alle lokalen Datenbankdaten
|
||||||
|
@echo "Achtung: Dieser Schritt löscht das lokale Datenbank-Volume."
|
||||||
|
@read -p "Fortfahren? [j/N] " CONFIRMATION; \
|
||||||
|
if [ "$$CONFIRMATION" = "j" ] || [ "$$CONFIRMATION" = "J" ]; then \
|
||||||
|
docker compose -f $(COMPOSE_FILE) down --volumes; \
|
||||||
|
echo "Lokale Datenbankdaten wurden gelöscht."; \
|
||||||
|
else \
|
||||||
|
echo "Abgebrochen."; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
.PHONY: require-env
|
||||||
|
require-env: ## Prüft, ob die lokale Konfiguration vorhanden ist
|
||||||
|
@if [ ! -f $(ENV_FILE) ]; then \
|
||||||
|
echo "Es fehlt die Datei $(ENV_FILE). Bitte zuerst 'make dev-env' ausführen."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Backend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: ## Baut alle Go-Binaries nach ./bin
|
||||||
|
@mkdir -p $(BIN_DIR)
|
||||||
|
@go build -ldflags "$(GO_LDFLAGS)" -o $(BIN_DIR)/syncova-api ./apps/api/cmd/syncova-api
|
||||||
|
@go build -ldflags "$(GO_LDFLAGS)" -o $(BIN_DIR)/syncova-migrate ./apps/api/cmd/syncova-migrate
|
||||||
|
@go build -ldflags "$(GO_LDFLAGS)" -o $(BIN_DIR)/syncova-admin ./apps/api/cmd/syncova-admin
|
||||||
|
@go build -ldflags "$(GO_LDFLAGS)" -o $(BIN_DIR)/syncova-repo ./apps/api/cmd/syncova-repo
|
||||||
|
@go build -ldflags "$(GO_LDFLAGS)" -o $(BIN_DIR)/syncova-dr ./apps/api/cmd/syncova-dr
|
||||||
|
@go build -ldflags "$(GO_LDFLAGS)" -o $(BIN_DIR)/syncova-bench ./apps/api/cmd/syncova-bench
|
||||||
|
@go build -ldflags "$(GO_LDFLAGS)" -o $(BIN_DIR)/syncova-agent ./apps/agent/cmd/syncova-agent
|
||||||
|
@go build -ldflags "$(GO_LDFLAGS)" -o $(BIN_DIR)/syncova-proxmox ./apps/api/cmd/syncova-proxmox
|
||||||
|
@echo "Binaries gebaut (Version $(BUILD_VERSION))."
|
||||||
|
|
||||||
|
.PHONY: run-api
|
||||||
|
run-api: require-env ## Startet den API-Dienst lokal
|
||||||
|
@go run -ldflags "$(GO_LDFLAGS)" ./apps/api/cmd/syncova-api
|
||||||
|
|
||||||
|
.PHONY: migrate-up
|
||||||
|
migrate-up: require-env ## Wendet alle ausstehenden Datenbankmigrationen an
|
||||||
|
@go run ./apps/api/cmd/syncova-migrate up
|
||||||
|
|
||||||
|
.PHONY: migrate-status
|
||||||
|
migrate-status: require-env ## Zeigt den aktuellen Migrationsstand
|
||||||
|
@go run ./apps/api/cmd/syncova-migrate status
|
||||||
|
|
||||||
|
.PHONY: migrate-down
|
||||||
|
migrate-down: require-env ## Nimmt genau eine Migration zurück
|
||||||
|
@go run ./apps/api/cmd/syncova-migrate down
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test: ## Führt alle Go-Tests mit Race-Detector aus
|
||||||
|
@go test -race $(GO_PACKAGES)
|
||||||
|
|
||||||
|
.PHONY: test-coverage
|
||||||
|
test-coverage: ## Führt die Go-Tests aus und schreibt einen Coverage-Bericht
|
||||||
|
@go test -race -coverprofile=coverage.out $(GO_PACKAGES)
|
||||||
|
@go tool cover -func=coverage.out | tail -1
|
||||||
|
|
||||||
|
.PHONY: lint
|
||||||
|
lint: ## Prüft Formatierung und statische Analyse des Go-Codes
|
||||||
|
@echo "==> gofmt"
|
||||||
|
@UNFORMATTED_FILES="$$(gofmt -l $(GO_SOURCE_DIRS))"; \
|
||||||
|
if [ -n "$$UNFORMATTED_FILES" ]; then \
|
||||||
|
echo "Nicht formatierte Dateien gefunden:"; echo "$$UNFORMATTED_FILES"; exit 1; \
|
||||||
|
fi
|
||||||
|
@echo "==> go vet"
|
||||||
|
@go vet $(GO_PACKAGES)
|
||||||
|
@echo "==> staticcheck"
|
||||||
|
@go run honnef.co/go/tools/cmd/staticcheck@latest $(GO_PACKAGES)
|
||||||
|
|
||||||
|
# Die Sicherheitsprüfungen laufen getrennt vom Lint, weil sie das Netz brauchen:
|
||||||
|
# govulncheck gleicht gegen die Schwachstellendatenbank von Go ab. In einer
|
||||||
|
# Umgebung ohne Netz soll `make lint` trotzdem durchlaufen.
|
||||||
|
# Die Plattformprüfung ist keine Formalie.
|
||||||
|
#
|
||||||
|
# Bis Phase 5 liess sich der Agent fuer Windows **gar nicht uebersetzen** —
|
||||||
|
# unix.Statfs gibt es dort nicht. Der Windows-Dienst galt als „geschrieben, aber
|
||||||
|
# ungeprueft"; tatsaechlich haette er sich nicht einmal bauen lassen. Ein
|
||||||
|
# Uebersetzungslauf je Zielplattform faengt das in Sekunden.
|
||||||
|
.PHONY: cross-build
|
||||||
|
cross-build: ## Prüft die Übersetzbarkeit für alle Zielplattformen
|
||||||
|
@echo "==> windows/amd64"
|
||||||
|
@GOOS=windows GOARCH=amd64 go build -o /dev/null ./apps/agent/cmd/syncova-agent
|
||||||
|
@GOOS=windows GOARCH=amd64 go build -o /dev/null ./apps/api/cmd/syncova-api
|
||||||
|
@GOOS=windows GOARCH=amd64 go build -o /dev/null ./apps/api/cmd/syncova-repo
|
||||||
|
@echo "==> linux/amd64"
|
||||||
|
@GOOS=linux GOARCH=amd64 go build -o /dev/null ./apps/agent/cmd/syncova-agent
|
||||||
|
@GOOS=linux GOARCH=amd64 go build -o /dev/null ./apps/api/cmd/syncova-api
|
||||||
|
@echo "==> linux/arm64"
|
||||||
|
@GOOS=linux GOARCH=arm64 go build -o /dev/null ./apps/agent/cmd/syncova-agent
|
||||||
|
@echo "Alle Zielplattformen übersetzen."
|
||||||
|
|
||||||
|
.PHONY: security-scan
|
||||||
|
security-scan: ## Prüft Abhängigkeiten auf bekannte Schwachstellen
|
||||||
|
@echo "==> govulncheck"
|
||||||
|
@go run golang.org/x/vuln/cmd/govulncheck@latest $(GO_PACKAGES)
|
||||||
|
@echo "==> npm audit (Frontend)"
|
||||||
|
@cd apps/web && npm audit --audit-level=high
|
||||||
|
|
||||||
|
.PHONY: tidy
|
||||||
|
tidy: ## Bereinigt die Go-Modulabhängigkeiten
|
||||||
|
@go mod tidy
|
||||||
|
|
||||||
|
.PHONY: generate-key
|
||||||
|
generate-key: ## Erzeugt einen neuen Verschlüsselungsschlüssel
|
||||||
|
@go run ./apps/api/cmd/syncova-admin generate-key
|
||||||
|
|
||||||
|
.PHONY: create-admin
|
||||||
|
create-admin: require-env ## Legt den ersten Administrator an (fragt nach dem Passwort)
|
||||||
|
@go run ./apps/api/cmd/syncova-admin create-admin --username $(USERNAME)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Frontend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.PHONY: web-install
|
||||||
|
web-install: ## Installiert die Frontend-Abhängigkeiten
|
||||||
|
@cd apps/web && npm ci
|
||||||
|
|
||||||
|
.PHONY: web-dev
|
||||||
|
web-dev: ## Startet den Frontend-Entwicklungsserver
|
||||||
|
@cd apps/web && npm run dev
|
||||||
|
|
||||||
|
.PHONY: web-build
|
||||||
|
web-build: ## Baut das Frontend für die Auslieferung
|
||||||
|
@cd apps/web && npm run build
|
||||||
|
|
||||||
|
.PHONY: web-test
|
||||||
|
web-test: ## Führt die Frontend-Tests aus
|
||||||
|
@cd apps/web && npm run test
|
||||||
|
|
||||||
|
.PHONY: web-lint
|
||||||
|
web-lint: ## Prüft den Frontend-Code
|
||||||
|
@cd apps/web && npm run lint
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Übergreifend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
.PHONY: secret-scan
|
||||||
|
secret-scan: ## Durchsucht den Quellbestand nach Geheimnissen
|
||||||
|
@go test -count=1 -run TestRepositoryContainsNoSecrets ./packages/platform/secretscan/
|
||||||
|
|
||||||
|
.PHONY: release
|
||||||
|
release: ## Baut das Auslieferungspaket nach ./dist
|
||||||
|
@scripts/build-release.sh
|
||||||
|
|
||||||
|
.PHONY: check
|
||||||
|
check: lint cross-build test web-lint web-test ## Führt alle Prüfungen aus (wie in der CI)
|
||||||
|
@echo "Alle Prüfungen bestanden."
|
||||||
466
README.md
Normal file
466
README.md
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
# Syncova Backups
|
||||||
|
|
||||||
|
Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform für Proxmox VE, Windows, Linux, physische Systeme sowie Dateien und Ordner.
|
||||||
|
|
||||||
|
> **Leitsatz des Produkts:** Ein Backup gilt erst als vertrauenswürdig, wenn seine Integrität geprüft und seine Wiederherstellbarkeit nachgewiesen wurde.
|
||||||
|
|
||||||
|
## Stand der Entwicklung
|
||||||
|
|
||||||
|
**Phase 0** (Produktfundament), **Phase 1** (Identität und Sicherheit), **Phase 2** (Repository Engine), **Phase 3** (Backup-Format), **Phase 4** (Backup Engine), **Phase 5** und **Phase 6** (Agenten — Dienstanbindungen geschrieben, auf echten Systemen ungeprüft), **Phase 8** (Scheduler), **Phase 9** (Recovery Engine), **Phase 10** (Verification und Recovery Assurance), **Phase 11** (Immutability), **Phase 12** (Weboberfläche), **Phase 13** (Kennzahlen), **Phase 14** (Meldungen), **Phase 15** (Security Center), **Phase 16** (Ransomware-Heuristik), **Phase 17** (Berichte) **Phase 18** (Disaster Recovery) **Phase 19** (Härtung) **Phase 20** (Leistungsmessung) und **Phase 21** (Chaos Testing) des [Implementierungsplans](SYNCOVA_IMPLEMENTATION_PLAN.md) sind abgeschlossen. Vorhanden sind:
|
||||||
|
|
||||||
|
- Control-Plane-API in Go mit Health-Engine, strukturiertem Logging und Correlation IDs
|
||||||
|
- PostgreSQL-Anbindung samt versioniertem Migrationsframework
|
||||||
|
- Anmeldung mit Argon2id-Passwörtern, TOTP-Zweitfaktor und Wiederherstellungscodes
|
||||||
|
- Rollenbasierte Zugriffssteuerung mit sieben Rollen und 34 Einzelberechtigungen
|
||||||
|
- Append-only-Auditprotokoll, per Datenbank-Trigger gegen Änderung geschützt
|
||||||
|
- Brute-Force-Schutz durch Kontosperre und Rate-Limiting
|
||||||
|
- Weboberfläche mit Übersicht, Wiederherstellungspunkten, Sicherungsaufträgen, Repositories, Agenten, Wiederherstellungen, Ereignissen, Benutzern und Rollen — unfertige Bereiche erscheinen benannt statt versteckt oder leer
|
||||||
|
- Repository Engine: inhaltsadressierte Chunk-Ablage mit Deduplizierung, versionierte Manifeste, atomares Commit-Protokoll, Integritätsscan, Katalog-Wiederaufbau ohne Datenbank, gehärteter Modus mit Aufbewahrungsschutz
|
||||||
|
- Versioniertes Backup-Format: portabler Container mit Versionsverhandlung, streamendem Schreiben, durchgehender Integritätsprüfung und Export/Import zwischen Repositories
|
||||||
|
- Backup Engine: inhaltsabhängiges Chunking, Deduplizierung trotz Verschlüsselung, zstd-Kompression in vier Stufen, AES-256-GCM, Streaming-Pipeline mit Worker-Pool und Backpressure, bitgenaue Wiederherstellung
|
||||||
|
- Security Center: acht geprüfte Bereiche, vollständige Befunde mit Empfehlung, Verlauf der Bewertung
|
||||||
|
- Meldungswesen: zehn geprüfte Regeln, eine Ursache je Meldung, automatische Auflösung, Zustellung per E-Mail und Webhook
|
||||||
|
- Kennzahlen: zwölf Diagramme über sieben Zeiträume, Lücken bleiben Lücken statt zu Nullen zu werden
|
||||||
|
- Unveränderlichkeit: gemessene statt behauptete Durchsetzungsstufe, Löschschutz über das Dateisystem, Legal Hold, Aufbewahrungsregeln mit Vorschau und Schutz des letzten Backups
|
||||||
|
- Prüfung und Recovery Assurance: fünf Prüfarten vom Manifest bis zum vollständigen Wiederherstellungstest, objektive Einstufung jedes Backups, Bewertung aus zehn Größen, die Ungemessenes niemals als gut zählt
|
||||||
|
- Agent: Aufnahme über einmalige Tokens, eigenes Betriebstoken ohne Benutzerrechte, Lebendmeldung mit Wiederaufnahme nach Netzunterbrechung, Dateierfassung mit Ein- und Ausschlussregeln
|
||||||
|
|
||||||
|
**Phase 5 ist für Windows nicht abgeschlossen:** die Dienstanbindung ist geschrieben, aber auf dieser Entwicklungsplattform nicht prüfbar. Einzelheiten in [docs/agent.md](docs/agent.md). Der Proxmox-Provider ist gebaut, der Nachweis auf echter Hardware steht aus — siehe unten. Funktionen, die noch nicht implementiert sind, werden ausdrücklich als solche gekennzeichnet und niemals vorgetäuscht.
|
||||||
|
|
||||||
|
## Ein System sichern und wiederherstellen
|
||||||
|
|
||||||
|
Die vertikale Scheibe aus [§27 des Implementierungsplans](SYNCOVA_IMPLEMENTATION_PLAN.md) ist geschlossen — Sichern und Wiederherstellen funktionieren end-to-end:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SYNCOVA_ENCRYPTION_KEYS="v1:$(openssl rand -base64 32)" # sicher verwahren!
|
||||||
|
|
||||||
|
syncova-repo create --path /backup/repo-01 --name "Produktiv"
|
||||||
|
syncova-agent backup --repository /backup/repo-01 --path /daten --id tagessicherung
|
||||||
|
syncova-repo scan --path /backup/repo-01 --deep
|
||||||
|
syncova-agent restore --repository /backup/repo-01 --id tagessicherung --target /wiederhergestellt
|
||||||
|
|
||||||
|
# Folgeläufe lesen nur, was sich geändert hat:
|
||||||
|
syncova-agent backup --repository /backup/repo-01 --path /daten --id nacht-2 --incremental
|
||||||
|
```
|
||||||
|
|
||||||
|
Real nachgewiesen: 41 Dateien mit Verzeichnissen, Rechten und symbolischem Verweis gesichert, Quelle gelöscht, wiederhergestellt — **Struktur, Inhalt, Rechte und Verweise bitgenau identisch**. Ein zweiter Lauf ohne Änderungen legt 0 Byte ab (100 % dedupliziert, trotz Verschlüsselung).
|
||||||
|
|
||||||
|
Die Zusatzsicherung spart **Zeit, nicht Speicher** — den Speicher spart die Deduplizierung ohnehin. Bei 190,7 MiB, von denen sich eine von 41 Dateien änderte: 1 815 ms voll gegen 133 ms inkrementell. Das entstehende Manifest bleibt trotzdem vollständig, eine Wiederherstellung braucht also nie die Kette.
|
||||||
|
|
||||||
|
## Sicherungsaufträge
|
||||||
|
|
||||||
|
Aufträge werden über `/api/v1/jobs` angelegt und laufen nach Zeitplan — die Ausführungsschleife läuft im API-Dienst mit und sichert über die Backup Engine.
|
||||||
|
|
||||||
|
Angelegt werden sie über den **Backup-Wizard** in der Oberfläche (zehn Schritte von Name bis Anlegen) oder direkt über die API.
|
||||||
|
|
||||||
|
Real nachgewiesen: 38 MiB beauftragt, vom Scheduler gesichert, Integritätsprüfung sauber, Quelle gelöscht, aus der Zusatzsicherung **bitgenau** wiederhergestellt. Der zweite Lauf las 0,0 MiB statt 38,1 MiB. Einzelheiten und Grenzen in [docs/scheduler.md](docs/scheduler.md) — gesichert werden derzeit nur Dateisystemquellen; Aufbewahrung, Prüfung und Benachrichtigung sind im Wizard als „noch nicht verfügbar" gekennzeichnet.
|
||||||
|
|
||||||
|
## Wiederherstellung
|
||||||
|
|
||||||
|
Vor jeder Wiederherstellung steht die **Vorabprüfung**: Sie schreibt nichts und stellt fest, ob jeder benötigte Block noch im Repository liegt — der eigentliche Nachweis der Wiederherstellbarkeit, lange bevor jemand ihn braucht.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST /api/v1/restores/validate -d '{"backup_id":"…","target_path":"/wiederhergestellt"}'
|
||||||
|
curl -X POST /api/v1/restores -d '{"backup_id":"…","target_path":"/wiederhergestellt"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Real nachgewiesen: 11,4 MiB gesichert, Quelle gelöscht, über die API wiederhergestellt — **bitgenau identisch**. Nach dem Entfernen eines einzigen Blocks meldet die Prüfung „NICHT WIEDERHERSTELLBAR" und benennt die betroffene Datei; der Auftrag wird abgelehnt. Einzelheiten in [docs/recovery.md](docs/recovery.md).
|
||||||
|
|
||||||
|
## Ist dieses Backup vertrauenswürdig?
|
||||||
|
|
||||||
|
Die Frage, die das Produkt trägt — und die einzige Antwort, die zählt, kommt vom **Wiederherstellungstest**: Das Backup wird an ein Wegwerfziel zurückgeschrieben und Datei für Datei gegen seine Prüfsummen verglichen. Manifest-, Block- und Kettenprüfung sind gute Indizien, aber Indizien.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST /api/v1/verification -d '{"backup_id":"…","verification_type":"restore_test"}'
|
||||||
|
curl /api/v1/backups/<id>/assurance
|
||||||
|
```
|
||||||
|
|
||||||
|
Jedes Backup trägt daraufhin eine Einstufung — `successful` → `verified` → `recoverable`, oder `corrupted` — und eine Bewertung aus zehn Größen. **Was nie gemessen wurde, zählt nie als gut:** Eine ungemessene Größe bekommt null Punkte und wird als ungemessen ausgewiesen; `missing_measurements` sagt, was als Nächstes zu prüfen ist.
|
||||||
|
|
||||||
|
Real nachgewiesen, vollständiger Lebenszyklus gegen den laufenden Dienst:
|
||||||
|
|
||||||
|
| Zustand | Einstufung | Bewertung |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| gesichert, ungeprüft | `successful` | 35 % |
|
||||||
|
| Integritätsprüfung bestanden | `verified` | 55 % |
|
||||||
|
| Wiederherstellungstest bestanden | `recoverable` | 90 % |
|
||||||
|
| ein Byte in einem Block gekippt | `corrupted` | **0 %** |
|
||||||
|
| Block zurückgespielt, erneut geprüft | `verified` | 55 % |
|
||||||
|
|
||||||
|
Der Befund nannte die betroffene Datei, nicht nur eine Zahl. Dass ein beschädigtes Backup 0 % bekommt und nicht 70 %, ist eine Korrektur aus genau diesem Nachweis: Ein Backup, aus dem sich ein Block nicht mehr lesen lässt, ist nicht zu 70 % wiederherstellbar. Einzelheiten in [docs/verification.md](docs/verification.md).
|
||||||
|
|
||||||
|
## Die Oberfläche sagt, was sie nicht weiß
|
||||||
|
|
||||||
|
Der Implementierungsplan nennt fünfzehn Seiten und zehn Kennzahlen. Sieben Kennzahlen haben heute eine Datengrundlage. Die übrigen drei — kritische Meldungen, Kapazitätsprognose, Security Score — erscheinen trotzdem, mit der Angabe, was fehlt:
|
||||||
|
|
||||||
|
> **Kritische Meldungen** · noch nicht verfügbar
|
||||||
|
> Es gibt noch kein Meldungswesen (Phase 14). Eine Null an dieser Stelle hieße „keine Probleme" und würde bedeuten „es wird nicht geprüft".
|
||||||
|
|
||||||
|
Dieselbe Regel überall: Ohne hinterlegte Speicherkapazität gibt es keinen Prozentsatz, sondern „nicht bezifferbar". Ohne Lauf in sieben Tagen gibt es keine Erfolgsquote von 100 %, sondern eine Warnung. Ein Wiederherstellungspunkt ohne Bewertung zeigt „nicht berechnet", nicht „0 %".
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make web-install # einmalig
|
||||||
|
make web-dev # Oberfläche auf http://127.0.0.1:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Einzelheiten in [docs/web-ui.md](docs/web-ui.md).
|
||||||
|
|
||||||
|
## Kann jemand die Backups überhaupt vernichten?
|
||||||
|
|
||||||
|
Das Security Center prüft acht Bereiche — vom Löschschutz über den zweiten Faktor bis zur Verteilung destruktiver Rechte — und beantwortet die Frage, die vor der Wiederherstellbarkeit kommt.
|
||||||
|
|
||||||
|
Jeder Befund trägt vier Angaben, und die vierte ist die wichtigste:
|
||||||
|
|
||||||
|
> **Kein Repository mit nachgewiesenem Löschschutz** · kritisch
|
||||||
|
> *Warum:* Bei keinem Repository verhindert das Dateisystem die Löschung. Ein kompromittiertes Konto kann alle Sicherungen entfernen — Verschlüsselung und zweiter Faktor halten dann niemanden auf.
|
||||||
|
> *Betroffen:* alle 3 erreichbaren Repositories
|
||||||
|
> *Was tun:* Legen Sie ein gehärtetes Repository an und messen Sie die Durchsetzungsstufe.
|
||||||
|
|
||||||
|
Ein Befund ohne Empfehlung ist eine Beunruhigung — deshalb wird ein unvollständiger Befund abgelehnt statt ausgeliefert.
|
||||||
|
|
||||||
|
**Ein kritischer Befund deckelt die Einstufung** auf „unzureichend", unabhängig von der Prozentzahl: Eine Anlage, bei der ein gestohlenes Konto alles vernichten kann, ist nicht „gut abgesichert mit kleinem Mangel". Und zwei der zehn Bereiche werden **nicht** geprüft — sekundäre Kopien und Agentenzertifikate gibt es nicht. Sie zählen weder als bestanden noch als durchgefallen.
|
||||||
|
|
||||||
|
Real nachgewiesen: 35 % („unzureichend", 2 kritische Befunde) → Repository gehärtet → 40 % → zweiter Faktor eingerichtet → 57 % („verbesserungsbedürftig", 0 kritisch). Einzelheiten in [docs/security-center.md](docs/security-center.md).
|
||||||
|
|
||||||
|
## Der Alarm, den niemand mehr liest
|
||||||
|
|
||||||
|
Zehn Regeln prüfen fortlaufend, ob jemand hinsehen muss — vom gescheiterten Lauf über das volle Repository bis zum Verdacht auf massenhafte Verschlüsselung. Der Feind des Meldungswesens ist dabei nicht der fehlende Alarm:
|
||||||
|
|
||||||
|
> Es ist der Alarm, den niemand mehr liest.
|
||||||
|
|
||||||
|
Deshalb zwei Eigenschaften, die alles tragen: **Eine Ursache erzeugt genau eine Meldung** (der wiederholte Befund erhöht nur einen Zähler), und **Meldungen lösen sich selbst auf**, sobald ihre Ursache verschwindet. Real nachgewiesen: Zwei von vier nicht erreichbaren Repositories wieder verfügbar gemacht — genau deren zwei Meldungen schlossen sich automatisch, die anderen blieben offen.
|
||||||
|
|
||||||
|
„Bestätigen" heißt dabei „ich weiß davon", nicht „weg damit": Die Meldung bleibt in der Liste, bis der Zustand tatsächlich vorbei ist.
|
||||||
|
|
||||||
|
Die elfte Regel — ablaufende Agentenzertifikate — wird **nicht** ausgewertet und sagt das: Die Agenten weisen sich über Betriebstokens aus, die Tabelle bleibt leer. Eine Regel, die dauerhaft schweigt, ist gefährlicher als keine.
|
||||||
|
|
||||||
|
Zustellung per E-Mail und Webhook, jeweils mit eigener Schwelle. Einzelheiten in [docs/alerting.md](docs/alerting.md).
|
||||||
|
|
||||||
|
## Melden, niemals handeln
|
||||||
|
|
||||||
|
Sechs Signale beschreiben einen Sicherungslauf: neu abgelegte Datenmenge, geänderte Objekte, verschwundene Objekte, Anteil nicht verkleinerbarer Blöcke, neue Dateiendungen, Größe des Laufs. Gemessen wird nicht gegen eine feste Schwelle, sondern gegen den **Normalverlauf derselben Kette** — ein Dateiserver mit zwei geänderten Dateien am Tag und ein Buildserver mit vierzigtausend sind beide normal.
|
||||||
|
|
||||||
|
Der Anteil nicht verkleinerbarer Blöcke ist dabei das aussagekräftigste Signal, und er kostet nichts: Die Engine entscheidet ohnehin bei jedem Block, ob er sich komprimieren ließ. Verschlüsselte Daten lassen sich nicht komprimieren — ein Sprung von 5 % auf 100 % ist der Fingerabdruck massenhafter Verschlüsselung.
|
||||||
|
|
||||||
|
Was Syncova daraufhin tut, ist der eigentliche Punkt:
|
||||||
|
|
||||||
|
> Nichts.
|
||||||
|
|
||||||
|
Es wird nichts gelöscht, nichts gesperrt, nichts angehalten. Eine Heuristik, die selbsttätig handelt, macht aus jedem Fehlalarm einen Schaden — und von außen sieht ein Betriebssystem-Update aus wie ein Angriff. Die Empfehlung lautet stattdessen: *Prüfen Sie die Quelle, bevor Sie ältere Wiederherstellungspunkte löschen.* Sie ist eine Unterlassung, denn wer nach einem Angriff die Aufbewahrung laufen lässt, vernichtet die letzten sauberen Wiederherstellungspunkte.
|
||||||
|
|
||||||
|
Nachgewiesen in beide Richtungen — ein Detektor, der auch jeden großen Arbeitstag meldet, wird nach einer Woche ignoriert:
|
||||||
|
|
||||||
|
| Lauf | Einstufung | Auffällig |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 200 neue, komprimierbare Dokumente | erhöht | 1 von 6 Signalen |
|
||||||
|
| 150 Dateien durch Zufallsdaten mit Endung `.locked` ersetzt, Originale gelöscht | **hoch** | 4 von 6 Signalen |
|
||||||
|
|
||||||
|
Einzelheiten in [docs/ransomware.md](docs/ransomware.md).
|
||||||
|
|
||||||
|
## Was ein Bericht nicht sagen darf
|
||||||
|
|
||||||
|
Neun Berichte — vom Tagesbericht über die Repository-Kapazität bis zum Bericht für Prüfungen — in CSV, JSON und PDF. Das PDF ist selbst geschrieben: Ein Prüfbericht wird als PDF verlangt, nicht als Tabelle.
|
||||||
|
|
||||||
|
Der Unterschied zu einer gewöhnlichen Berichtsmaske steckt in den leeren Zellen:
|
||||||
|
|
||||||
|
> Ein Zeitraum ohne Sicherungslauf hat **keine** Erfolgsquote — nicht hundert Prozent und nicht null.
|
||||||
|
|
||||||
|
Ein Bericht verlässt die Anlage. Er landet in einer Tabellenkalkulation, wo eine Null summiert, gemittelt und in ein Diagramm gezeichnet wird, und in einem Ordner, aus dem ihn Monate später jemand zieht, ohne nachfragen zu können. Syncova lässt die Zelle deshalb leer und schreibt den Grund daneben.
|
||||||
|
|
||||||
|
Am deutlichsten wird das beim RTO: Die Zeit, die eine Wiederherstellung *bräuchte*, kennt niemand, bevor sie stattgefunden hat. Dort steht eine tatsächlich gemessene Dauer — oder „nicht gemessen". Eine Hochrechnung aus Datenmenge und Durchsatz wäre die bequemste Zahl des Berichts und die einzige, auf die sich im Ernstfall niemand verlassen könnte.
|
||||||
|
|
||||||
|
Und der Bericht für Prüfungen liefert Messwerte statt Urteile: kein „erfüllt", kein Haken, keine Norm. Er ist keine Zertifizierung und sagt das auch.
|
||||||
|
|
||||||
|
Einzelheiten in [docs/reports.md](docs/reports.md).
|
||||||
|
|
||||||
|
## Der Tag, an dem nichts mehr da ist
|
||||||
|
|
||||||
|
Vier Notfallszenarien — Control-Server verloren, Datenbank verloren, Katalog verloren, Abbruch mitten im Lauf. Alle vier wurden nicht beschrieben, sondern **durchgespielt**.
|
||||||
|
|
||||||
|
Die Entscheidung, an der alles hängt, steht in einem Satz:
|
||||||
|
|
||||||
|
> Eine Konfigurationssicherung, die nur auf dem Control-Server liegt, ist beim Verlust des Control-Servers wertlos.
|
||||||
|
|
||||||
|
Server und Datenbank gehen meist gemeinsam verloren — sie stehen auf derselben Maschine. Der einzige Ort, der das überlebt, ist das Repository. Dort liegt deshalb auch die Konfiguration, neben den Backups: Aufträge, Quellen, Aufbewahrungsregeln, Konten mit ihren Rollen.
|
||||||
|
|
||||||
|
Was dort **nicht** liegt, ist ebenso wichtig: keine Passwörter, keine zweiten Faktoren, keine Zugangsdaten der Benachrichtigungswege — auch nicht deren Konfiguration, denn in einer Webhook-Adresse steckt oft das Token. Ein Repository liegt naturgemäß außerhalb der Anlage, womöglich bei einem Dienstleister.
|
||||||
|
|
||||||
|
Nach dem Wiederaufbau läuft nichts von selbst wieder an: Aufträge kommen angehalten zurück, Repositories als „nicht erreichbar", Konten deaktiviert. Ein Zeitplan, der um zwei Uhr nachts von allein startet, könnte auf ein halb wiederhergestelltes System schreiben.
|
||||||
|
|
||||||
|
Der Nachweis, in Kurzform:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DROP DATABASE syncova → 0 Tabellen
|
||||||
|
syncova-dr restore --catalog → Konfiguration + Wiederherstellungspunkte zurück
|
||||||
|
Quellverzeichnis gelöscht → Wiederherstellung: alle Prüfsummen stimmen
|
||||||
|
|
||||||
|
rm -rf indexes/ → Katalog baut sich aus den Manifesten neu auf
|
||||||
|
|
||||||
|
SIGKILL bei 496 von 1500 → Freigabe, Fortsetzung, 1500 Dateien, 0 übersprungen
|
||||||
|
```
|
||||||
|
|
||||||
|
Dabei fiel ein Fehler auf, den fünfzehn Phasen lang niemand bemerkt hatte: Jedes Manifest trug die Größe **null**, weil die Backup Engine an der Zählung der Schreibsession vorbeischrieb. Aufgefallen ist das erst, als das Repository zum ersten Mal die alleinige Quelle war — genau der Fall, für den es gebaut ist.
|
||||||
|
|
||||||
|
Einzelheiten in [docs/disaster-recovery.md](docs/disaster-recovery.md).
|
||||||
|
|
||||||
|
## Angegriffen statt behauptet
|
||||||
|
|
||||||
|
Elf Angriffsarten, gefahren gegen die laufende Anlage. Sieben wurden abgewehrt — SQL Injection, RBAC-Umgehung, Rechteausweitung, gefälschte Tokens, XSS, Pfadausbruch, und ein gesperrtes Konto verlor seinen Zugang sofort statt erst beim Ablauf des Tokens.
|
||||||
|
|
||||||
|
Vier kamen durch:
|
||||||
|
|
||||||
|
| Angriff | Was möglich war |
|
||||||
|
| --- | --- |
|
||||||
|
| Wiederherstellungsziel | Ein Backup nach `/etc/cron.d` zurückschreiben — aus einem Anwendungsrecht wird ein Systemzugang |
|
||||||
|
| SSRF | Ein Webhook auf `169.254.169.254` — den Metadatendienst der Cloud, der mit Zugangsdaten antwortet |
|
||||||
|
| Rate Limit | 100 von 100 Anfragen liefen durch; nur der Login war begrenzt |
|
||||||
|
| TLS | Gab es nicht |
|
||||||
|
|
||||||
|
Alle vier sind behoben, jeder mit einem Regressionstest. Beim SSRF-Schutz sitzt die Prüfung an **zwei** Stellen — beim Anlegen und erneut vor dem Verbindungsaufbau. Der Grund ist kein Übereifer: Ein DNS-Eintrag, der beim Prüfen öffentlich und beim Zustellen intern auflöst, ist der übliche Weg um eine einmalige Prüfung herum.
|
||||||
|
|
||||||
|
Dazu kam ein Fund aus dem Abhängigkeitsscan: neun Schwachstellen in der Go-Standardbibliothek. Nach dem Anheben der Toolchain: null.
|
||||||
|
|
||||||
|
Einzelheiten in [docs/hardening.md](docs/hardening.md).
|
||||||
|
|
||||||
|
## Zahlen, die sagen, woher sie kommen
|
||||||
|
|
||||||
|
Sieben Messszenarien auf einem Apple M1 mit acht Kernen, lokaler SSD und **inkompressiblen** Daten:
|
||||||
|
|
||||||
|
| Szenario | Ergebnis |
|
||||||
|
| --- | --- |
|
||||||
|
| Eine große Quelle (512 MiB) | 151,9 MiB/s |
|
||||||
|
| Zweiter Lauf über unveränderte Daten | 662,8 MiB/s |
|
||||||
|
| Vier gleichzeitige Aufträge | 148,4 MiB/s |
|
||||||
|
| Viele kleine Dateien (4000 × 16 KiB) | 107 Dateien/s |
|
||||||
|
|
||||||
|
Der zweite Lauf ist viermal schneller als der erste — gelesen und gehasht wird weiterhin alles, gespart wird das Ablegen. Das bestätigt unabhängig, was Phase 6 behauptet: Der Gewinn einer Zusatzsicherung ist Zeit, nicht Speicher.
|
||||||
|
|
||||||
|
Warum jede Zahl ihre Bedingungen mitträgt, hat einen Grund in der eigenen Geschichte: In Phase 4 zeigte ein Messlauf eine 1021-fache Kompression — die Testdaten waren periodisch, die Zahl wertlos. Heute lehnt das Messmodell einen Durchsatz aus komprimierbaren Daten ab.
|
||||||
|
|
||||||
|
Die Messung fand zwei Dinge. Der Chunker legte für **jede** Datei einen 4-MiB-Lesepuffer an, auch für eine von 16 KiB: 4000 Dateien forderten exakt 16 GiB Speicher an. Und bei kleinen Dateien ist nicht die Anlage der Engpass, sondern `fsync` — direkt gemessen 113 Dateien pro Sekunde mit, 4722 ohne. Die Anlage erreicht 107; ihr Eigenanteil liegt bei fünf Prozent. Das ist der Preis der Haltbarkeitszusage, kein Fehler.
|
||||||
|
|
||||||
|
Einzelheiten in [docs/performance.md](docs/performance.md).
|
||||||
|
|
||||||
|
## Jeder Fehler muss vorhersagbar enden
|
||||||
|
|
||||||
|
Platten laufen voll, Datenbanken fallen aus, Prozesse werden abgeschossen. Die Frage ist nicht, ob das passiert, sondern wie es endet. „Kontrolliert" heißt hier vier Dinge — und drei von vier genügen ausdrücklich nicht:
|
||||||
|
|
||||||
|
1. Der Fehler wird gemeldet, nicht verschluckt.
|
||||||
|
2. Er ist klassifiziert — sonst weiß niemand, ob eine Wiederholung sinnvoll ist.
|
||||||
|
3. Es bleibt **kein unvollständiges Backup zurück, das gültig aussieht**.
|
||||||
|
4. Der Zustand danach ist konsistent.
|
||||||
|
|
||||||
|
Die dritte wiegt am schwersten. Ein abgebrochener Lauf, der nichts hinterlässt, ist ein Ärgernis. Einer, der ein halbes Backup als gültig hinterlässt, ist ein Datenverlust mit Zeitzünder — er fällt erst auf, wenn jemand wiederherstellen will.
|
||||||
|
|
||||||
|
Geprüft mit einem **echten** 40-MiB-Dateisystem, einer Sicherung von 120 MiB und einem angehaltenen Datenbankcontainer. Zwei Funde:
|
||||||
|
|
||||||
|
**Eine volle Platte wurde als Quellfehler eingestuft — und deshalb wiederholt.** Die Quelle war in Ordnung; jeder neue Versuch legte weitere Blöcke ab und verschärfte die Lage. Eine falsche Fehlerklasse führt hier nicht zu einem sinnlosen, sondern zu einem schädlichen Versuch.
|
||||||
|
|
||||||
|
**Ein Datenbankausfall meldete sich als „Sitzung abgelaufen".** Die Tokenprüfung braucht die Datenbank; fällt sie aus, scheitert jede Anmeldung. Der Betreiber meldet sich neu an, was ebenfalls scheitert, und sucht den Fehler an der falschen Stelle.
|
||||||
|
|
||||||
|
Einzelheiten in [docs/chaos.md](docs/chaos.md).
|
||||||
|
|
||||||
|
## Eine Lücke ist keine Null
|
||||||
|
|
||||||
|
Zwölf Diagramme über sieben Zeiträume, von der Erfolgsquote bis zum Alter des jüngsten Wiederherstellungspunkts. Die Regel, die darüber entscheidet, ob eine Kurve etwas wert ist:
|
||||||
|
|
||||||
|
> Ein Zeitfenster ohne Sicherungslauf hat **keinen** Durchsatz — nicht null Byte je Sekunde.
|
||||||
|
|
||||||
|
Wer Lücken als Nullen zeichnet, erzeugt eine Kurve, die nachts auf den Boden fällt: Die Anlage sieht aus, als wäre ihre Leistung eingebrochen, obwohl sie nur nichts zu tun hatte. Syncova unterbricht die Linie stattdessen. Das Diagramm ist deshalb selbst geschrieben — die gängigen Bibliotheken machen es standardmäßig falsch.
|
||||||
|
|
||||||
|
Zwei der zwölf Diagramme haben keine Datengrundlage und sagen das: Die Agenten melden keine Ressourcendaten, und die Größe nach der Kompression wird nirgends festgehalten. Eine Kurve aus geschätzten Werten wäre eine erfundene Statistik.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "/api/v1/metrics" # Katalog aller Diagramme
|
||||||
|
curl "/api/v1/metrics/backup_throughput?range=24h" # eine Reihe
|
||||||
|
```
|
||||||
|
|
||||||
|
Einzelheiten in [docs/metrics.md](docs/metrics.md).
|
||||||
|
|
||||||
|
## Kann ein Angreifer die Backups löschen?
|
||||||
|
|
||||||
|
Syncova beantwortet die Frage nicht mit einer Datenbankspalte, sondern mit einer **Messung**: Eine Probedatei wird angelegt und zu löschen versucht. Gemeldet wird nur, was das Betriebssystem nachweislich verhindert.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST /api/v1/repositories/<id>/enforcement/measure
|
||||||
|
```
|
||||||
|
|
||||||
|
| Stufe | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| `advisory` | Nur diese Software hält sich daran. Wer Dateizugriff hat, löscht trotzdem. |
|
||||||
|
| `filesystem` | Das Dateisystem verweigert die Löschung. Ein Systemverwalter kann den Schutz weiterhin aufheben. |
|
||||||
|
| `storage` | S3 Object Lock / WORM — **nicht umgesetzt**, wird deshalb nie gemeldet. |
|
||||||
|
|
||||||
|
In einem gehärteten Repository tragen Manifeste, Datenblöcke, Schutzvermerke, der Descriptor **und der Datenschlüssel** das Unveränderlich-Kennzeichen des Dateisystems. Real nachgewiesen: `rm -rf` auf ein solches Repository verweigert 15 Löschungen, alle geschützten Dateien überleben, und der anschließende Tiefenscan meldet das Backup als vollständig.
|
||||||
|
|
||||||
|
Zwei Angriffsversuche waren zunächst erfolgreich und haben den Umfang bestimmt: Beim ersten überlebte das Manifest, aber nicht seine Blöcke — ein Backup, das sich für vollständig ausgibt und leer ist. Beim zweiten fehlten Descriptor und Datenschlüssel; die Daten waren da und trotzdem verloren.
|
||||||
|
|
||||||
|
Dazu **Legal Hold** (unbefristet, mit Begründungspflicht), Fristverlängerung (verkürzen ist ausgeschlossen — auch für Administratoren) und Aufbewahrungsregeln mit Vorschau. Jede Regel schützt das letzte vorhandene Backup: Ohne diese Sicherung löschte „7 Tage" bei einem drei Wochen nicht gesicherten System *jedes* Backup. Einzelheiten in [docs/immutability.md](docs/immutability.md).
|
||||||
|
|
||||||
|
## Proxmox
|
||||||
|
|
||||||
|
Ein Gast lässt sich entdecken, sichern, prüfen und bitgenau zurückschreiben — die Kette läuft von der Auftragsverwaltung über vzdump und die Backup Engine ins Repository und wieder zurück auf den Knoten. Ein Test belegt den vollständigen Rundlauf.
|
||||||
|
|
||||||
|
**Ungeprüft bleibt der entscheidende Schritt: ob die wiederhergestellte Maschine startet.** Dafür stand keine echte Umgebung zur Verfügung. Bis zu diesem Nachweis ist der Provider nicht freigegeben; der Ablauf dafür steht in [docs/proxmox.md](docs/proxmox.md).
|
||||||
|
|
||||||
|
Die Lücke, die alles erklärt: **Proxmox kann eine Sicherung anstoßen, gibt die entstandene Datei aber nicht über die REST-API heraus.** Deshalb braucht Syncova einen zweiten Zugriffsweg auf den Knoten — als Dateizugriff auf eine gemeinsame Freigabe (`local`) oder über SSH. Beim SSH-Weg wird ein Knoten ohne hinterlegten Fingerabdruck seines Wirtsschlüssels abgelehnt; einen Schalter „Wirtsschlüssel egal" gibt es nicht.
|
||||||
|
|
||||||
|
Rein lesende Erfassung gegen einen echten Verbund — der erste Schritt, er verändert nichts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SYNCOVA_PROXMOX_TOKEN_SECRET="<geheimnis>"
|
||||||
|
syncova-proxmox discover --url https://pve.example:8006 \
|
||||||
|
--token 'syncova@pve!backup' --fingerprint <sha256> --disks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Was ist eingefroren?
|
||||||
|
|
||||||
|
Vor einer Auslieferung wird festgeschrieben, worauf sich andere verlassen: der API-Vertrag, die Migrationen, das Backup-Format und das Repository-Protokoll. Nicht als Absichtserklärung, sondern als Prüfung, die anschlägt.
|
||||||
|
|
||||||
|
| Vertrag | Was auffällt |
|
||||||
|
| --- | --- |
|
||||||
|
| 102 API-Endpunkte | ein neuer, ein entfallener oder ein **anders berechtigter** Endpunkt |
|
||||||
|
| 26 Migrationsdateien | eine nachträglich geänderte Migration, eine fehlende Gegenrichtung |
|
||||||
|
| Backup-Container | ein Container von gestern, der heute nicht mehr lesbar ist |
|
||||||
|
| Repository | ein umbenanntes Verzeichnis, ein beschädigter Block |
|
||||||
|
|
||||||
|
Die letzten beiden liegen als **echte Dateien** im Quellbestand. Ein gewöhnlicher Rundlauftest schreibt und liest mit demselben Code — er bliebe grün, wenn sich beide Seiten gemeinsam ändern, also genau im gefürchteten Fall.
|
||||||
|
|
||||||
|
Der wichtigste neue Test ist der, den es nie gab: **eine alte Datenbank mit Daten, über die eine neue Version läuft.** Bisher wurde jedes Schema von Grund auf angelegt; damit prüft man ausschließlich die Neuinstallation. Eine Spalte mit `NOT NULL` ohne Vorgabewert läuft auf einer leeren Tabelle einwandfrei durch und scheitert auf einer gefüllten.
|
||||||
|
|
||||||
|
Der vollständige Durchlauf brachte dabei einen Fund, den keine der 21 Phasen davor gefunden hatte: **Ein Repository ließ sich über die API gar nicht anlegen** — jeder frühere Nachweis hatte die Datenbankzeile selbst geschrieben. Einzelheiten in [docs/release-candidate.md](docs/release-candidate.md).
|
||||||
|
|
||||||
|
## Auslieferung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make release
|
||||||
|
```
|
||||||
|
|
||||||
|
Erzeugt je Zielplattform einen Verzeichnisbaum und ein Archiv — Programme, Oberfläche, Migrationen, Dokumentation, Prüfsummen. Die Programme sind statisch gebunden und laufen in einem leeren `debian:12-slim`.
|
||||||
|
|
||||||
|
| Paket | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| `linux-amd64`, `linux-arm64` | vollständiger Server, Oberfläche, Agent, alle Werkzeuge |
|
||||||
|
| `windows-amd64` | Agent und `syncova-repo` |
|
||||||
|
|
||||||
|
macOS und ein vollständiger Windows-Server werden bewusst nicht ausgeliefert: Eine Plattform ohne Betriebskonzept weckt Erwartungen, die niemand einlöst.
|
||||||
|
|
||||||
|
Für den Einstieg: [Installation](docs/installation.md) · [Wiederherstellung im Ernstfall](docs/recovery-runbook.md) · [Sicherheitsleitfaden](docs/security-guide.md) · [API](docs/api.md) · [Störungen](docs/troubleshooting.md)
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
Voraussetzungen: Go 1.26+, Node.js 22+, Docker.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev-env # erzeugt .env mit Zufallspasswort und Schlüssel
|
||||||
|
make dev-up # startet PostgreSQL
|
||||||
|
make migrate-up # wendet die Datenbankmigrationen an
|
||||||
|
make create-admin USERNAME=admin # legt den ersten Administrator an
|
||||||
|
make run-api # startet die API auf 127.0.0.1:8080
|
||||||
|
|
||||||
|
# in einer zweiten Shell:
|
||||||
|
make web-install
|
||||||
|
make web-dev # startet die Oberfläche auf 127.0.0.1:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Es gibt bewusst **kein** vorkonfiguriertes Standardkonto: das wäre eine bekannte Schwachstelle jeder Installation. Der erste Administrator wird auf dem Server angelegt, das Passwort dabei verdeckt eingegeben.
|
||||||
|
|
||||||
|
Prüfung, ob alles läuft:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8080/api/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
`make help` listet alle verfügbaren Ziele.
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/
|
||||||
|
api/ Control-Plane-API und Migrationskommando
|
||||||
|
web/ Weboberfläche (React + TypeScript)
|
||||||
|
agent/ Windows- und Linux-Agent (ab Phase 5)
|
||||||
|
worker/ Hintergrundverarbeitung (ab Phase 4)
|
||||||
|
packages/
|
||||||
|
platform/ Querschnitt: Konfiguration, Logging, Datenbank, Health
|
||||||
|
migrations/ Versionierte SQL-Migrationen
|
||||||
|
deployment/ Lokale Entwicklungsumgebung
|
||||||
|
docs/ Betriebs- und Architekturdokumentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Kommandos
|
||||||
|
|
||||||
|
| Kommando | Zweck |
|
||||||
|
| --- | --- |
|
||||||
|
| `syncova-api` | Startet den Control-Plane-API-Dienst |
|
||||||
|
| `syncova-migrate up` | Wendet alle ausstehenden Migrationen an |
|
||||||
|
| `syncova-migrate status` | Zeigt den Migrationsstand |
|
||||||
|
| `syncova-migrate down` | Nimmt genau eine Migration zurück |
|
||||||
|
| `syncova-admin generate-key` | Erzeugt einen Verschlüsselungsschlüssel |
|
||||||
|
| `syncova-admin create-admin` | Legt den ersten Administrator an |
|
||||||
|
| `syncova-admin reset-password` | Setzt ein Passwort zurück (Aussperrung) |
|
||||||
|
| `syncova-repo create` | Legt ein Repository an (`--hardened` für Aufbewahrungsschutz) |
|
||||||
|
| `syncova-dr export` / `inspect` / `restore` | Konfiguration ins Repository sichern, ansehen, einspielen |
|
||||||
|
| `syncova-bench run` | Fährt die Leistungsmessung mit benannten Messbedingungen |
|
||||||
|
| `syncova-repo scan --deep` | Prüft jeden Chunk gegen seine Prüfsumme |
|
||||||
|
| `syncova-repo rebuild` | Baut den Katalog allein aus den Manifesten auf |
|
||||||
|
| `syncova-repo list` / `info` / `health` | Backups, Angaben und Zustand anzeigen |
|
||||||
|
| `syncova-repo prune` | Entfernt verwaiste Chunks (`--apply` zum Ausführen) |
|
||||||
|
| `syncova-repo export` | Schreibt ein Backup als portablen Container |
|
||||||
|
| `syncova-repo import` | Liest einen Container in ein Repository ein |
|
||||||
|
| `syncova-repo inspect` | Prüft einen Container vollständig, ohne ihn einzulesen |
|
||||||
|
| `syncova-agent register` | Nimmt den Agent mit einem Aufnahme-Token auf |
|
||||||
|
| `syncova-agent run` | Startet den Agent als Dienst |
|
||||||
|
| `syncova-agent discover` | Zeigt, welche Dateien erfasst würden |
|
||||||
|
| `syncova-agent backup` | Sichert ein Verzeichnis in ein Repository |
|
||||||
|
| `syncova-agent restore` | Stellt ein Backup wieder her |
|
||||||
|
| `syncova-proxmox discover` | Erfasst Verbund, Knoten, Gäste und Platten (rein lesend) |
|
||||||
|
| `syncova-proxmox clusters` | Zeigt die eingerichteten Virtualisierungsverbünde |
|
||||||
|
| `syncova-proxmox restore-guest` | Stellt einen gesicherten Gast wieder her |
|
||||||
|
|
||||||
|
Migrationen laufen bewusst als eigenes Kommando: der Start des API-Dienstes verändert das Schema niemals selbst. Passt das Schema nicht zur Programmversion, verweigert der Dienst den Start mit einer erklärenden Meldung.
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
Die Konfiguration erfolgt ausschließlich über Umgebungsvariablen mit dem Präfix `SYNCOVA_`; siehe [.env.example](.env.example). Es gibt bewusst keine eingebauten Zugangsdaten: fehlen `SYNCOVA_DB_PASSWORD` oder `SYNCOVA_ENCRYPTION_KEYS`, startet kein Dienst.
|
||||||
|
|
||||||
|
**Der Verschlüsselungsschlüssel ist kritisch.** Ohne ihn sind MFA-Secrets und später Repository-Zugangsdaten dauerhaft unlesbar. Er gehört sicher verwahrt und getrennt von der Datenbank gesichert.
|
||||||
|
|
||||||
|
## Dokumentation
|
||||||
|
|
||||||
|
| Dokument | Inhalt |
|
||||||
|
| --- | --- |
|
||||||
|
| [PROMPT.md](PROMPT.md) | Produktdefinition und verbindliche Entwicklungsregeln |
|
||||||
|
| [SYNCOVA_ARCHITECTURE.md](SYNCOVA_ARCHITECTURE.md) | Systemarchitektur, Backup-Format, Repository-Protokoll |
|
||||||
|
| [SYNCOVA_DATABASE.md](SYNCOVA_DATABASE.md) | Datenbankschema der Control Plane |
|
||||||
|
| [SYNCOVA_API.md](SYNCOVA_API.md) | REST-API-Vertrag |
|
||||||
|
| [SYNCOVA_IMPLEMENTATION_PLAN.md](SYNCOVA_IMPLEMENTATION_PLAN.md) | Phasen, Exit-Kriterien, Akzeptanztests |
|
||||||
|
| [docs/development.md](docs/development.md) | Entwicklungsumgebung und Arbeitsweise |
|
||||||
|
| [docs/architecture.md](docs/architecture.md) | Aufbau des aktuellen Codes |
|
||||||
|
| [docs/security.md](docs/security.md) | Sicherheitsarchitektur: Passwörter, MFA, Sitzungen, RBAC, Audit |
|
||||||
|
| [docs/ransomware.md](docs/ransomware.md) | Ransomware-Heuristik: sechs Signale, robuster Basiswert, „Alert first" |
|
||||||
|
| [docs/reports.md](docs/reports.md) | Berichte: neun Arten, drei Formate, selbst geschriebenes PDF |
|
||||||
|
| [docs/disaster-recovery.md](docs/disaster-recovery.md) | Notfallhandbuch: vier Szenarien, real durchgespielt |
|
||||||
|
| [docs/hardening.md](docs/hardening.md) | Härtung: elf Angriffsarten, vier Funde, vier Behebungen |
|
||||||
|
| [docs/performance.md](docs/performance.md) | Leistungsmessung: sieben Szenarien, Zahlen mit Bedingungen |
|
||||||
|
| [docs/chaos.md](docs/chaos.md) | Chaos Testing: neun Störungen, zwei Funde |
|
||||||
|
| [docs/repository.md](docs/repository.md) | Repository-Format, Commit-Protokoll, Integrität, Wiederaufbau |
|
||||||
|
| [docs/backup-format.md](docs/backup-format.md) | Container-Format, Versionsverhandlung, Export und Import |
|
||||||
|
| [docs/backup-engine.md](docs/backup-engine.md) | Pipeline, Chunking, Deduplizierung trotz Verschlüsselung, Messwerte |
|
||||||
|
| [docs/linux-agent.md](docs/linux-agent.md) | Linux-Agent: Sonderdateien, systemd-Härtung, bekannte Grenzen |
|
||||||
|
| [docs/agent-installation.md](docs/agent-installation.md) | Agent einrichten: Windows-Dienst, systemd, Repositoryzugriff |
|
||||||
|
| [docs/agent-tasks.md](docs/agent-tasks.md) | Auftragsübermittlung: der Agent holt ab, führt aus, meldet zurück |
|
||||||
|
| [docs/agent.md](docs/agent.md) | Agent: Tokenarten, Registrierung, Erfassung, offene Punkte |
|
||||||
|
|
||||||
|
## Entwicklungsregeln
|
||||||
|
|
||||||
|
Diese Regeln gelten ohne Ausnahme:
|
||||||
|
|
||||||
|
1. Ein Teilfehler heißt `PARTIAL FAILURE`, niemals `SUCCESS`.
|
||||||
|
2. Integritätsfehler werden nie verborgen; kein Fehler wird still verschluckt.
|
||||||
|
3. Backup-Nutzdaten gehören niemals in PostgreSQL.
|
||||||
|
4. Secrets erscheinen nie in Logs, Fehlermeldungen, API-Antworten oder der Oberfläche.
|
||||||
|
5. Berechtigungen werden immer serverseitig geprüft.
|
||||||
|
6. Destruktive Aktionen werden bestätigt und auditiert.
|
||||||
|
7. Unfertige Funktionen werden als „nicht implementiert“ gekennzeichnet, nicht simuliert.
|
||||||
|
8. Kein Backup-Feature ohne zugehörigen Recovery-Test.
|
||||||
602
SYNCOVA_API.md
Normal file
602
SYNCOVA_API.md
Normal file
@ -0,0 +1,602 @@
|
|||||||
|
# Syncova Backups V1 — REST API Specification
|
||||||
|
|
||||||
|
Base URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
Transport:
|
||||||
|
|
||||||
|
- HTTPS
|
||||||
|
- JSON
|
||||||
|
- UTF-8
|
||||||
|
|
||||||
|
Authentication:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authorization: Bearer <access-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Every request should support a correlation identifier:
|
||||||
|
|
||||||
|
```text
|
||||||
|
X-Correlation-ID: <uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. Standard Response
|
||||||
|
|
||||||
|
Success:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {},
|
||||||
|
"meta": {
|
||||||
|
"request_id": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Error:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "BACKUP_REPOSITORY_UNAVAILABLE",
|
||||||
|
"message": "The configured repository is unavailable.",
|
||||||
|
"details": {},
|
||||||
|
"request_id": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Authentication
|
||||||
|
|
||||||
|
### POST /auth/login
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response may require MFA:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"mfa_required": true,
|
||||||
|
"challenge_id": "uuid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /auth/mfa/verify
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"challenge_id": "uuid",
|
||||||
|
"code": "123456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /auth/refresh
|
||||||
|
|
||||||
|
Refresh an access token.
|
||||||
|
|
||||||
|
### POST /auth/logout
|
||||||
|
|
||||||
|
Invalidate the current session.
|
||||||
|
|
||||||
|
## 3. Current User
|
||||||
|
|
||||||
|
### GET /me
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
|
||||||
|
- user
|
||||||
|
- roles
|
||||||
|
- permissions
|
||||||
|
- MFA status
|
||||||
|
|
||||||
|
## 4. Users
|
||||||
|
|
||||||
|
### GET /users
|
||||||
|
|
||||||
|
Filters:
|
||||||
|
|
||||||
|
- status
|
||||||
|
- role
|
||||||
|
- search
|
||||||
|
|
||||||
|
### POST /users
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "operator",
|
||||||
|
"email": "operator@example.com",
|
||||||
|
"roles": ["backup_operator"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /users/{id}
|
||||||
|
|
||||||
|
### PATCH /users/{id}
|
||||||
|
|
||||||
|
### DELETE /users/{id}
|
||||||
|
|
||||||
|
Deletion must be audited.
|
||||||
|
|
||||||
|
## 5. Roles
|
||||||
|
|
||||||
|
### GET /roles
|
||||||
|
|
||||||
|
### POST /roles
|
||||||
|
|
||||||
|
### GET /roles/{id}
|
||||||
|
|
||||||
|
### PATCH /roles/{id}
|
||||||
|
|
||||||
|
### DELETE /roles/{id}
|
||||||
|
|
||||||
|
## 6. Agents
|
||||||
|
|
||||||
|
### GET /agents
|
||||||
|
|
||||||
|
### POST /agents/register
|
||||||
|
|
||||||
|
Used by an enrollment flow.
|
||||||
|
|
||||||
|
### GET /agents/{id}
|
||||||
|
|
||||||
|
### POST /agents/{id}/revoke
|
||||||
|
|
||||||
|
### POST /agents/{id}/rotate-credentials
|
||||||
|
|
||||||
|
### GET /agents/{id}/health
|
||||||
|
|
||||||
|
## 7. Proxmox
|
||||||
|
|
||||||
|
### GET /proxmox/clusters
|
||||||
|
|
||||||
|
### POST /proxmox/clusters
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Production PVE",
|
||||||
|
"api_endpoint": "https://pve.example.local:8006",
|
||||||
|
"credential_ref": "secret-ref"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /proxmox/clusters/{id}
|
||||||
|
|
||||||
|
### POST /proxmox/clusters/{id}/test
|
||||||
|
|
||||||
|
### POST /proxmox/clusters/{id}/discover
|
||||||
|
|
||||||
|
### GET /proxmox/clusters/{id}/hosts
|
||||||
|
|
||||||
|
### GET /proxmox/clusters/{id}/vms
|
||||||
|
|
||||||
|
### GET /proxmox/vms/{id}
|
||||||
|
|
||||||
|
## 8. Repositories
|
||||||
|
|
||||||
|
### GET /repositories
|
||||||
|
|
||||||
|
### POST /repositories
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Repository-01",
|
||||||
|
"type": "hardened_linux",
|
||||||
|
"endpoint": "/backup/repository-01",
|
||||||
|
"immutable": true,
|
||||||
|
"encryption_required": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /repositories/{id}
|
||||||
|
|
||||||
|
### PATCH /repositories/{id}
|
||||||
|
|
||||||
|
### POST /repositories/{id}/test
|
||||||
|
|
||||||
|
### POST /repositories/{id}/health-check
|
||||||
|
|
||||||
|
### POST /repositories/{id}/integrity-scan
|
||||||
|
|
||||||
|
### POST /repositories/{id}/rebuild-catalog
|
||||||
|
|
||||||
|
The rebuild operation must require appropriate authorization.
|
||||||
|
|
||||||
|
## 9. Backup Jobs
|
||||||
|
|
||||||
|
### GET /jobs
|
||||||
|
|
||||||
|
Filters:
|
||||||
|
|
||||||
|
- status
|
||||||
|
- source
|
||||||
|
- repository
|
||||||
|
- search
|
||||||
|
|
||||||
|
### POST /jobs
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Production VMs",
|
||||||
|
"description": "Nightly production backup",
|
||||||
|
"priority": "high",
|
||||||
|
"schedule": {
|
||||||
|
"type": "daily",
|
||||||
|
"time": "02:00"
|
||||||
|
},
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"type": "proxmox_vm",
|
||||||
|
"id": "uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"repository_id": "uuid",
|
||||||
|
"retention_policy_id": "uuid",
|
||||||
|
"encryption_policy_id": "uuid",
|
||||||
|
"verification_policy_id": "uuid",
|
||||||
|
"rpo_seconds": 14400,
|
||||||
|
"rto_seconds": 7200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /jobs/{id}
|
||||||
|
|
||||||
|
### PATCH /jobs/{id}
|
||||||
|
|
||||||
|
### DELETE /jobs/{id}
|
||||||
|
|
||||||
|
Deletion is audited and may require step-up authentication.
|
||||||
|
|
||||||
|
### POST /jobs/{id}/run
|
||||||
|
|
||||||
|
Run immediately.
|
||||||
|
|
||||||
|
### POST /jobs/{id}/pause
|
||||||
|
|
||||||
|
### POST /jobs/{id}/resume
|
||||||
|
|
||||||
|
### GET /jobs/{id}/runs
|
||||||
|
|
||||||
|
## 10. Backup Runs
|
||||||
|
|
||||||
|
### GET /backup-runs/{id}
|
||||||
|
|
||||||
|
### POST /backup-runs/{id}/cancel
|
||||||
|
|
||||||
|
### GET /backup-runs/{id}/logs
|
||||||
|
|
||||||
|
### GET /backup-runs/{id}/metrics
|
||||||
|
|
||||||
|
## 11. Backups
|
||||||
|
|
||||||
|
### GET /backups
|
||||||
|
|
||||||
|
Filters:
|
||||||
|
|
||||||
|
- source
|
||||||
|
- job
|
||||||
|
- repository
|
||||||
|
- status
|
||||||
|
- date range
|
||||||
|
- verification status
|
||||||
|
|
||||||
|
### GET /backups/{id}
|
||||||
|
|
||||||
|
### GET /backups/{id}/manifest
|
||||||
|
|
||||||
|
### GET /backups/{id}/integrity
|
||||||
|
|
||||||
|
### POST /backups/{id}/verify
|
||||||
|
|
||||||
|
## 12. Recovery Points
|
||||||
|
|
||||||
|
### GET /recovery-points
|
||||||
|
|
||||||
|
### GET /recovery-points/{id}
|
||||||
|
|
||||||
|
### POST /recovery-points/{id}/validate
|
||||||
|
|
||||||
|
## 13. Restore
|
||||||
|
|
||||||
|
### POST /restores/validate
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"backup_id": "uuid",
|
||||||
|
"target_type": "proxmox_vm",
|
||||||
|
"target": {
|
||||||
|
"cluster_id": "uuid",
|
||||||
|
"host_id": "uuid",
|
||||||
|
"vm_name": "restored-vm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /restores
|
||||||
|
|
||||||
|
Create a restore job.
|
||||||
|
|
||||||
|
### GET /restores
|
||||||
|
|
||||||
|
### GET /restores/{id}
|
||||||
|
|
||||||
|
### POST /restores/{id}/cancel
|
||||||
|
|
||||||
|
### GET /restores/{id}/logs
|
||||||
|
|
||||||
|
### GET /restores/{id}/metrics
|
||||||
|
|
||||||
|
## 14. Verification
|
||||||
|
|
||||||
|
### GET /verification
|
||||||
|
|
||||||
|
### POST /verification
|
||||||
|
|
||||||
|
### GET /verification/{id}
|
||||||
|
|
||||||
|
### POST /verification/{id}/cancel
|
||||||
|
|
||||||
|
### GET /verification/{id}/results
|
||||||
|
|
||||||
|
## 15. Alerts
|
||||||
|
|
||||||
|
### GET /alerts
|
||||||
|
|
||||||
|
Filters:
|
||||||
|
|
||||||
|
- severity
|
||||||
|
- status
|
||||||
|
- date
|
||||||
|
- entity
|
||||||
|
|
||||||
|
### POST /alerts/{id}/acknowledge
|
||||||
|
|
||||||
|
### POST /alerts/{id}/resolve
|
||||||
|
|
||||||
|
### GET /alert-rules
|
||||||
|
|
||||||
|
### POST /alert-rules
|
||||||
|
|
||||||
|
### PATCH /alert-rules/{id}
|
||||||
|
|
||||||
|
### DELETE /alert-rules/{id}
|
||||||
|
|
||||||
|
## 16. Events
|
||||||
|
|
||||||
|
### GET /events
|
||||||
|
|
||||||
|
Filters:
|
||||||
|
|
||||||
|
- severity
|
||||||
|
- type
|
||||||
|
- entity
|
||||||
|
- date range
|
||||||
|
|
||||||
|
## 17. Audit
|
||||||
|
|
||||||
|
### GET /audit-events
|
||||||
|
|
||||||
|
Filters:
|
||||||
|
|
||||||
|
- user
|
||||||
|
- action
|
||||||
|
- entity
|
||||||
|
- date range
|
||||||
|
- result
|
||||||
|
|
||||||
|
Audit access must be restricted.
|
||||||
|
|
||||||
|
## 18. Metrics
|
||||||
|
|
||||||
|
### GET /metrics
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
|
||||||
|
```text
|
||||||
|
metric
|
||||||
|
entity_type
|
||||||
|
entity_id
|
||||||
|
from
|
||||||
|
to
|
||||||
|
interval
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/metrics?metric=repository.used&entity_id=uuid&from=...&to=...&interval=1h
|
||||||
|
```
|
||||||
|
|
||||||
|
## 19. Dashboard
|
||||||
|
|
||||||
|
### GET /dashboard/summary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
|
||||||
|
- protected systems
|
||||||
|
- successful backups
|
||||||
|
- failed backups
|
||||||
|
- warnings
|
||||||
|
- critical alerts
|
||||||
|
- storage
|
||||||
|
- recovery assurance
|
||||||
|
- security score
|
||||||
|
|
||||||
|
### GET /dashboard/backup-trends
|
||||||
|
|
||||||
|
### GET /dashboard/storage-trends
|
||||||
|
|
||||||
|
### GET /dashboard/rpo
|
||||||
|
|
||||||
|
### GET /dashboard/ransomware-risk
|
||||||
|
|
||||||
|
## 20. Security
|
||||||
|
|
||||||
|
### GET /security/score
|
||||||
|
|
||||||
|
### GET /security/findings
|
||||||
|
|
||||||
|
### GET /security/status
|
||||||
|
|
||||||
|
### GET /security/certificates
|
||||||
|
|
||||||
|
## 21. Reports
|
||||||
|
|
||||||
|
### GET /reports
|
||||||
|
|
||||||
|
### POST /reports/generate
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "daily_backup",
|
||||||
|
"from": "2026-08-01T00:00:00Z",
|
||||||
|
"to": "2026-08-10T23:59:59Z",
|
||||||
|
"format": "pdf"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 22. Policies
|
||||||
|
|
||||||
|
### GET /retention-policies
|
||||||
|
|
||||||
|
### POST /retention-policies
|
||||||
|
|
||||||
|
### GET /encryption-policies
|
||||||
|
|
||||||
|
### POST /encryption-policies
|
||||||
|
|
||||||
|
### GET /verification-policies
|
||||||
|
|
||||||
|
### POST /verification-policies
|
||||||
|
|
||||||
|
### GET /notification-policies
|
||||||
|
|
||||||
|
### POST /notification-policies
|
||||||
|
|
||||||
|
## 23. System Health
|
||||||
|
|
||||||
|
### GET /health/live
|
||||||
|
|
||||||
|
### GET /health/ready
|
||||||
|
|
||||||
|
### GET /health
|
||||||
|
|
||||||
|
Returns component health:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"status": "healthy",
|
||||||
|
"components": {
|
||||||
|
"database": "healthy",
|
||||||
|
"repositories": "healthy",
|
||||||
|
"scheduler": "healthy",
|
||||||
|
"agents": "healthy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 24. WebSocket / Live Updates
|
||||||
|
|
||||||
|
Recommended endpoint:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/api/v1/events/stream
|
||||||
|
```
|
||||||
|
|
||||||
|
Events:
|
||||||
|
|
||||||
|
- job.started
|
||||||
|
- job.progress
|
||||||
|
- job.completed
|
||||||
|
- job.failed
|
||||||
|
- restore.progress
|
||||||
|
- alert.created
|
||||||
|
- repository.health_changed
|
||||||
|
- agent.status_changed
|
||||||
|
|
||||||
|
## 25. HTTP Status Codes
|
||||||
|
|
||||||
|
Use standard codes:
|
||||||
|
|
||||||
|
- 200 OK
|
||||||
|
- 201 Created
|
||||||
|
- 202 Accepted
|
||||||
|
- 204 No Content
|
||||||
|
- 400 Bad Request
|
||||||
|
- 401 Unauthorized
|
||||||
|
- 403 Forbidden
|
||||||
|
- 404 Not Found
|
||||||
|
- 409 Conflict
|
||||||
|
- 422 Unprocessable Entity
|
||||||
|
- 429 Too Many Requests
|
||||||
|
- 500 Internal Server Error
|
||||||
|
- 503 Service Unavailable
|
||||||
|
|
||||||
|
## 26. API Rules
|
||||||
|
|
||||||
|
- Validate all input.
|
||||||
|
- Enforce RBAC server-side.
|
||||||
|
- Never trust frontend permissions.
|
||||||
|
- Never expose secrets.
|
||||||
|
- Use pagination on large collections.
|
||||||
|
- Use idempotency keys for operations where duplicate execution could be dangerous.
|
||||||
|
- Audit destructive actions.
|
||||||
|
- Use request IDs and correlation IDs.
|
||||||
|
- Keep error messages useful but avoid leaking sensitive internals.
|
||||||
|
|
||||||
|
## 27. Idempotency
|
||||||
|
|
||||||
|
Support:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Idempotency-Key: <uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
for:
|
||||||
|
|
||||||
|
- create backup job
|
||||||
|
- start backup
|
||||||
|
- create restore
|
||||||
|
- create repository
|
||||||
|
- destructive configuration changes
|
||||||
|
|
||||||
|
## 28. Pagination
|
||||||
|
|
||||||
|
Preferred:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?page=1&page_size=50
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [],
|
||||||
|
"meta": {
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 50,
|
||||||
|
"total": 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
469
SYNCOVA_ARCHITECTURE.md
Normal file
469
SYNCOVA_ARCHITECTURE.md
Normal file
@ -0,0 +1,469 @@
|
|||||||
|
# Syncova Backups V1 — System Architecture
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
Syncova is an enterprise-grade backup, recovery, verification, security, and monitoring platform. V1 focuses on:
|
||||||
|
|
||||||
|
- Proxmox VE
|
||||||
|
- Windows Server / Windows clients
|
||||||
|
- Linux systems
|
||||||
|
- Physical systems
|
||||||
|
- Files and folders
|
||||||
|
|
||||||
|
VMware is explicitly out of scope for V1, but the architecture must support future provider implementations.
|
||||||
|
|
||||||
|
Core principle:
|
||||||
|
|
||||||
|
> A backup is not considered trustworthy until integrity and recoverability are proven.
|
||||||
|
|
||||||
|
## 2. High-Level Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
+----------------------+
|
||||||
|
| Web UI |
|
||||||
|
| React + TypeScript |
|
||||||
|
+----------+-----------+
|
||||||
|
|
|
||||||
|
HTTPS / WebSocket
|
||||||
|
|
|
||||||
|
+----------v-----------+
|
||||||
|
| API Gateway |
|
||||||
|
| REST / Auth / RBAC |
|
||||||
|
+----------+-----------+
|
||||||
|
|
|
||||||
|
+---------------+----------------+
|
||||||
|
| |
|
||||||
|
+---------v---------+ +--------v--------+
|
||||||
|
| Control Plane | | Monitoring |
|
||||||
|
| Jobs / Policies | | Metrics / Alerts|
|
||||||
|
+---------+---------+ +--------+--------+
|
||||||
|
| |
|
||||||
|
+---------------+----------------+
|
||||||
|
|
|
||||||
|
+----------v-----------+
|
||||||
|
| Backup Orchestrator |
|
||||||
|
+----------+-----------+
|
||||||
|
|
|
||||||
|
+---------------------+----------------------+
|
||||||
|
| | |
|
||||||
|
+------v------+ +------v------+ +------v------+
|
||||||
|
| Proxmox | | Windows | | Linux |
|
||||||
|
| Provider | | Agent | | Agent |
|
||||||
|
+------+------+ +------+------+ +------+------+
|
||||||
|
| | |
|
||||||
|
+---------------------+----------------------+
|
||||||
|
|
|
||||||
|
+----------v-----------+
|
||||||
|
| Backup Engine |
|
||||||
|
| chunk/dedup/compress |
|
||||||
|
| encrypt/integrity |
|
||||||
|
+----------+-----------+
|
||||||
|
|
|
||||||
|
+----------v-----------+
|
||||||
|
| Repository Service |
|
||||||
|
+----------+-----------+
|
||||||
|
|
|
||||||
|
+-------------------+-------------------+
|
||||||
|
| | |
|
||||||
|
Local/Hardened S3-compatible Secondary
|
||||||
|
Repository Storage Repository
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Design Principles
|
||||||
|
|
||||||
|
1. Security by default.
|
||||||
|
2. Least privilege.
|
||||||
|
3. Recovery-first design.
|
||||||
|
4. Repository data independent of PostgreSQL.
|
||||||
|
5. Control plane failure must not destroy backup data.
|
||||||
|
6. Streaming processing; never load complete backups into RAM.
|
||||||
|
7. Provider abstraction for hypervisors.
|
||||||
|
8. Versioned backup format.
|
||||||
|
9. Explicit states; no silent failures.
|
||||||
|
10. Every destructive operation is auditable.
|
||||||
|
|
||||||
|
## 4. Services
|
||||||
|
|
||||||
|
### 4.1 API Service
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- HTTP API
|
||||||
|
- authentication
|
||||||
|
- authorization
|
||||||
|
- request validation
|
||||||
|
- rate limiting
|
||||||
|
- API versioning
|
||||||
|
- audit integration
|
||||||
|
|
||||||
|
### 4.2 Control Service
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- jobs
|
||||||
|
- policies
|
||||||
|
- sources
|
||||||
|
- repositories
|
||||||
|
- orchestration
|
||||||
|
- configuration
|
||||||
|
- scheduling
|
||||||
|
|
||||||
|
### 4.3 Scheduler
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- schedules
|
||||||
|
- retry/backoff
|
||||||
|
- concurrency
|
||||||
|
- priorities
|
||||||
|
- backup windows
|
||||||
|
- job dependencies
|
||||||
|
|
||||||
|
### 4.4 Backup Engine
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Source Read
|
||||||
|
-> Change Detection
|
||||||
|
-> Chunking
|
||||||
|
-> Hash
|
||||||
|
-> Dedup Lookup
|
||||||
|
-> Compression
|
||||||
|
-> Encryption
|
||||||
|
-> Repository Write
|
||||||
|
-> Manifest Commit
|
||||||
|
-> Verification
|
||||||
|
```
|
||||||
|
|
||||||
|
Backpressure must be used between pipeline stages.
|
||||||
|
|
||||||
|
### 4.5 Repository Service
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- object/chunk writes
|
||||||
|
- manifests
|
||||||
|
- atomic commits
|
||||||
|
- retention operations
|
||||||
|
- integrity scanning
|
||||||
|
- repository health
|
||||||
|
- repository rebuild
|
||||||
|
|
||||||
|
PostgreSQL is not the source of truth for backup payloads.
|
||||||
|
|
||||||
|
### 4.6 Verification Service
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- integrity checks
|
||||||
|
- chain validation
|
||||||
|
- restore-point validation
|
||||||
|
- automated restore tests
|
||||||
|
- recovery assurance
|
||||||
|
|
||||||
|
### 4.7 Monitoring Service
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- metrics
|
||||||
|
- events
|
||||||
|
- alerts
|
||||||
|
- health checks
|
||||||
|
- anomaly detection
|
||||||
|
- capacity forecasts
|
||||||
|
|
||||||
|
## 5. Provider Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
VirtualizationProvider
|
||||||
|
|
|
||||||
|
+-- ProxmoxProvider (V1)
|
||||||
|
+-- VMwareProvider (future)
|
||||||
|
+-- HyperVProvider (future)
|
||||||
|
```
|
||||||
|
|
||||||
|
Generic interface:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Connect
|
||||||
|
Disconnect
|
||||||
|
ListClusters
|
||||||
|
ListHosts
|
||||||
|
ListVMs
|
||||||
|
GetVMInfo
|
||||||
|
GetVMDisks
|
||||||
|
GetVMMetaData
|
||||||
|
CreateSnapshot
|
||||||
|
RemoveSnapshot
|
||||||
|
ReadChangedBlocks
|
||||||
|
RestoreVM
|
||||||
|
```
|
||||||
|
|
||||||
|
Provider-specific logic must not leak into the backup engine.
|
||||||
|
|
||||||
|
## 6. Agent Architecture
|
||||||
|
|
||||||
|
Agents run as native services:
|
||||||
|
|
||||||
|
- Windows Service
|
||||||
|
- Linux systemd service
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- secure registration
|
||||||
|
- heartbeat
|
||||||
|
- source discovery
|
||||||
|
- file/system reads
|
||||||
|
- data streaming
|
||||||
|
- restore execution
|
||||||
|
- local health reporting
|
||||||
|
|
||||||
|
Agents must use minimal privileges.
|
||||||
|
|
||||||
|
## 7. Security Architecture
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
V1:
|
||||||
|
|
||||||
|
- username/password
|
||||||
|
- TOTP MFA
|
||||||
|
|
||||||
|
Architecture-ready:
|
||||||
|
|
||||||
|
- WebAuthn/passkeys
|
||||||
|
- OIDC
|
||||||
|
- SAML
|
||||||
|
- LDAP/AD
|
||||||
|
- Entra ID
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
RBAC with explicit permissions.
|
||||||
|
|
||||||
|
Critical actions may require step-up MFA or future four-eyes approval.
|
||||||
|
|
||||||
|
### Transport
|
||||||
|
|
||||||
|
TLS for all remote communication.
|
||||||
|
|
||||||
|
### Secrets
|
||||||
|
|
||||||
|
Never store or log secrets in plaintext. Use encrypted secret storage and a key hierarchy.
|
||||||
|
|
||||||
|
## 8. Backup Format
|
||||||
|
|
||||||
|
A backup consists of:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Header
|
||||||
|
Format Version
|
||||||
|
Backup ID
|
||||||
|
Source ID
|
||||||
|
Creation Time
|
||||||
|
Encryption Metadata
|
||||||
|
|
||||||
|
Manifest
|
||||||
|
Files/Disks
|
||||||
|
Chunk References
|
||||||
|
Parent Backup
|
||||||
|
Consistency Level
|
||||||
|
|
||||||
|
Chunk Index
|
||||||
|
Chunk ID
|
||||||
|
Offset
|
||||||
|
Length
|
||||||
|
Integrity Metadata
|
||||||
|
|
||||||
|
Encrypted Chunks
|
||||||
|
|
||||||
|
Footer
|
||||||
|
Manifest Hash
|
||||||
|
Completion Marker
|
||||||
|
```
|
||||||
|
|
||||||
|
The format must support version negotiation and future extensions.
|
||||||
|
|
||||||
|
## 9. Repository Layout
|
||||||
|
|
||||||
|
Logical layout:
|
||||||
|
|
||||||
|
```text
|
||||||
|
repository/
|
||||||
|
format/
|
||||||
|
manifests/
|
||||||
|
chunks/
|
||||||
|
indexes/
|
||||||
|
journals/
|
||||||
|
verification/
|
||||||
|
metadata/
|
||||||
|
```
|
||||||
|
|
||||||
|
Exact physical layout may evolve, but repositories must remain self-describing and rebuildable.
|
||||||
|
|
||||||
|
## 10. Repository Commit Protocol
|
||||||
|
|
||||||
|
Use staged writes:
|
||||||
|
|
||||||
|
1. Create session.
|
||||||
|
2. Write chunks.
|
||||||
|
3. Write manifest.
|
||||||
|
4. Verify manifest.
|
||||||
|
5. Atomically commit completion marker.
|
||||||
|
6. Update catalog.
|
||||||
|
7. Publish successful backup state.
|
||||||
|
|
||||||
|
A backup without a valid completion marker is incomplete.
|
||||||
|
|
||||||
|
## 11. Recovery Architecture
|
||||||
|
|
||||||
|
Recovery must work even if the control server is rebuilt.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Attach Repository
|
||||||
|
-> Discover Format
|
||||||
|
-> Scan Manifests
|
||||||
|
-> Validate Chains
|
||||||
|
-> Rebuild Catalog
|
||||||
|
-> Select Restore Point
|
||||||
|
-> Validate Dependencies
|
||||||
|
-> Restore
|
||||||
|
-> Verify
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Proxmox Recovery
|
||||||
|
|
||||||
|
Support:
|
||||||
|
|
||||||
|
- original host
|
||||||
|
- alternate host
|
||||||
|
- restore as new VM
|
||||||
|
- VM configuration restoration
|
||||||
|
- disk restoration
|
||||||
|
- metadata restoration
|
||||||
|
|
||||||
|
Use official Proxmox APIs where possible.
|
||||||
|
|
||||||
|
## 13. Metrics
|
||||||
|
|
||||||
|
Core metrics include:
|
||||||
|
|
||||||
|
- backup.duration
|
||||||
|
- backup.bytes_processed
|
||||||
|
- backup.bytes_written
|
||||||
|
- backup.throughput
|
||||||
|
- backup.success
|
||||||
|
- backup.failure
|
||||||
|
- repository.capacity
|
||||||
|
- repository.used
|
||||||
|
- repository.free
|
||||||
|
- repository.latency
|
||||||
|
- dedup.ratio
|
||||||
|
- compression.ratio
|
||||||
|
- verification.duration
|
||||||
|
- recovery.duration
|
||||||
|
- agent.cpu
|
||||||
|
- agent.memory
|
||||||
|
- network.throughput
|
||||||
|
|
||||||
|
Metric retention should use rollups.
|
||||||
|
|
||||||
|
## 14. Recovery Assurance
|
||||||
|
|
||||||
|
Each protected system gets a score based on:
|
||||||
|
|
||||||
|
- recent successful backup
|
||||||
|
- verification
|
||||||
|
- recovery test
|
||||||
|
- RPO compliance
|
||||||
|
- RTO compliance
|
||||||
|
- immutability
|
||||||
|
- offsite copy
|
||||||
|
- encryption
|
||||||
|
- repository health
|
||||||
|
- anomaly/ransomware risk
|
||||||
|
|
||||||
|
The score must always be explainable.
|
||||||
|
|
||||||
|
## 15. Failure Model
|
||||||
|
|
||||||
|
Failures must be classified as:
|
||||||
|
|
||||||
|
- transient
|
||||||
|
- permanent
|
||||||
|
- integrity
|
||||||
|
- authentication
|
||||||
|
- authorization
|
||||||
|
- repository
|
||||||
|
- source
|
||||||
|
- network
|
||||||
|
- configuration
|
||||||
|
- security
|
||||||
|
|
||||||
|
Retries use bounded exponential backoff.
|
||||||
|
|
||||||
|
## 16. Scaling
|
||||||
|
|
||||||
|
V1 should support small environments but have a path to 1000+ protected systems.
|
||||||
|
|
||||||
|
Avoid shared global locks. Use:
|
||||||
|
|
||||||
|
- worker pools
|
||||||
|
- bounded concurrency
|
||||||
|
- partitionable queues
|
||||||
|
- indexed queries
|
||||||
|
- streaming
|
||||||
|
- asynchronous jobs
|
||||||
|
|
||||||
|
## 17. Deployment
|
||||||
|
|
||||||
|
Initial supported deployment:
|
||||||
|
|
||||||
|
- Linux server
|
||||||
|
- VM
|
||||||
|
- dedicated backup appliance
|
||||||
|
|
||||||
|
Components should be packaged so that later containerized deployment is possible.
|
||||||
|
|
||||||
|
## 18. Observability
|
||||||
|
|
||||||
|
Every request/job/session has:
|
||||||
|
|
||||||
|
- correlation ID
|
||||||
|
- job ID
|
||||||
|
- task ID
|
||||||
|
- structured logs
|
||||||
|
|
||||||
|
Expose:
|
||||||
|
|
||||||
|
- liveness
|
||||||
|
- readiness
|
||||||
|
- metrics
|
||||||
|
- health checks
|
||||||
|
|
||||||
|
## 19. Disaster Recovery
|
||||||
|
|
||||||
|
Back up Syncova configuration and provide:
|
||||||
|
|
||||||
|
- control-server rebuild
|
||||||
|
- database restore
|
||||||
|
- repository attach
|
||||||
|
- catalog rebuild
|
||||||
|
- key recovery
|
||||||
|
|
||||||
|
## 20. Non-Goals for V1
|
||||||
|
|
||||||
|
Do not implement:
|
||||||
|
|
||||||
|
- VMware
|
||||||
|
- Kubernetes
|
||||||
|
- Microsoft 365
|
||||||
|
- Exchange application-aware backup
|
||||||
|
- broad SaaS backup
|
||||||
|
- complex multi-tenant MSP architecture
|
||||||
|
- AI-dependent functionality
|
||||||
|
|
||||||
|
The architecture must leave room for these later.
|
||||||
538
SYNCOVA_DATABASE.md
Normal file
538
SYNCOVA_DATABASE.md
Normal file
@ -0,0 +1,538 @@
|
|||||||
|
# Syncova Backups V1 — PostgreSQL Database Design
|
||||||
|
|
||||||
|
## 1. Database Rules
|
||||||
|
|
||||||
|
- PostgreSQL is the control-plane database.
|
||||||
|
- Backup payloads are never stored in PostgreSQL.
|
||||||
|
- UUIDs are preferred for public identifiers.
|
||||||
|
- All timestamps are UTC.
|
||||||
|
- Foreign keys are mandatory for relationships.
|
||||||
|
- Use soft deletion where audit/history requires it.
|
||||||
|
- Never store passwords or raw encryption keys.
|
||||||
|
- Every schema change uses a migration.
|
||||||
|
|
||||||
|
## 2. Core Tables
|
||||||
|
|
||||||
|
### users
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
username TEXT UNIQUE NOT NULL
|
||||||
|
email TEXT UNIQUE
|
||||||
|
password_hash TEXT
|
||||||
|
status TEXT NOT NULL
|
||||||
|
mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
last_login_at TIMESTAMPTZ
|
||||||
|
```
|
||||||
|
|
||||||
|
### user_mfa_methods
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
user_id UUID REFERENCES users(id)
|
||||||
|
type TEXT NOT NULL
|
||||||
|
secret_ciphertext BYTEA
|
||||||
|
enabled BOOLEAN NOT NULL
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### roles
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
name TEXT UNIQUE NOT NULL
|
||||||
|
description TEXT
|
||||||
|
```
|
||||||
|
|
||||||
|
### permissions
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
name TEXT UNIQUE NOT NULL
|
||||||
|
description TEXT
|
||||||
|
```
|
||||||
|
|
||||||
|
### role_permissions
|
||||||
|
|
||||||
|
```sql
|
||||||
|
role_id UUID REFERENCES roles(id)
|
||||||
|
permission_id UUID REFERENCES permissions(id)
|
||||||
|
PRIMARY KEY(role_id, permission_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
### user_roles
|
||||||
|
|
||||||
|
```sql
|
||||||
|
user_id UUID REFERENCES users(id)
|
||||||
|
role_id UUID REFERENCES roles(id)
|
||||||
|
PRIMARY KEY(user_id, role_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Agents
|
||||||
|
|
||||||
|
### agents
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
name TEXT NOT NULL
|
||||||
|
hostname TEXT
|
||||||
|
platform TEXT NOT NULL
|
||||||
|
version TEXT
|
||||||
|
status TEXT NOT NULL
|
||||||
|
last_heartbeat_at TIMESTAMPTZ
|
||||||
|
registered_at TIMESTAMPTZ NOT NULL
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### agent_certificates
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
agent_id UUID REFERENCES agents(id)
|
||||||
|
fingerprint TEXT UNIQUE NOT NULL
|
||||||
|
not_before TIMESTAMPTZ
|
||||||
|
not_after TIMESTAMPTZ
|
||||||
|
status TEXT NOT NULL
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Proxmox
|
||||||
|
|
||||||
|
### proxmox_clusters
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
name TEXT NOT NULL
|
||||||
|
api_endpoint TEXT NOT NULL
|
||||||
|
credential_ref TEXT NOT NULL
|
||||||
|
status TEXT NOT NULL
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### proxmox_hosts
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
cluster_id UUID REFERENCES proxmox_clusters(id)
|
||||||
|
node_name TEXT NOT NULL
|
||||||
|
status TEXT NOT NULL
|
||||||
|
last_seen_at TIMESTAMPTZ
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### virtual_machines
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
cluster_id UUID REFERENCES proxmox_clusters(id)
|
||||||
|
host_id UUID REFERENCES proxmox_hosts(id)
|
||||||
|
provider_vm_id TEXT NOT NULL
|
||||||
|
name TEXT NOT NULL
|
||||||
|
status TEXT
|
||||||
|
cpu_count INTEGER
|
||||||
|
memory_bytes BIGINT
|
||||||
|
config_json JSONB
|
||||||
|
last_discovered_at TIMESTAMPTZ
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
UNIQUE(cluster_id, provider_vm_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Repositories
|
||||||
|
|
||||||
|
### repositories
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
name TEXT UNIQUE NOT NULL
|
||||||
|
type TEXT NOT NULL
|
||||||
|
endpoint TEXT NOT NULL
|
||||||
|
status TEXT NOT NULL
|
||||||
|
capacity_bytes BIGINT
|
||||||
|
used_bytes BIGINT
|
||||||
|
free_bytes BIGINT
|
||||||
|
immutable BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
encryption_required BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### repository_health
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
repository_id UUID REFERENCES repositories(id)
|
||||||
|
health_status TEXT NOT NULL
|
||||||
|
latency_ms DOUBLE PRECISION
|
||||||
|
error_count BIGINT DEFAULT 0
|
||||||
|
checked_at TIMESTAMPTZ NOT NULL
|
||||||
|
details JSONB
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Backup Jobs
|
||||||
|
|
||||||
|
### backup_jobs
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
name TEXT UNIQUE NOT NULL
|
||||||
|
description TEXT
|
||||||
|
status TEXT NOT NULL
|
||||||
|
priority TEXT NOT NULL
|
||||||
|
schedule_type TEXT NOT NULL
|
||||||
|
schedule_config JSONB NOT NULL
|
||||||
|
retention_policy_id UUID
|
||||||
|
repository_id UUID REFERENCES repositories(id)
|
||||||
|
encryption_policy_id UUID
|
||||||
|
verification_policy_id UUID
|
||||||
|
notification_policy_id UUID
|
||||||
|
rpo_seconds BIGINT
|
||||||
|
rto_seconds BIGINT
|
||||||
|
bandwidth_limit_bps BIGINT
|
||||||
|
max_concurrency INTEGER
|
||||||
|
created_by UUID REFERENCES users(id)
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### backup_job_sources
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
job_id UUID REFERENCES backup_jobs(id)
|
||||||
|
source_type TEXT NOT NULL
|
||||||
|
source_id UUID
|
||||||
|
include_patterns JSONB
|
||||||
|
exclude_patterns JSONB
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Backup Runs
|
||||||
|
|
||||||
|
### backup_job_runs
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
job_id UUID REFERENCES backup_jobs(id)
|
||||||
|
status TEXT NOT NULL
|
||||||
|
started_at TIMESTAMPTZ
|
||||||
|
completed_at TIMESTAMPTZ
|
||||||
|
bytes_processed BIGINT DEFAULT 0
|
||||||
|
bytes_written BIGINT DEFAULT 0
|
||||||
|
bytes_transferred BIGINT DEFAULT 0
|
||||||
|
throughput_bps BIGINT
|
||||||
|
error_code TEXT
|
||||||
|
error_message TEXT
|
||||||
|
correlation_id UUID NOT NULL
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Backups and Chains
|
||||||
|
|
||||||
|
### backup_chains
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
source_id UUID NOT NULL
|
||||||
|
status TEXT NOT NULL
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### backups
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
job_run_id UUID REFERENCES backup_job_runs(id)
|
||||||
|
chain_id UUID REFERENCES backup_chains(id)
|
||||||
|
repository_id UUID REFERENCES repositories(id)
|
||||||
|
parent_backup_id UUID REFERENCES backups(id)
|
||||||
|
backup_type TEXT NOT NULL
|
||||||
|
consistency_level TEXT
|
||||||
|
status TEXT NOT NULL
|
||||||
|
manifest_ref TEXT NOT NULL
|
||||||
|
logical_bytes BIGINT
|
||||||
|
unique_bytes BIGINT
|
||||||
|
compressed_bytes BIGINT
|
||||||
|
encrypted_bytes BIGINT
|
||||||
|
started_at TIMESTAMPTZ
|
||||||
|
completed_at TIMESTAMPTZ
|
||||||
|
integrity_status TEXT
|
||||||
|
verified_at TIMESTAMPTZ
|
||||||
|
immutable_until TIMESTAMPTZ
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Chunks
|
||||||
|
|
||||||
|
The database stores references/index information, not chunk payloads.
|
||||||
|
|
||||||
|
### chunks
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
repository_id UUID REFERENCES repositories(id)
|
||||||
|
content_hash TEXT NOT NULL
|
||||||
|
size_bytes BIGINT NOT NULL
|
||||||
|
compressed_size_bytes BIGINT
|
||||||
|
encryption_version TEXT
|
||||||
|
storage_ref TEXT NOT NULL
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
UNIQUE(repository_id, content_hash)
|
||||||
|
```
|
||||||
|
|
||||||
|
### chunk_references
|
||||||
|
|
||||||
|
```sql
|
||||||
|
backup_id UUID REFERENCES backups(id)
|
||||||
|
chunk_id UUID REFERENCES chunks(id)
|
||||||
|
sequence_no BIGINT NOT NULL
|
||||||
|
logical_offset BIGINT
|
||||||
|
length_bytes BIGINT
|
||||||
|
PRIMARY KEY(backup_id, sequence_no)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Restore
|
||||||
|
|
||||||
|
### restore_jobs
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
backup_id UUID REFERENCES backups(id)
|
||||||
|
source_type TEXT NOT NULL
|
||||||
|
target_type TEXT NOT NULL
|
||||||
|
target_ref TEXT
|
||||||
|
status TEXT NOT NULL
|
||||||
|
started_at TIMESTAMPTZ
|
||||||
|
completed_at TIMESTAMPTZ
|
||||||
|
bytes_restored BIGINT
|
||||||
|
error_code TEXT
|
||||||
|
error_message TEXT
|
||||||
|
created_by UUID REFERENCES users(id)
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### restore_sessions
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
restore_job_id UUID REFERENCES restore_jobs(id)
|
||||||
|
state TEXT NOT NULL
|
||||||
|
checkpoint JSONB
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Verification
|
||||||
|
|
||||||
|
### verification_jobs
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
backup_id UUID REFERENCES backups(id)
|
||||||
|
type TEXT NOT NULL
|
||||||
|
status TEXT NOT NULL
|
||||||
|
started_at TIMESTAMPTZ
|
||||||
|
completed_at TIMESTAMPTZ
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### verification_results
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
verification_job_id UUID REFERENCES verification_jobs(id)
|
||||||
|
check_name TEXT NOT NULL
|
||||||
|
status TEXT NOT NULL
|
||||||
|
details JSONB
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Alerts and Events
|
||||||
|
|
||||||
|
### alert_rules
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
name TEXT UNIQUE NOT NULL
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
severity TEXT NOT NULL
|
||||||
|
condition JSONB NOT NULL
|
||||||
|
actions JSONB NOT NULL
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### alerts
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
rule_id UUID REFERENCES alert_rules(id)
|
||||||
|
severity TEXT NOT NULL
|
||||||
|
status TEXT NOT NULL
|
||||||
|
title TEXT NOT NULL
|
||||||
|
message TEXT NOT NULL
|
||||||
|
entity_type TEXT
|
||||||
|
entity_id UUID
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
acknowledged_at TIMESTAMPTZ
|
||||||
|
resolved_at TIMESTAMPTZ
|
||||||
|
```
|
||||||
|
|
||||||
|
### events
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
event_type TEXT NOT NULL
|
||||||
|
severity TEXT NOT NULL
|
||||||
|
entity_type TEXT
|
||||||
|
entity_id UUID
|
||||||
|
message TEXT NOT NULL
|
||||||
|
details JSONB
|
||||||
|
correlation_id UUID
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. Audit
|
||||||
|
|
||||||
|
### audit_events
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
user_id UUID REFERENCES users(id)
|
||||||
|
action TEXT NOT NULL
|
||||||
|
entity_type TEXT
|
||||||
|
entity_id UUID
|
||||||
|
result TEXT NOT NULL
|
||||||
|
ip_address INET
|
||||||
|
user_agent TEXT
|
||||||
|
details JSONB
|
||||||
|
correlation_id UUID
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit data should be append-oriented.
|
||||||
|
|
||||||
|
## 14. Metrics
|
||||||
|
|
||||||
|
### metrics
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id BIGSERIAL PRIMARY KEY
|
||||||
|
metric_name TEXT NOT NULL
|
||||||
|
entity_type TEXT
|
||||||
|
entity_id UUID
|
||||||
|
value DOUBLE PRECISION NOT NULL
|
||||||
|
unit TEXT
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL
|
||||||
|
labels JSONB
|
||||||
|
```
|
||||||
|
|
||||||
|
Use indexes on:
|
||||||
|
|
||||||
|
```text
|
||||||
|
(metric_name, timestamp)
|
||||||
|
(entity_type, entity_id, timestamp)
|
||||||
|
```
|
||||||
|
|
||||||
|
For large installations, introduce time partitioning and rollups.
|
||||||
|
|
||||||
|
## 15. Policies
|
||||||
|
|
||||||
|
### retention_policies
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
name TEXT UNIQUE NOT NULL
|
||||||
|
rules JSONB NOT NULL
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### encryption_policies
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
name TEXT UNIQUE NOT NULL
|
||||||
|
algorithm TEXT NOT NULL
|
||||||
|
key_ref TEXT NOT NULL
|
||||||
|
rotation_policy JSONB
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### verification_policies
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
name TEXT UNIQUE NOT NULL
|
||||||
|
rules JSONB NOT NULL
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### notification_policies
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
name TEXT UNIQUE NOT NULL
|
||||||
|
channels JSONB NOT NULL
|
||||||
|
rules JSONB NOT NULL
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
## 16. Certificates and Configuration
|
||||||
|
|
||||||
|
### certificates
|
||||||
|
|
||||||
|
```sql
|
||||||
|
id UUID PRIMARY KEY
|
||||||
|
name TEXT UNIQUE NOT NULL
|
||||||
|
type TEXT NOT NULL
|
||||||
|
fingerprint TEXT UNIQUE NOT NULL
|
||||||
|
not_before TIMESTAMPTZ
|
||||||
|
not_after TIMESTAMPTZ
|
||||||
|
status TEXT NOT NULL
|
||||||
|
secret_ref TEXT
|
||||||
|
created_at TIMESTAMPTZ NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### system_settings
|
||||||
|
|
||||||
|
```sql
|
||||||
|
key TEXT PRIMARY KEY
|
||||||
|
value_json JSONB NOT NULL
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL
|
||||||
|
updated_by UUID REFERENCES users(id)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 17. Recommended Indexes
|
||||||
|
|
||||||
|
Create indexes for:
|
||||||
|
|
||||||
|
- backup_jobs(status)
|
||||||
|
- backup_job_runs(job_id, started_at DESC)
|
||||||
|
- backups(repository_id, completed_at DESC)
|
||||||
|
- backups(chain_id, completed_at DESC)
|
||||||
|
- backups(parent_backup_id)
|
||||||
|
- virtual_machines(cluster_id)
|
||||||
|
- agents(status)
|
||||||
|
- alerts(status, severity, created_at DESC)
|
||||||
|
- events(created_at DESC)
|
||||||
|
- audit_events(created_at DESC)
|
||||||
|
- metrics(metric_name, timestamp DESC)
|
||||||
|
|
||||||
|
## 18. Migration Strategy
|
||||||
|
|
||||||
|
Use a migration tool such as:
|
||||||
|
|
||||||
|
- golang-migrate
|
||||||
|
- Atlas
|
||||||
|
|
||||||
|
Every schema change must be versioned.
|
||||||
|
|
||||||
|
Production startup must never silently mutate the schema.
|
||||||
835
SYNCOVA_IMPLEMENTATION_PLAN.md
Normal file
835
SYNCOVA_IMPLEMENTATION_PLAN.md
Normal file
@ -0,0 +1,835 @@
|
|||||||
|
# Syncova Backups V1 — Implementation Plan
|
||||||
|
|
||||||
|
## 1. Objective
|
||||||
|
|
||||||
|
Build Syncova V1 as a production-oriented backup and recovery platform with a staged implementation process.
|
||||||
|
|
||||||
|
Do not attempt to implement all functionality simultaneously.
|
||||||
|
|
||||||
|
Each phase ends with:
|
||||||
|
|
||||||
|
- working software
|
||||||
|
- automated tests
|
||||||
|
- documentation
|
||||||
|
- security review
|
||||||
|
- measurable acceptance criteria
|
||||||
|
|
||||||
|
## 2. Phase 0 — Product Foundation
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
|
||||||
|
- repository structure
|
||||||
|
- coding standards
|
||||||
|
- architecture documentation
|
||||||
|
- CI pipeline
|
||||||
|
- dependency management
|
||||||
|
- local development environment
|
||||||
|
- PostgreSQL
|
||||||
|
- migration framework
|
||||||
|
- basic frontend shell
|
||||||
|
- backend health endpoint
|
||||||
|
|
||||||
|
### Suggested repository
|
||||||
|
|
||||||
|
```text
|
||||||
|
syncova/
|
||||||
|
apps/
|
||||||
|
api/
|
||||||
|
web/
|
||||||
|
agent/
|
||||||
|
worker/
|
||||||
|
packages/
|
||||||
|
backup/
|
||||||
|
repository/
|
||||||
|
crypto/
|
||||||
|
providers/
|
||||||
|
metrics/
|
||||||
|
auth/
|
||||||
|
migrations/
|
||||||
|
docs/
|
||||||
|
tests/
|
||||||
|
deployment/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exit Criteria
|
||||||
|
|
||||||
|
- Backend starts.
|
||||||
|
- Frontend starts.
|
||||||
|
- Database connects.
|
||||||
|
- Migration runs.
|
||||||
|
- CI executes tests.
|
||||||
|
- No hardcoded credentials.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Phase 1 — Identity and Security Foundation
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- users
|
||||||
|
- roles
|
||||||
|
- permissions
|
||||||
|
- login
|
||||||
|
- sessions/tokens
|
||||||
|
- password hashing
|
||||||
|
- TOTP MFA
|
||||||
|
- audit logging
|
||||||
|
- TLS configuration
|
||||||
|
- secret abstraction
|
||||||
|
|
||||||
|
### Exit Criteria
|
||||||
|
|
||||||
|
- Admin can log in.
|
||||||
|
- MFA works.
|
||||||
|
- Unauthorized users cannot access protected APIs.
|
||||||
|
- Privileged operations are audited.
|
||||||
|
- Passwords are never stored plaintext.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Phase 2 — Repository Engine
|
||||||
|
|
||||||
|
This is the first major engineering milestone.
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- repository abstraction
|
||||||
|
- local filesystem repository
|
||||||
|
- hardened repository mode
|
||||||
|
- chunk storage
|
||||||
|
- manifests
|
||||||
|
- atomic commit
|
||||||
|
- repository catalog
|
||||||
|
- integrity metadata
|
||||||
|
- repository health
|
||||||
|
- repository scan
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- write chunk
|
||||||
|
- read chunk
|
||||||
|
- detect corruption
|
||||||
|
- interrupted write
|
||||||
|
- restart
|
||||||
|
- catalog rebuild
|
||||||
|
|
||||||
|
### Exit Criteria
|
||||||
|
|
||||||
|
A repository can be created, written, scanned, restarted, and rebuilt without PostgreSQL containing the only copy of backup metadata.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. Phase 3 — Backup Format
|
||||||
|
|
||||||
|
Implement versioned Syncova format.
|
||||||
|
|
||||||
|
Must contain:
|
||||||
|
|
||||||
|
- header
|
||||||
|
- source metadata
|
||||||
|
- backup metadata
|
||||||
|
- manifest
|
||||||
|
- chunk references
|
||||||
|
- integrity information
|
||||||
|
- encryption metadata
|
||||||
|
- completion marker
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- serialize
|
||||||
|
- deserialize
|
||||||
|
- version compatibility
|
||||||
|
- invalid manifest
|
||||||
|
- missing chunk
|
||||||
|
- corrupted chunk
|
||||||
|
- incomplete backup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Phase 4 — Backup Engine Core
|
||||||
|
|
||||||
|
Implement streaming pipeline:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Read
|
||||||
|
-> Chunk
|
||||||
|
-> Hash
|
||||||
|
-> Dedup
|
||||||
|
-> Compress
|
||||||
|
-> Encrypt
|
||||||
|
-> Write
|
||||||
|
-> Manifest
|
||||||
|
-> Commit
|
||||||
|
```
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- bounded worker pools
|
||||||
|
- backpressure
|
||||||
|
- checkpoints
|
||||||
|
- cancellation
|
||||||
|
- retry
|
||||||
|
- progress reporting
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
Collect:
|
||||||
|
|
||||||
|
- bytes processed
|
||||||
|
- bytes written
|
||||||
|
- throughput
|
||||||
|
- compression ratio
|
||||||
|
- dedup ratio
|
||||||
|
- duration
|
||||||
|
|
||||||
|
### Exit Criteria
|
||||||
|
|
||||||
|
A large test dataset can be backed up without loading the dataset into memory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Phase 5 — Windows Agent
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- Windows service
|
||||||
|
- enrollment
|
||||||
|
- heartbeat
|
||||||
|
- file discovery
|
||||||
|
- file backup
|
||||||
|
- restore
|
||||||
|
- secure communication
|
||||||
|
- local logging
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- service restart
|
||||||
|
- network interruption
|
||||||
|
- permission errors
|
||||||
|
- large files
|
||||||
|
- many small files
|
||||||
|
- incremental backup
|
||||||
|
|
||||||
|
### Exit Criteria
|
||||||
|
|
||||||
|
A Windows system can be backed up and restored reliably.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. Phase 6 — Linux Agent
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- systemd service
|
||||||
|
- enrollment
|
||||||
|
- heartbeat
|
||||||
|
- filesystem backup
|
||||||
|
- restore
|
||||||
|
- secure communication
|
||||||
|
|
||||||
|
Test:
|
||||||
|
|
||||||
|
- permissions
|
||||||
|
- symlinks
|
||||||
|
- special files as applicable
|
||||||
|
- interrupted jobs
|
||||||
|
- incremental operation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. Phase 7 — Proxmox Provider
|
||||||
|
|
||||||
|
Implement provider interface and Proxmox implementation.
|
||||||
|
|
||||||
|
### Discovery
|
||||||
|
|
||||||
|
- cluster
|
||||||
|
- hosts
|
||||||
|
- VMs
|
||||||
|
- VM storage
|
||||||
|
- configuration
|
||||||
|
|
||||||
|
### Backup
|
||||||
|
|
||||||
|
- VM metadata
|
||||||
|
- VM disks
|
||||||
|
- snapshots where appropriate
|
||||||
|
- changed blocks where available
|
||||||
|
- consistency state
|
||||||
|
|
||||||
|
### Restore
|
||||||
|
|
||||||
|
- original host
|
||||||
|
- alternate host
|
||||||
|
- new VM
|
||||||
|
|
||||||
|
### Exit Criteria
|
||||||
|
|
||||||
|
A test Proxmox environment can:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Discover VM
|
||||||
|
-> Backup VM
|
||||||
|
-> Verify
|
||||||
|
-> Delete test VM
|
||||||
|
-> Restore VM
|
||||||
|
-> Boot VM
|
||||||
|
-> Validate VM
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a mandatory end-to-end milestone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. Phase 8 — Scheduler and Job Management
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- jobs
|
||||||
|
- schedules
|
||||||
|
- priorities
|
||||||
|
- concurrency
|
||||||
|
- retries
|
||||||
|
- backoff
|
||||||
|
- job dependencies
|
||||||
|
- bandwidth limits
|
||||||
|
- maintenance windows
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
Create the backup wizard:
|
||||||
|
|
||||||
|
1. Name
|
||||||
|
2. Source
|
||||||
|
3. Schedule
|
||||||
|
4. Repository
|
||||||
|
5. Retention
|
||||||
|
6. Security
|
||||||
|
7. Verification
|
||||||
|
8. Notifications
|
||||||
|
9. Review
|
||||||
|
10. Create
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. Phase 9 — Recovery Engine
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- file restore
|
||||||
|
- folder restore
|
||||||
|
- disk restore
|
||||||
|
- full system restore where supported
|
||||||
|
- Proxmox VM restore
|
||||||
|
- restore validation
|
||||||
|
- restore sessions
|
||||||
|
- checkpoints
|
||||||
|
- progress
|
||||||
|
|
||||||
|
### Safety
|
||||||
|
|
||||||
|
Restores to production must require explicit confirmation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 12. Phase 10 — Verification and Recovery Assurance
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- chunk verification
|
||||||
|
- manifest verification
|
||||||
|
- chain validation
|
||||||
|
- repository integrity scans
|
||||||
|
- restore-point validation
|
||||||
|
- automated recovery tests
|
||||||
|
- Recovery Assurance score
|
||||||
|
|
||||||
|
### Score Inputs
|
||||||
|
|
||||||
|
- backup freshness
|
||||||
|
- verification
|
||||||
|
- restore test
|
||||||
|
- RPO
|
||||||
|
- RTO
|
||||||
|
- immutability
|
||||||
|
- offsite
|
||||||
|
- encryption
|
||||||
|
- repository health
|
||||||
|
- anomalies
|
||||||
|
|
||||||
|
### Exit Criteria
|
||||||
|
|
||||||
|
A backup can be objectively classified as:
|
||||||
|
|
||||||
|
- successful
|
||||||
|
- verified
|
||||||
|
- recoverable
|
||||||
|
- failed
|
||||||
|
- corrupted
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 13. Phase 11 — Immutability
|
||||||
|
|
||||||
|
Implement hardened repository protections.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- retention lock
|
||||||
|
- immutable-until metadata
|
||||||
|
- restricted delete
|
||||||
|
- audit
|
||||||
|
- cleanup protection
|
||||||
|
|
||||||
|
Where the storage layer supports stronger WORM/object-lock behavior, integrate it.
|
||||||
|
|
||||||
|
Do not claim true immutability if the underlying storage cannot enforce it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 14. Phase 12 — Web Dashboard
|
||||||
|
|
||||||
|
Build the main UI.
|
||||||
|
|
||||||
|
### Pages
|
||||||
|
|
||||||
|
- Dashboard
|
||||||
|
- Backup Jobs
|
||||||
|
- Protected Systems
|
||||||
|
- Recovery
|
||||||
|
- Recovery Points
|
||||||
|
- Repositories
|
||||||
|
- Proxmox
|
||||||
|
- Agents
|
||||||
|
- Alerts
|
||||||
|
- Events
|
||||||
|
- Security Center
|
||||||
|
- Reports
|
||||||
|
- Users
|
||||||
|
- Roles
|
||||||
|
- Settings
|
||||||
|
|
||||||
|
### Dashboard widgets
|
||||||
|
|
||||||
|
- protected systems
|
||||||
|
- backup success rate
|
||||||
|
- failed jobs
|
||||||
|
- critical alerts
|
||||||
|
- storage
|
||||||
|
- capacity forecast
|
||||||
|
- Recovery Assurance
|
||||||
|
- Security Score
|
||||||
|
- RPO compliance
|
||||||
|
- repository health
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 15. Phase 13 — Metrics and Graphs
|
||||||
|
|
||||||
|
Implement metrics storage and chart APIs.
|
||||||
|
|
||||||
|
Required graphs:
|
||||||
|
|
||||||
|
- backup success/failure
|
||||||
|
- duration
|
||||||
|
- throughput
|
||||||
|
- processed/written data
|
||||||
|
- storage growth
|
||||||
|
- deduplication
|
||||||
|
- compression
|
||||||
|
- repository capacity
|
||||||
|
- agent resources
|
||||||
|
- RPO
|
||||||
|
- recovery duration
|
||||||
|
- verification results
|
||||||
|
|
||||||
|
Time ranges:
|
||||||
|
|
||||||
|
- 1h
|
||||||
|
- 24h
|
||||||
|
- 7d
|
||||||
|
- 30d
|
||||||
|
- 90d
|
||||||
|
- 1y
|
||||||
|
- custom
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 16. Phase 14 — Alerts and Notifications
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- alert rules
|
||||||
|
- severity
|
||||||
|
- acknowledge
|
||||||
|
- resolve
|
||||||
|
- email
|
||||||
|
- webhook
|
||||||
|
|
||||||
|
Initial rules:
|
||||||
|
|
||||||
|
- backup failure
|
||||||
|
- repeated backup failure
|
||||||
|
- repository unavailable
|
||||||
|
- repository >80%
|
||||||
|
- repository >90%
|
||||||
|
- agent offline
|
||||||
|
- RPO violation
|
||||||
|
- verification failure
|
||||||
|
- certificate expiry
|
||||||
|
- possible ransomware activity
|
||||||
|
- immutable copy missing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 17. Phase 15 — Security Center
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- Security Score
|
||||||
|
- MFA status
|
||||||
|
- encryption status
|
||||||
|
- immutability status
|
||||||
|
- offsite status
|
||||||
|
- audit status
|
||||||
|
- certificate status
|
||||||
|
- agent security
|
||||||
|
- update status
|
||||||
|
- ransomware risk
|
||||||
|
|
||||||
|
Every finding should contain:
|
||||||
|
|
||||||
|
- severity
|
||||||
|
- explanation
|
||||||
|
- affected object
|
||||||
|
- recommendation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 18. Phase 16 — Ransomware Heuristics
|
||||||
|
|
||||||
|
Implement initial statistical detection.
|
||||||
|
|
||||||
|
Baseline:
|
||||||
|
|
||||||
|
- changed bytes
|
||||||
|
- changed file count
|
||||||
|
- extension distribution
|
||||||
|
- entropy indicators
|
||||||
|
- deletion rate
|
||||||
|
- unusual backup size
|
||||||
|
|
||||||
|
Do not make destructive decisions automatically.
|
||||||
|
|
||||||
|
Alert first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 19. Phase 17 — Reports
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- daily backup report
|
||||||
|
- weekly report
|
||||||
|
- monthly report
|
||||||
|
- failed backup report
|
||||||
|
- repository report
|
||||||
|
- recovery report
|
||||||
|
- security report
|
||||||
|
- compliance-oriented report
|
||||||
|
- RPO/RTO report
|
||||||
|
|
||||||
|
Export:
|
||||||
|
|
||||||
|
- CSV
|
||||||
|
- JSON
|
||||||
|
- PDF where practical
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 20. Phase 18 — Disaster Recovery
|
||||||
|
|
||||||
|
Implement and test:
|
||||||
|
|
||||||
|
## Scenario A
|
||||||
|
|
||||||
|
Control server lost.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Install Syncova
|
||||||
|
-> Restore configuration
|
||||||
|
-> Attach repository
|
||||||
|
-> Rebuild catalog
|
||||||
|
-> Restore VM
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scenario B
|
||||||
|
|
||||||
|
Database lost.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- restore database
|
||||||
|
- verify repository
|
||||||
|
- recover jobs/catalog
|
||||||
|
|
||||||
|
## Scenario C
|
||||||
|
|
||||||
|
Repository catalog lost.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- scan repository
|
||||||
|
- reconstruct metadata
|
||||||
|
|
||||||
|
## Scenario D
|
||||||
|
|
||||||
|
Network interruption.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- checkpoint
|
||||||
|
- retry
|
||||||
|
- resume
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 21. Phase 19 — Hardening
|
||||||
|
|
||||||
|
Perform:
|
||||||
|
|
||||||
|
- dependency scanning
|
||||||
|
- static analysis
|
||||||
|
- secret scanning
|
||||||
|
- API security tests
|
||||||
|
- RBAC tests
|
||||||
|
- authentication tests
|
||||||
|
- path traversal tests
|
||||||
|
- injection tests
|
||||||
|
- SSRF tests
|
||||||
|
- XSS tests
|
||||||
|
- privilege escalation tests
|
||||||
|
|
||||||
|
Harden:
|
||||||
|
|
||||||
|
- headers
|
||||||
|
- cookies
|
||||||
|
- CORS
|
||||||
|
- rate limits
|
||||||
|
- TLS
|
||||||
|
- service permissions
|
||||||
|
- filesystem permissions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 22. Phase 20 — Performance Testing
|
||||||
|
|
||||||
|
Test:
|
||||||
|
|
||||||
|
- 1 large VM
|
||||||
|
- many small files
|
||||||
|
- multiple concurrent jobs
|
||||||
|
- high throughput repository
|
||||||
|
- slow repository
|
||||||
|
- slow network
|
||||||
|
- CPU-constrained host
|
||||||
|
- memory-constrained host
|
||||||
|
|
||||||
|
Measure:
|
||||||
|
|
||||||
|
- throughput
|
||||||
|
- latency
|
||||||
|
- CPU
|
||||||
|
- memory
|
||||||
|
- disk IOPS
|
||||||
|
- network
|
||||||
|
- repository contention
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 23. Phase 21 — Chaos Testing
|
||||||
|
|
||||||
|
Simulate:
|
||||||
|
|
||||||
|
- process kill
|
||||||
|
- service restart
|
||||||
|
- network loss
|
||||||
|
- repository unavailable
|
||||||
|
- database unavailable
|
||||||
|
- disk full
|
||||||
|
- corrupted chunk
|
||||||
|
- corrupted manifest
|
||||||
|
- power interruption simulation
|
||||||
|
|
||||||
|
Every failure must produce a controlled result.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 24. Phase 22 — Release Candidate
|
||||||
|
|
||||||
|
Freeze:
|
||||||
|
|
||||||
|
- API contracts
|
||||||
|
- backup format version
|
||||||
|
- migration process
|
||||||
|
- repository protocol
|
||||||
|
|
||||||
|
Perform:
|
||||||
|
|
||||||
|
- full E2E test
|
||||||
|
- upgrade test
|
||||||
|
- downgrade/rollback test where supported
|
||||||
|
- disaster recovery test
|
||||||
|
- security review
|
||||||
|
- performance review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 25. Phase 23 — V1 Release
|
||||||
|
|
||||||
|
Release package must include:
|
||||||
|
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
- Windows agent
|
||||||
|
- Linux agent
|
||||||
|
- repository service
|
||||||
|
- migration files
|
||||||
|
- documentation
|
||||||
|
- installation instructions
|
||||||
|
- recovery documentation
|
||||||
|
- security guide
|
||||||
|
- API docs
|
||||||
|
- troubleshooting guide
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 26. Acceptance Test Matrix
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
- [ ] Windows file backup
|
||||||
|
- [ ] Linux file backup
|
||||||
|
- [ ] Proxmox VM backup
|
||||||
|
- [ ] incremental
|
||||||
|
- [ ] deduplication
|
||||||
|
- [ ] compression
|
||||||
|
- [ ] encryption
|
||||||
|
- [ ] retry
|
||||||
|
- [ ] resume
|
||||||
|
- [ ] bandwidth control
|
||||||
|
|
||||||
|
## Recovery
|
||||||
|
|
||||||
|
- [ ] file restore
|
||||||
|
- [ ] folder restore
|
||||||
|
- [ ] Proxmox restore
|
||||||
|
- [ ] alternate-host restore
|
||||||
|
- [ ] restore validation
|
||||||
|
- [ ] interrupted restore recovery
|
||||||
|
|
||||||
|
## Repository
|
||||||
|
|
||||||
|
- [ ] local repository
|
||||||
|
- [ ] hardened repository
|
||||||
|
- [ ] integrity scan
|
||||||
|
- [ ] corruption detection
|
||||||
|
- [ ] catalog rebuild
|
||||||
|
- [ ] immutable retention
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- [ ] RBAC
|
||||||
|
- [ ] MFA
|
||||||
|
- [ ] audit
|
||||||
|
- [ ] TLS
|
||||||
|
- [ ] secret protection
|
||||||
|
- [ ] privilege boundaries
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
- [ ] dashboard
|
||||||
|
- [ ] metrics
|
||||||
|
- [ ] graphs
|
||||||
|
- [ ] alerts
|
||||||
|
- [ ] capacity forecast
|
||||||
|
- [ ] health
|
||||||
|
|
||||||
|
## Reliability
|
||||||
|
|
||||||
|
- [ ] control server restart
|
||||||
|
- [ ] database restart
|
||||||
|
- [ ] repository restart
|
||||||
|
- [ ] agent restart
|
||||||
|
- [ ] network interruption
|
||||||
|
- [ ] corrupted data
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 27. Recommended First Engineering Sprint
|
||||||
|
|
||||||
|
Do NOT begin with the dashboard.
|
||||||
|
|
||||||
|
The first production-quality vertical slice should be:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PostgreSQL
|
||||||
|
|
|
||||||
|
Control API
|
||||||
|
|
|
||||||
|
Repository
|
||||||
|
|
|
||||||
|
Backup Engine
|
||||||
|
|
|
||||||
|
Test Source
|
||||||
|
|
|
||||||
|
Backup
|
||||||
|
|
|
||||||
|
Manifest
|
||||||
|
|
|
||||||
|
Integrity Verification
|
||||||
|
|
|
||||||
|
Restore
|
||||||
|
```
|
||||||
|
|
||||||
|
Once this works reliably, add agents and Proxmox.
|
||||||
|
|
||||||
|
The backup/recovery engine is the product's foundation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 28. Development Rules
|
||||||
|
|
||||||
|
1. Never fake a completed backup.
|
||||||
|
2. Never hide integrity failures.
|
||||||
|
3. Never store backup payloads in PostgreSQL.
|
||||||
|
4. Never put secrets in logs.
|
||||||
|
5. Never trust frontend authorization.
|
||||||
|
6. Never make destructive actions silent.
|
||||||
|
7. Never load complete backups into RAM.
|
||||||
|
8. Never make Proxmox code part of the generic backup engine.
|
||||||
|
9. Never mark a partial backup successful.
|
||||||
|
10. Never release a backup feature without a recovery test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 29. V1 Success Definition
|
||||||
|
|
||||||
|
Syncova V1 succeeds when a small IT team can:
|
||||||
|
|
||||||
|
1. Install Syncova.
|
||||||
|
2. Add a repository.
|
||||||
|
3. Add a Proxmox cluster.
|
||||||
|
4. Discover VMs.
|
||||||
|
5. Create a backup job.
|
||||||
|
6. Run a backup.
|
||||||
|
7. See live progress.
|
||||||
|
8. Verify the backup.
|
||||||
|
9. See meaningful statistics.
|
||||||
|
10. Restore the VM.
|
||||||
|
11. Confirm that it boots.
|
||||||
|
12. Understand the security/recovery state from the dashboard.
|
||||||
|
|
||||||
|
The experience should feel simple even though the underlying system is sophisticated.
|
||||||
407
apps/agent/cmd/syncova-agent/backup_commands.go
Normal file
407
apps/agent/cmd/syncova-agent/backup_commands.go
Normal file
@ -0,0 +1,407 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/agent"
|
||||||
|
"github.com/syncova/syncova/packages/backupengine"
|
||||||
|
"github.com/syncova/syncova/packages/platform/crypto"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
"github.com/syncova/syncova/packages/platform/ratelimit"
|
||||||
|
"github.com/syncova/syncova/packages/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// encryptionKeysVariable ist die Umgebungsvariable mit dem Schlüsselmaterial.
|
||||||
|
//
|
||||||
|
// Der Schlüssel steht bewusst nicht als Kommandozeilenoption zur Verfügung:
|
||||||
|
// Argumente sind auf den meisten Systemen für andere Prozesse sichtbar
|
||||||
|
// (PROMPT.md §45: keine Secrets in URLs oder Argumenten).
|
||||||
|
const encryptionKeysVariable = "SYNCOVA_ENCRYPTION_KEYS"
|
||||||
|
|
||||||
|
// openRepositoryWithEngine öffnet ein Repository und baut die Backup Engine.
|
||||||
|
func openRepositoryWithEngine(openContext context.Context, repositoryPath string, requireWriteAccess bool) (*repository.LocalRepository, *backupengine.Engine, error) {
|
||||||
|
commandLogger := logging.New(os.Stderr, logging.Options{
|
||||||
|
ServiceName: serviceName, Level: "warn", Format: "text",
|
||||||
|
})
|
||||||
|
|
||||||
|
openedRepository, openError := repository.Open(openContext, repositoryPath,
|
||||||
|
repository.OpenOptions{ReadOnly: !requireWriteAccess}, commandLogger)
|
||||||
|
if openError != nil {
|
||||||
|
return nil, nil, openError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne Schlüsselmaterial sind ausschließlich unverschlüsselte Backups
|
||||||
|
// möglich. Das wird nicht stillschweigend angenommen, sondern beim Aufruf
|
||||||
|
// geprüft.
|
||||||
|
var secretStore crypto.SecretStore
|
||||||
|
|
||||||
|
rawKeySet := os.Getenv(encryptionKeysVariable)
|
||||||
|
if strings.TrimSpace(rawKeySet) != "" {
|
||||||
|
parsedKeys, parseError := crypto.ParseKeySet(rawKeySet)
|
||||||
|
if parseError != nil {
|
||||||
|
_ = openedRepository.Close()
|
||||||
|
return nil, nil, fmt.Errorf("%s: %w", encryptionKeysVariable, parseError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne ausdrückliche Angabe wird der einzige vorhandene Schlüssel verwendet.
|
||||||
|
currentKeyVersion := os.Getenv("SYNCOVA_ENCRYPTION_CURRENT_KEY")
|
||||||
|
if currentKeyVersion == "" {
|
||||||
|
if len(parsedKeys) != 1 {
|
||||||
|
_ = openedRepository.Close()
|
||||||
|
return nil, nil, errors.New("bei mehreren Schlüsseln ist SYNCOVA_ENCRYPTION_CURRENT_KEY erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
for keyVersion := range parsedKeys {
|
||||||
|
currentKeyVersion = keyVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builtStore, storeError := crypto.NewLocalSecretStore(parsedKeys, currentKeyVersion)
|
||||||
|
if storeError != nil {
|
||||||
|
_ = openedRepository.Close()
|
||||||
|
return nil, nil, storeError
|
||||||
|
}
|
||||||
|
|
||||||
|
secretStore = builtStore
|
||||||
|
}
|
||||||
|
|
||||||
|
return openedRepository, backupengine.NewEngine(openedRepository, secretStore, commandLogger), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runBackupCommand sichert ein Verzeichnis in ein Repository.
|
||||||
|
func runBackupCommand(commandArguments []string) error {
|
||||||
|
commandFlags := flag.NewFlagSet("backup", flag.ContinueOnError)
|
||||||
|
repositoryPath := commandFlags.String("repository", "", "Pfad des Repositorys")
|
||||||
|
sourcePath := commandFlags.String("path", "", "Zu sicherndes Verzeichnis")
|
||||||
|
backupID := commandFlags.String("id", "", "Kennung des Backups; leer erzeugt eine aus Zeitstempel")
|
||||||
|
sourceName := commandFlags.String("name", "", "Sprechende Bezeichnung der Quelle")
|
||||||
|
excludeList := commandFlags.String("exclude", "", "Auszuschließende Muster, kommasepariert")
|
||||||
|
includeList := commandFlags.String("include", "", "Einzuschließende Muster, kommasepariert")
|
||||||
|
compressionName := commandFlags.String("compression", "balanced", "Kompression: off, fast, balanced, maximum")
|
||||||
|
disableEncryption := commandFlags.Bool("no-encryption", false, "Verschlüsselung abschalten (nicht empfohlen)")
|
||||||
|
abortOnProblems := commandFlags.Bool("strict", false, "Bei nicht lesbaren Objekten abbrechen")
|
||||||
|
incrementalMode := commandFlags.Bool("incremental", false, "Nur Geändertes lesen; unveränderte Objekte aus dem Elternbackup übernehmen")
|
||||||
|
parentBackupID := commandFlags.String("parent", "", "Kennung des Elternbackups; leer wählt das jüngste Backup derselben Quelle")
|
||||||
|
bandwidthLimit := commandFlags.String("bandwidth", "", "Lesedurchsatz begrenzen, etwa 50MB oder 400Mbit; leer bedeutet unbegrenzt")
|
||||||
|
|
||||||
|
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if *repositoryPath == "" || *sourcePath == "" {
|
||||||
|
return errors.New("--repository und --path sind erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Elternbackup ohne --incremental wäre ein stiller Widerspruch: der
|
||||||
|
// Aufrufer erwartete eine Zusatzsicherung und bekäme eine vollständige.
|
||||||
|
if *parentBackupID != "" && !*incrementalMode {
|
||||||
|
return errors.New("--parent ergibt nur zusammen mit --incremental einen Sinn")
|
||||||
|
}
|
||||||
|
|
||||||
|
compressionLevel, compressionError := parseCompressionLevel(*compressionName)
|
||||||
|
if compressionError != nil {
|
||||||
|
return compressionError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Angabe wird vor dem Öffnen des Repositorys geprüft: Ein Tippfehler in
|
||||||
|
// der Bandbreite soll nicht erst nach dem Sperren auffallen.
|
||||||
|
bandwidthBytesPerSecond, bandwidthError := ratelimit.ParseBandwidthLimit(*bandwidthLimit)
|
||||||
|
if bandwidthError != nil {
|
||||||
|
return bandwidthError
|
||||||
|
}
|
||||||
|
|
||||||
|
bandwidthLimiter, limiterError := ratelimit.NewLimiter(bandwidthBytesPerSecond)
|
||||||
|
if limiterError != nil {
|
||||||
|
return limiterError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verschlüsselung ist der Standard; sie abzuschalten muss ausdrücklich
|
||||||
|
// geschehen und wird deutlich benannt (PROMPT.md §119, §120).
|
||||||
|
encryptionEnabled := !*disableEncryption
|
||||||
|
if encryptionEnabled && strings.TrimSpace(os.Getenv(encryptionKeysVariable)) == "" {
|
||||||
|
return fmt.Errorf("für ein verschlüsseltes Backup muss %s gesetzt sein.\n"+
|
||||||
|
"Einen Schlüssel erzeugt 'syncova-admin generate-key'.\n"+
|
||||||
|
"Ein unverschlüsseltes Backup verlangt ausdrücklich --no-encryption", encryptionKeysVariable)
|
||||||
|
}
|
||||||
|
|
||||||
|
effectiveBackupID := *backupID
|
||||||
|
if effectiveBackupID == "" {
|
||||||
|
effectiveBackupID = "backup-" + time.Now().UTC().Format("20060102-150405")
|
||||||
|
}
|
||||||
|
|
||||||
|
backupContext, stopSignalListener := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stopSignalListener()
|
||||||
|
|
||||||
|
openedRepository, engine, openError := openRepositoryWithEngine(backupContext, *repositoryPath, true)
|
||||||
|
if openError != nil {
|
||||||
|
return openError
|
||||||
|
}
|
||||||
|
defer func() { _ = openedRepository.Close() }()
|
||||||
|
|
||||||
|
commandLogger := logging.New(os.Stderr, logging.Options{
|
||||||
|
ServiceName: serviceName, Level: "warn", Format: "text",
|
||||||
|
})
|
||||||
|
|
||||||
|
backupRunner := agent.NewBackupRunner(engine, commandLogger)
|
||||||
|
|
||||||
|
if !encryptionEnabled {
|
||||||
|
fmt.Fprintln(os.Stderr, "Hinweis: Dieses Backup wird UNVERSCHLÜSSELT abgelegt.")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Sichere %s nach %s ...\n\n", *sourcePath, *repositoryPath)
|
||||||
|
|
||||||
|
backupResult, backupError := backupRunner.RunBackup(backupContext, agent.BackupRunOptions{
|
||||||
|
BackupID: effectiveBackupID,
|
||||||
|
SourcePath: *sourcePath,
|
||||||
|
SourceName: *sourceName,
|
||||||
|
DiscoveryOptions: agent.DiscoveryOptions{
|
||||||
|
IncludePatterns: splitPatternList(*includeList),
|
||||||
|
ExcludePatterns: splitPatternList(*excludeList),
|
||||||
|
},
|
||||||
|
CompressionLevel: compressionLevel,
|
||||||
|
EncryptionEnabled: encryptionEnabled,
|
||||||
|
AbortOnProblems: *abortOnProblems,
|
||||||
|
BandwidthLimiter: bandwidthLimiter,
|
||||||
|
Incremental: *incrementalMode,
|
||||||
|
ParentBackupID: *parentBackupID,
|
||||||
|
CreatedByVersion: buildVersion,
|
||||||
|
ProgressCallback: func(progress backupengine.Progress) {
|
||||||
|
fmt.Printf("\r %s verarbeitet | %.0f MiB/s | %d Blöcke neu, %d wiederverwendet ",
|
||||||
|
formatBytes(progress.BytesProcessed),
|
||||||
|
progress.ThroughputBytesPerSecond/(1024*1024),
|
||||||
|
progress.ChunksWritten, progress.ChunksDeduplicated)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if backupError != nil {
|
||||||
|
fmt.Println()
|
||||||
|
return backupError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\r%-78s\r", "")
|
||||||
|
|
||||||
|
fmt.Printf(" Backup: %s\n", backupResult.BackupID)
|
||||||
|
fmt.Printf(" Art: %s\n", describeBackupType(backupResult.BackupType))
|
||||||
|
fmt.Printf(" Dateien: %d\n", backupResult.FilesBackedUp)
|
||||||
|
fmt.Printf(" Verzeichnisse: %d\n", backupResult.DirectoriesRecorded)
|
||||||
|
|
||||||
|
if backupResult.SymlinksRecorded > 0 {
|
||||||
|
fmt.Printf(" Verweise: %d\n", backupResult.SymlinksRecorded)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" Verarbeitet: %s\n", formatBytes(backupResult.Progress.BytesProcessed))
|
||||||
|
fmt.Printf(" Abgelegt: %s\n", formatBytes(backupResult.Progress.BytesWritten))
|
||||||
|
fmt.Printf(" Ersparnis: %.1f %%\n", backupResult.Progress.SavingsPercentage())
|
||||||
|
fmt.Printf(" Dauer: %s\n", backupResult.Duration.Round(time.Millisecond))
|
||||||
|
fmt.Printf(" Verschlüsselt: %s\n", formatBoolean(encryptionEnabled))
|
||||||
|
|
||||||
|
if !bandwidthLimiter.IsUnlimited() {
|
||||||
|
fmt.Printf(" Bandbreite: %s (Lesen von der Quelle)\n",
|
||||||
|
ratelimit.FormatBandwidthLimit(bandwidthLimiter.BytesPerSecond()))
|
||||||
|
}
|
||||||
|
|
||||||
|
printChangeSummary(backupResult.Changes)
|
||||||
|
|
||||||
|
fmt.Printf("\n%s\n", backupResult.Summary())
|
||||||
|
|
||||||
|
// Ein Teilfehler wird nicht beschönigt und liefert einen Fehlerstatus,
|
||||||
|
// damit ein Skript oder Monitoring daran anschlägt (PROMPT.md §140).
|
||||||
|
if backupResult.IsPartialFailure() {
|
||||||
|
fmt.Println("\nNicht gesicherte Objekte:")
|
||||||
|
|
||||||
|
for problemIndex, backupProblem := range backupResult.Problems {
|
||||||
|
if problemIndex >= 10 {
|
||||||
|
fmt.Printf(" ... und %d weitere\n", len(backupResult.Problems)-10)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" %s\n %s\n", backupProblem.Path, backupProblem.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.New("die sicherung ist unvollständig")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRestoreCommand stellt ein Backup wieder her.
|
||||||
|
func runRestoreCommand(commandArguments []string) error {
|
||||||
|
commandFlags := flag.NewFlagSet("restore", flag.ContinueOnError)
|
||||||
|
repositoryPath := commandFlags.String("repository", "", "Pfad des Repositorys")
|
||||||
|
backupID := commandFlags.String("id", "", "Kennung des wiederherzustellenden Backups")
|
||||||
|
targetPath := commandFlags.String("target", "", "Zielverzeichnis")
|
||||||
|
pathPrefix := commandFlags.String("subtree", "", "Nur diesen Teilbaum wiederherstellen")
|
||||||
|
overwriteExisting := commandFlags.Bool("overwrite", false, "Vorhandene Dateien überschreiben")
|
||||||
|
skipPermissions := commandFlags.Bool("no-permissions", false, "Ursprüngliche Rechte nicht setzen")
|
||||||
|
|
||||||
|
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if *repositoryPath == "" || *backupID == "" || *targetPath == "" {
|
||||||
|
return errors.New("--repository, --id und --target sind erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreContext, stopSignalListener := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stopSignalListener()
|
||||||
|
|
||||||
|
openedRepository, engine, openError := openRepositoryWithEngine(restoreContext, *repositoryPath, false)
|
||||||
|
if openError != nil {
|
||||||
|
return openError
|
||||||
|
}
|
||||||
|
defer func() { _ = openedRepository.Close() }()
|
||||||
|
|
||||||
|
commandLogger := logging.New(os.Stderr, logging.Options{
|
||||||
|
ServiceName: serviceName, Level: "warn", Format: "text",
|
||||||
|
})
|
||||||
|
|
||||||
|
restoreRunner := agent.NewRestoreRunner(engine, commandLogger)
|
||||||
|
|
||||||
|
// Das Überschreiben vorhandener Daten wird deutlich angekündigt.
|
||||||
|
if *overwriteExisting {
|
||||||
|
fmt.Fprintf(os.Stderr, "Achtung: Vorhandene Dateien in %s werden überschrieben.\n\n", *targetPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Stelle %s nach %s wieder her ...\n\n", *backupID, *targetPath)
|
||||||
|
|
||||||
|
restoreResult, restoreError := restoreRunner.RunRestore(restoreContext, agent.RestoreRunOptions{
|
||||||
|
BackupID: *backupID,
|
||||||
|
TargetPath: *targetPath,
|
||||||
|
PathPrefix: *pathPrefix,
|
||||||
|
OverwriteExisting: *overwriteExisting,
|
||||||
|
RestorePermissions: !*skipPermissions,
|
||||||
|
})
|
||||||
|
|
||||||
|
if restoreError != nil {
|
||||||
|
return restoreError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" Dateien: %d\n", restoreResult.FilesRestored)
|
||||||
|
fmt.Printf(" Verzeichnisse: %d\n", restoreResult.DirectoriesCreated)
|
||||||
|
|
||||||
|
if restoreResult.SymlinksCreated > 0 {
|
||||||
|
fmt.Printf(" Verweise: %d\n", restoreResult.SymlinksCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" Zurückgeschrieben: %s\n", formatBytes(restoreResult.BytesRestored))
|
||||||
|
fmt.Printf(" Geprüft: %d Dateien gegen ihre Prüfsumme\n", restoreResult.VerifiedFiles)
|
||||||
|
fmt.Printf(" Dauer: %s\n", restoreResult.Duration.Round(time.Millisecond))
|
||||||
|
|
||||||
|
fmt.Printf("\n%s\n", restoreResult.Summary())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCompressionLevel liest eine Kompressionsstufe.
|
||||||
|
func parseCompressionLevel(levelName string) (backupengine.CompressionLevel, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(levelName)) {
|
||||||
|
case "off":
|
||||||
|
return backupengine.CompressionOff, nil
|
||||||
|
case "fast":
|
||||||
|
return backupengine.CompressionFast, nil
|
||||||
|
case "balanced", "":
|
||||||
|
return backupengine.CompressionBalanced, nil
|
||||||
|
case "maximum":
|
||||||
|
return backupengine.CompressionMaximum, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unbekannte Kompressionsstufe %q (erlaubt: off, fast, balanced, maximum)", levelName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatBoolean stellt einen Schalter in Worten dar.
|
||||||
|
func formatBoolean(flagValue bool) string {
|
||||||
|
if flagValue {
|
||||||
|
return "ja"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "nein"
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeBackupType benennt die Art eines Backups verständlich.
|
||||||
|
func describeBackupType(backupType repository.BackupType) string {
|
||||||
|
switch backupType {
|
||||||
|
case repository.BackupTypeIncremental:
|
||||||
|
return "Zusatzsicherung"
|
||||||
|
case repository.BackupTypeSyntheticFull:
|
||||||
|
return "zusammengesetzte Vollsicherung"
|
||||||
|
default:
|
||||||
|
return "Vollsicherung"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printChangeSummary gibt das Ergebnis der Änderungserkennung aus.
|
||||||
|
//
|
||||||
|
// Gelöschte Objekte werden ausdrücklich genannt. Ein Backup, das eine
|
||||||
|
// verschwundene Datei stillschweigend nicht mehr enthält, verwehrt genau die
|
||||||
|
// Beobachtung, für die man Backups anlegt.
|
||||||
|
func printChangeSummary(changeSet *agent.ChangeSet) {
|
||||||
|
if changeSet == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n Verglichen mit: %s\n", changeSet.ParentBackupID)
|
||||||
|
fmt.Printf(" Neu: %d\n", changeSet.CountOf(agent.ChangeKindAdded))
|
||||||
|
fmt.Printf(" Geändert: %d\n", changeSet.CountOf(agent.ChangeKindModified))
|
||||||
|
fmt.Printf(" Übernommen: %d Dateien ohne erneutes Lesen\n", changeSet.ReusedFileCount())
|
||||||
|
fmt.Printf(" Nur Rechte: %d\n", changeSet.CountOf(agent.ChangeKindMetadataOnly))
|
||||||
|
|
||||||
|
if len(changeSet.DeletedPaths) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" Gelöscht: %d (nicht mehr im Backup enthalten)\n", len(changeSet.DeletedPaths))
|
||||||
|
|
||||||
|
// Bei vielen Löschungen wird die Ausgabe gekürzt; die Zahl darüber bleibt
|
||||||
|
// vollständig.
|
||||||
|
const maximumListedDeletions = 10
|
||||||
|
for deletionIndex, deletedPath := range changeSet.DeletedPaths {
|
||||||
|
if deletionIndex >= maximumListedDeletions {
|
||||||
|
fmt.Printf(" ... und %d weitere\n", len(changeSet.DeletedPaths)-maximumListedDeletions)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" - %s\n", deletedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSecretStoreFromEnvironment liest das Schlüsselmaterial aus der Umgebung.
|
||||||
|
//
|
||||||
|
// Dieselbe Quelle wie beim Kommandozeilenweg: Ein Agent, der als Dienst läuft,
|
||||||
|
// bekommt seinen Schlüssel über die Diensteinstellungen — und ein zweiter
|
||||||
|
// Einleseweg wäre ein zweiter Ort, an dem eine Prüfung fehlen kann.
|
||||||
|
//
|
||||||
|
// Fehlt die Variable, ist das kein Fehler: Der Agent kann unverschlüsselte
|
||||||
|
// Repositories bedienen. Ein Auftrag mit verlangter Verschlüsselung wird dann
|
||||||
|
// abgelehnt.
|
||||||
|
func buildSecretStoreFromEnvironment() (crypto.SecretStore, error) {
|
||||||
|
rawKeySet := os.Getenv(encryptionKeysVariable)
|
||||||
|
if strings.TrimSpace(rawKeySet) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedKeys, parseError := crypto.ParseKeySet(rawKeySet)
|
||||||
|
if parseError != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", encryptionKeysVariable, parseError)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentKeyVersion := os.Getenv("SYNCOVA_ENCRYPTION_CURRENT_KEY")
|
||||||
|
if currentKeyVersion == "" {
|
||||||
|
if len(parsedKeys) != 1 {
|
||||||
|
return nil, errors.New("bei mehreren Schlüsseln ist SYNCOVA_ENCRYPTION_CURRENT_KEY erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
for keyVersion := range parsedKeys {
|
||||||
|
currentKeyVersion = keyVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.NewLocalSecretStore(parsedKeys, currentKeyVersion)
|
||||||
|
}
|
||||||
388
apps/agent/cmd/syncova-agent/main.go
Normal file
388
apps/agent/cmd/syncova-agent/main.go
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
// Command syncova-agent sichert ein System auf Anweisung des Control Servers.
|
||||||
|
//
|
||||||
|
// Der Agent läuft als Dienst: unter Windows als Windows-Dienst, unter Linux als
|
||||||
|
// systemd-Unit. Der fachliche Kern ist plattformunabhängig; die Dienstanbindung
|
||||||
|
// liegt in den Dateien service_windows.go und service_unix.go.
|
||||||
|
//
|
||||||
|
// Aufruf:
|
||||||
|
//
|
||||||
|
// syncova-agent register --server <url> --token <aufnahme-token>
|
||||||
|
// syncova-agent run
|
||||||
|
// syncova-agent discover --path <pfad> [--exclude <muster>]
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/agent"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serviceName benennt den Agent in Logs und beim Betriebssystem.
|
||||||
|
const serviceName = "syncova-agent"
|
||||||
|
|
||||||
|
// buildVersion wird beim Bauen über -ldflags gesetzt.
|
||||||
|
var buildVersion = "0.1.0-dev"
|
||||||
|
|
||||||
|
// agentStateFileName ist die Ablage des Betriebstokens.
|
||||||
|
const agentStateFileName = "agent-state.json"
|
||||||
|
|
||||||
|
// agentState ist der dauerhaft gespeicherte Zustand des Agents.
|
||||||
|
type agentState struct {
|
||||||
|
// ServerBaseURL ist die Adresse des Control Servers.
|
||||||
|
ServerBaseURL string `json:"server_base_url"`
|
||||||
|
// AgentToken ist das Betriebstoken.
|
||||||
|
//
|
||||||
|
// Es liegt im Klartext auf der Platte, weil der Agent es bei jedem Start
|
||||||
|
// braucht und niemand zur Eingabe bereitsteht. Die Datei erhält deshalb
|
||||||
|
// möglichst enge Rechte; ein Angreifer mit Lesezugriff darauf hat ohnehin
|
||||||
|
// bereits Zugriff auf das zu sichernde System.
|
||||||
|
AgentToken string `json:"agent_token"`
|
||||||
|
// AgentID ist der Bezeichner des Agents beim Server.
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
// RegisteredAt ist der Zeitpunkt der Aufnahme.
|
||||||
|
RegisteredAt time.Time `json:"registered_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if runError := run(); runError != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %v\n", serviceName, runError)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run wertet das Unterkommando aus.
|
||||||
|
func run() error {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
return errors.New(usageText())
|
||||||
|
}
|
||||||
|
|
||||||
|
commandArguments := os.Args[2:]
|
||||||
|
|
||||||
|
switch requestedCommand := os.Args[1]; requestedCommand {
|
||||||
|
case "register":
|
||||||
|
return runRegister(commandArguments)
|
||||||
|
case "run":
|
||||||
|
return runService(commandArguments)
|
||||||
|
case "discover":
|
||||||
|
return runDiscover(commandArguments)
|
||||||
|
case "backup":
|
||||||
|
return runBackupCommand(commandArguments)
|
||||||
|
case "restore":
|
||||||
|
return runRestoreCommand(commandArguments)
|
||||||
|
case "version", "--version", "-version":
|
||||||
|
fmt.Printf("%s %s (%s)\n", serviceName, buildVersion, agent.CollectSystemInformation().Platform)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unbekanntes Kommando %q\n\n%s", requestedCommand, usageText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// usageText beschreibt die Verwendung.
|
||||||
|
func usageText() string {
|
||||||
|
return `Verwendung:
|
||||||
|
syncova-agent register --server <url> --token <aufnahme-token> Nimmt den Agent auf
|
||||||
|
syncova-agent run [--state <pfad>] Startet den Agent
|
||||||
|
syncova-agent discover --path <pfad> [--exclude <muster>] Zeigt die zu sichernden Dateien
|
||||||
|
syncova-agent backup --repository <pfad> --path <pfad> Sichert ein Verzeichnis
|
||||||
|
syncova-agent backup --repository <pfad> --path <pfad> --incremental
|
||||||
|
Sichert nur die Änderungen
|
||||||
|
syncova-agent backup ... --bandwidth 50MB Begrenzt den Lesedurchsatz
|
||||||
|
syncova-agent restore --repository <pfad> --id <backup> --target <pfad>
|
||||||
|
Stellt ein Backup wieder her
|
||||||
|
syncova-agent version Zeigt die Version
|
||||||
|
|
||||||
|
Der Schlüssel für verschlüsselte Backups kommt aus SYNCOVA_ENCRYPTION_KEYS.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultStateDirectory liefert das Standardverzeichnis für den Agent-Zustand.
|
||||||
|
func defaultStateDirectory() string {
|
||||||
|
// Der Zustand gehört neben das Programm, damit er einem Dienstkonto ohne
|
||||||
|
// Benutzerprofil zugänglich ist.
|
||||||
|
executablePath, executableError := os.Executable()
|
||||||
|
if executableError != nil {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Dir(executablePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRegister nimmt den Agent am Control Server auf.
|
||||||
|
func runRegister(commandArguments []string) error {
|
||||||
|
commandFlags := flag.NewFlagSet("register", flag.ContinueOnError)
|
||||||
|
serverURL := commandFlags.String("server", "", "Adresse des Control Servers")
|
||||||
|
enrollmentToken := commandFlags.String("token", "", "Aufnahme-Token")
|
||||||
|
statePath := commandFlags.String("state", "", "Ablageort des Agent-Zustands")
|
||||||
|
|
||||||
|
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if *serverURL == "" || *enrollmentToken == "" {
|
||||||
|
return errors.New("--server und --token sind erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
stateFilePath := resolveStatePath(*statePath)
|
||||||
|
|
||||||
|
// Eine bestehende Aufnahme wird nicht überschrieben: der Agent verlöre
|
||||||
|
// sonst sein Token und wäre für den Server ein neues, unbekanntes System.
|
||||||
|
if _, statError := os.Stat(stateFilePath); statError == nil {
|
||||||
|
return fmt.Errorf("der agent ist bereits registriert (%s). "+
|
||||||
|
"Für eine erneute Aufnahme die Datei entfernen", stateFilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
agentLogger := logging.New(os.Stderr, logging.Options{
|
||||||
|
ServiceName: serviceName, Level: "info", Format: "text",
|
||||||
|
})
|
||||||
|
|
||||||
|
agentClient := agent.NewClient(agent.ClientOptions{
|
||||||
|
ServerBaseURL: strings.TrimRight(*serverURL, "/"),
|
||||||
|
AgentVersion: buildVersion,
|
||||||
|
})
|
||||||
|
|
||||||
|
agentRunner := agent.NewRunner(agentClient, 0, agentLogger)
|
||||||
|
|
||||||
|
registerContext, cancelRegister := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
defer cancelRegister()
|
||||||
|
|
||||||
|
if _, registerError := agentRunner.RegisterIfNeeded(registerContext, *enrollmentToken); registerError != nil {
|
||||||
|
return registerError
|
||||||
|
}
|
||||||
|
|
||||||
|
systemInformation := agent.CollectSystemInformation()
|
||||||
|
|
||||||
|
if writeError := writeAgentState(stateFilePath, agentState{
|
||||||
|
ServerBaseURL: strings.TrimRight(*serverURL, "/"),
|
||||||
|
AgentToken: agentClient.AgentToken(),
|
||||||
|
RegisteredAt: time.Now().UTC(),
|
||||||
|
}); writeError != nil {
|
||||||
|
return writeError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Der Agent wurde aufgenommen.\n\n")
|
||||||
|
fmt.Printf(" Rechner: %s (%s/%s)\n", systemInformation.Hostname, systemInformation.Platform, systemInformation.Architecture)
|
||||||
|
fmt.Printf(" Server: %s\n", *serverURL)
|
||||||
|
fmt.Printf(" Zustand: %s\n", stateFilePath)
|
||||||
|
fmt.Printf("\nDer Agent kann jetzt als Dienst gestartet werden.\n")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runService startet den Agent.
|
||||||
|
func runService(commandArguments []string) error {
|
||||||
|
commandFlags := flag.NewFlagSet("run", flag.ContinueOnError)
|
||||||
|
statePath := commandFlags.String("state", "", "Ablageort des Agent-Zustands")
|
||||||
|
heartbeatSeconds := commandFlags.Int("heartbeat-seconds", 0, "Abstand der Lebendmeldungen in Sekunden")
|
||||||
|
|
||||||
|
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
stateFilePath := resolveStatePath(*statePath)
|
||||||
|
|
||||||
|
loadedState, loadError := readAgentState(stateFilePath)
|
||||||
|
if loadError != nil {
|
||||||
|
return fmt.Errorf("der agent ist nicht registriert (%s): %w. "+
|
||||||
|
"Bitte zuerst 'syncova-agent register' ausführen", stateFilePath, loadError)
|
||||||
|
}
|
||||||
|
|
||||||
|
agentLogger := logging.New(os.Stdout, logging.Options{
|
||||||
|
ServiceName: serviceName, Level: "info", Format: "text",
|
||||||
|
})
|
||||||
|
|
||||||
|
agentClient := agent.NewClient(agent.ClientOptions{
|
||||||
|
ServerBaseURL: loadedState.ServerBaseURL,
|
||||||
|
AgentToken: loadedState.AgentToken,
|
||||||
|
AgentVersion: buildVersion,
|
||||||
|
})
|
||||||
|
|
||||||
|
agentRunner := agent.NewRunner(agentClient,
|
||||||
|
time.Duration(*heartbeatSeconds)*time.Second, agentLogger)
|
||||||
|
|
||||||
|
// Die Auftragsausführung wird eingeschaltet, sobald der Agent sie leisten
|
||||||
|
// kann (Phase 5).
|
||||||
|
//
|
||||||
|
// Ohne Schlüsselmaterial bleibt sie eingeschaltet, aber ein Auftrag mit
|
||||||
|
// verlangter Verschlüsselung wird abgelehnt statt unverschlüsselt
|
||||||
|
// ausgeführt: Ein Backup, das der Server für verschlüsselt hält und das es
|
||||||
|
// nicht ist, wäre eine Zusicherung ins Leere.
|
||||||
|
agentSecretStore, secretStoreError := buildSecretStoreFromEnvironment()
|
||||||
|
if secretStoreError != nil {
|
||||||
|
return fmt.Errorf("das schluesselmaterial des agenten ist unbrauchbar: %w", secretStoreError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if agentSecretStore == nil {
|
||||||
|
agentLogger.Warn("auf diesem agenten ist kein schluesselmaterial eingerichtet; " +
|
||||||
|
"auftraege mit verschluesselung werden abgelehnt")
|
||||||
|
}
|
||||||
|
|
||||||
|
agentRunner.EnableTaskExecution(
|
||||||
|
agent.NewTaskExecutor(agentSecretStore, agentClient, agentLogger), 0)
|
||||||
|
|
||||||
|
// SIGINT und SIGTERM beenden den Agent geordnet. Der Windows-Dienst nutzt
|
||||||
|
// denselben Weg über seinen eigenen Abbruchkanal.
|
||||||
|
runContext, stopSignalListener := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stopSignalListener()
|
||||||
|
|
||||||
|
return runUnderServiceManager(runContext, agentRunner, agentLogger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDiscover zeigt die zu sichernden Dateien.
|
||||||
|
//
|
||||||
|
// Das Kommando dient der Kontrolle vor einem Backup: es beantwortet die Frage,
|
||||||
|
// was tatsächlich erfasst würde — einschließlich der Probleme.
|
||||||
|
func runDiscover(commandArguments []string) error {
|
||||||
|
commandFlags := flag.NewFlagSet("discover", flag.ContinueOnError)
|
||||||
|
sourcePath := commandFlags.String("path", "", "Zu erfassendes Verzeichnis")
|
||||||
|
excludeList := commandFlags.String("exclude", "", "Auszuschließende Muster, kommasepariert")
|
||||||
|
includeList := commandFlags.String("include", "", "Einzuschließende Muster, kommasepariert")
|
||||||
|
showFiles := commandFlags.Bool("list", false, "Alle erfassten Dateien auflisten")
|
||||||
|
|
||||||
|
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if *sourcePath == "" {
|
||||||
|
return errors.New("--path ist erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveryResult, discoveryError := agent.Discover(*sourcePath, agent.DiscoveryOptions{
|
||||||
|
IncludePatterns: splitPatternList(*includeList),
|
||||||
|
ExcludePatterns: splitPatternList(*excludeList),
|
||||||
|
})
|
||||||
|
|
||||||
|
if discoveryError != nil {
|
||||||
|
return discoveryError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" Dateien: %d\n", discoveryResult.FileCount())
|
||||||
|
fmt.Printf(" Gesamtgröße: %s\n", formatBytes(discoveryResult.TotalBytes))
|
||||||
|
fmt.Printf(" Übergangen: %d (durch Muster)\n", discoveryResult.SkippedByPattern)
|
||||||
|
|
||||||
|
if *showFiles {
|
||||||
|
fmt.Println()
|
||||||
|
outputTable := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(outputTable, "GRÖSSE\tRECHTE\tPFAD")
|
||||||
|
|
||||||
|
for _, discoveredEntry := range discoveryResult.Entries {
|
||||||
|
fmt.Fprintf(outputTable, "%s\t%s\t%s\n",
|
||||||
|
formatBytes(discoveredEntry.SizeBytes), discoveredEntry.Mode, discoveredEntry.RelativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = outputTable.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probleme werden ausdrücklich benannt: ein Backup mit übergangenen Dateien
|
||||||
|
// ist ein Teilfehler, kein Erfolg (PROMPT.md §140).
|
||||||
|
if discoveryResult.HasProblems() {
|
||||||
|
fmt.Printf("\n %d Probleme (davon %d Rechtefehler):\n",
|
||||||
|
len(discoveryResult.Problems), discoveryResult.PermissionProblemCount())
|
||||||
|
|
||||||
|
for problemIndex, discoveryProblem := range discoveryResult.Problems {
|
||||||
|
if problemIndex >= 10 {
|
||||||
|
fmt.Printf(" ... und %d weitere\n", len(discoveryResult.Problems)-10)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" %s\n %s\n", discoveryProblem.Path, discoveryProblem.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nEin Backup dieser Auswahl wäre unvollständig.")
|
||||||
|
|
||||||
|
return errors.New("die erfassung meldete probleme")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveStatePath ermittelt den Ablageort des Agent-Zustands.
|
||||||
|
func resolveStatePath(providedPath string) string {
|
||||||
|
if providedPath != "" {
|
||||||
|
return providedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(defaultStateDirectory(), agentStateFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeAgentState legt den Agent-Zustand ab.
|
||||||
|
func writeAgentState(stateFilePath string, stateToWrite agentState) error {
|
||||||
|
encodedState, marshalError := json.MarshalIndent(stateToWrite, "", " ")
|
||||||
|
if marshalError != nil {
|
||||||
|
return fmt.Errorf("der agent-zustand konnte nicht erzeugt werden: %w", marshalError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Datei enthält das Betriebstoken und erhält deshalb die engstmöglichen
|
||||||
|
// Rechte: nur der Eigentümer darf lesen.
|
||||||
|
if writeError := os.WriteFile(stateFilePath, append(encodedState, '\n'), 0o600); writeError != nil {
|
||||||
|
return fmt.Errorf("der agent-zustand konnte nicht abgelegt werden: %w", writeError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readAgentState liest den Agent-Zustand.
|
||||||
|
func readAgentState(stateFilePath string) (agentState, error) {
|
||||||
|
rawState, readError := os.ReadFile(stateFilePath)
|
||||||
|
if readError != nil {
|
||||||
|
return agentState{}, readError
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadedState agentState
|
||||||
|
if unmarshalError := json.Unmarshal(rawState, &loadedState); unmarshalError != nil {
|
||||||
|
return agentState{}, fmt.Errorf("der agent-zustand ist unlesbar: %w", unmarshalError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loadedState.AgentToken == "" || loadedState.ServerBaseURL == "" {
|
||||||
|
return agentState{}, errors.New("dem agent-zustand fehlen token oder serveradresse")
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadedState, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitPatternList zerlegt eine kommaseparierte Musterliste.
|
||||||
|
func splitPatternList(patternList string) []string {
|
||||||
|
if strings.TrimSpace(patternList) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var patterns []string
|
||||||
|
for _, rawPattern := range strings.Split(patternList, ",") {
|
||||||
|
trimmedPattern := strings.TrimSpace(rawPattern)
|
||||||
|
if trimmedPattern != "" {
|
||||||
|
patterns = append(patterns, trimmedPattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return patterns
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatBytes stellt eine Bytezahl lesbar dar.
|
||||||
|
func formatBytes(byteCount int64) string {
|
||||||
|
const unitStep = 1024
|
||||||
|
|
||||||
|
if byteCount < unitStep {
|
||||||
|
return fmt.Sprintf("%d B", byteCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentValue := float64(byteCount)
|
||||||
|
for _, unitName := range []string{"KiB", "MiB", "GiB", "TiB"} {
|
||||||
|
currentValue /= unitStep
|
||||||
|
|
||||||
|
if currentValue < unitStep {
|
||||||
|
return fmt.Sprintf("%.1f %s", currentValue, unitName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%.1f PiB", currentValue/unitStep)
|
||||||
|
}
|
||||||
21
apps/agent/cmd/syncova-agent/service_unix.go
Normal file
21
apps/agent/cmd/syncova-agent/service_unix.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runUnderServiceManager startet den Agent unter einem Unix-Dienstverwalter.
|
||||||
|
//
|
||||||
|
// Unter systemd braucht es keine besondere Anbindung: der Dienst läuft im
|
||||||
|
// Vordergrund und wird über SIGTERM beendet, was der aufrufende Code bereits
|
||||||
|
// behandelt. Diese Umsetzung ist auf macOS und Linux getestet.
|
||||||
|
//
|
||||||
|
// Eine Beispiel-Unit liegt unter deployment/syncova-agent.service.
|
||||||
|
func runUnderServiceManager(runContext context.Context, agentRunner *agent.Runner, agentLogger *slog.Logger) error {
|
||||||
|
return agentRunner.Run(runContext)
|
||||||
|
}
|
||||||
159
apps/agent/cmd/syncova-agent/service_windows.go
Normal file
159
apps/agent/cmd/syncova-agent/service_windows.go
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/agent"
|
||||||
|
"golang.org/x/sys/windows/svc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serviceName ist der Name des Windows-Dienstes.
|
||||||
|
//
|
||||||
|
// Er muss mit dem Namen übereinstimmen, unter dem der Dienst registriert wurde
|
||||||
|
// (`sc.exe create Syncova...`). Weicht er ab, meldet sich der Prozess beim
|
||||||
|
// Dienstverwalter nicht an, und Windows beendet ihn nach der Startfrist.
|
||||||
|
const windowsServiceName = "SyncovaAgent"
|
||||||
|
|
||||||
|
// serviceStopTimeout begrenzt das geordnete Beenden.
|
||||||
|
//
|
||||||
|
// Windows räumt einem Dienst standardmäßig etwa 30 Sekunden ein, bevor es ihn
|
||||||
|
// hart beendet. Der Agent meldet deshalb frühzeitig „wird beendet" und bricht
|
||||||
|
// eine laufende Sicherung ab, statt sie zu Ende zu führen: Ein Neustart, der am
|
||||||
|
// längsten Backup hängt, ist schlimmer als ein abgebrochener Lauf — und ein
|
||||||
|
// abgebrochener hinterlässt dank des Commit-Protokolls kein sichtbares Backup.
|
||||||
|
const serviceStopTimeout = 20 * time.Second
|
||||||
|
|
||||||
|
// runUnderServiceManager startet den Agent unter Windows.
|
||||||
|
//
|
||||||
|
// **Ungeprüfter Code.** Diese Umsetzung wurde auf einem macOS-System
|
||||||
|
// geschrieben. Sie übersetzt für Windows (`make cross-build` prüft das bei jedem
|
||||||
|
// Lauf), wurde dort aber nie ausgeführt. Vor einem produktiven Einsatz ist auf
|
||||||
|
// einem Windows-System zu prüfen:
|
||||||
|
//
|
||||||
|
// - die Anmeldung am Dienstverwalter und der Startzeitpunkt
|
||||||
|
// - das Verhalten bei „Dienst beenden" und beim Systemneustart
|
||||||
|
// - die Rechte des Dienstkontos beim Lesen der zu sichernden Dateien
|
||||||
|
//
|
||||||
|
// Läuft der Prozess nicht unter dem Dienstverwalter — etwa beim Aufruf von
|
||||||
|
// Hand —, arbeitet er im Vordergrund weiter. Das ist keine Notlösung, sondern
|
||||||
|
// der übliche Weg: So lässt sich derselbe Aufruf für einen Probelauf und für
|
||||||
|
// den Dienstbetrieb verwenden.
|
||||||
|
func runUnderServiceManager(runContext context.Context, agentRunner *agent.Runner, agentLogger *slog.Logger) error {
|
||||||
|
isWindowsService, detectionError := svc.IsWindowsService()
|
||||||
|
if detectionError != nil {
|
||||||
|
// Die Erkennung schlägt fehl — das ist kein Grund, den Agenten nicht zu
|
||||||
|
// starten. Er läuft dann im Vordergrund, und die Meldung sagt warum.
|
||||||
|
agentLogger.Warn("die dienstumgebung liess sich nicht bestimmen; der agent laeuft im vordergrund",
|
||||||
|
slog.String("grund", detectionError.Error()))
|
||||||
|
|
||||||
|
return agentRunner.Run(runContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isWindowsService {
|
||||||
|
agentLogger.Info("der agent laeuft im vordergrund (kein dienstkontext)")
|
||||||
|
|
||||||
|
return agentRunner.Run(runContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
agentLogger.Info("der agent meldet sich am dienstverwalter an",
|
||||||
|
slog.String("dienst", windowsServiceName))
|
||||||
|
|
||||||
|
return svc.Run(windowsServiceName, &windowsServiceHandler{
|
||||||
|
runContext: runContext,
|
||||||
|
agentRunner: agentRunner,
|
||||||
|
agentLogger: agentLogger,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// windowsServiceHandler verbindet den Agenten mit dem Dienstverwalter.
|
||||||
|
type windowsServiceHandler struct {
|
||||||
|
// runContext bricht den Agenten von außen ab.
|
||||||
|
runContext context.Context
|
||||||
|
// agentRunner ist die Betriebsschleife.
|
||||||
|
agentRunner *agent.Runner
|
||||||
|
// agentLogger protokolliert den Verlauf.
|
||||||
|
agentLogger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute bedient den Dienstverwalter.
|
||||||
|
//
|
||||||
|
// Der Ablauf ist von Windows vorgegeben: erst „startet", dann „läuft" melden,
|
||||||
|
// danach auf Steuerbefehle warten. Wer den Zustand „läuft" nicht innerhalb der
|
||||||
|
// Startfrist meldet, wird vom Dienstverwalter beendet — mit einer Meldung, die
|
||||||
|
// wie ein Absturz aussieht.
|
||||||
|
func (handler *windowsServiceHandler) Execute(serviceArguments []string,
|
||||||
|
changeRequests <-chan svc.ChangeRequest, statusUpdates chan<- svc.Status) (bool, uint32) {
|
||||||
|
const acceptedCommands = svc.AcceptStop | svc.AcceptShutdown
|
||||||
|
|
||||||
|
statusUpdates <- svc.Status{State: svc.StartPending}
|
||||||
|
|
||||||
|
// Der Agent läuft in einem eigenen Ablauf; dieser hier muss für den
|
||||||
|
// Dienstverwalter ansprechbar bleiben.
|
||||||
|
serviceContext, stopAgent := context.WithCancel(handler.runContext)
|
||||||
|
defer stopAgent()
|
||||||
|
|
||||||
|
agentFinished := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
agentFinished <- handler.agentRunner.Run(serviceContext)
|
||||||
|
}()
|
||||||
|
|
||||||
|
statusUpdates <- svc.Status{State: svc.Running, Accepts: acceptedCommands}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case runError := <-agentFinished:
|
||||||
|
// Der Agent hat von sich aus geendet — etwa weil sein Token
|
||||||
|
// abgelehnt wurde. Das ist ein Dienstfehler, kein geordnetes Ende.
|
||||||
|
if runError != nil {
|
||||||
|
handler.agentLogger.Error("der agent hat sich beendet",
|
||||||
|
slog.String("grund", runError.Error()))
|
||||||
|
|
||||||
|
statusUpdates <- svc.Status{State: svc.Stopped}
|
||||||
|
|
||||||
|
// Ein von null verschiedener Code lässt Windows den Dienst als
|
||||||
|
// fehlerhaft führen — und, je nach Einstellung, neu starten.
|
||||||
|
return false, 1
|
||||||
|
}
|
||||||
|
|
||||||
|
statusUpdates <- svc.Status{State: svc.Stopped}
|
||||||
|
|
||||||
|
return false, 0
|
||||||
|
|
||||||
|
case changeRequest := <-changeRequests:
|
||||||
|
switch changeRequest.Cmd {
|
||||||
|
case svc.Interrogate:
|
||||||
|
// Der Dienstverwalter fragt den Zustand ab. Die Antwort ist
|
||||||
|
// der unveränderte aktuelle Zustand.
|
||||||
|
statusUpdates <- changeRequest.CurrentStatus
|
||||||
|
|
||||||
|
case svc.Stop, svc.Shutdown:
|
||||||
|
handler.agentLogger.Info("der dienstverwalter beendet den agenten")
|
||||||
|
|
||||||
|
statusUpdates <- svc.Status{State: svc.StopPending}
|
||||||
|
stopAgent()
|
||||||
|
|
||||||
|
// Auf das Ende warten, aber nicht endlos: Windows beendet den
|
||||||
|
// Prozess sonst hart, und ein hart beendeter Dienst erscheint
|
||||||
|
// im Ereignisprotokoll als Absturz.
|
||||||
|
select {
|
||||||
|
case <-agentFinished:
|
||||||
|
case <-time.After(serviceStopTimeout):
|
||||||
|
handler.agentLogger.Warn("der agent endete nicht innerhalb der frist")
|
||||||
|
}
|
||||||
|
|
||||||
|
statusUpdates <- svc.Status{State: svc.Stopped}
|
||||||
|
|
||||||
|
return false, 0
|
||||||
|
|
||||||
|
default:
|
||||||
|
handler.agentLogger.Warn("unerwarteter steuerbefehl des dienstverwalters",
|
||||||
|
slog.Int("befehl", int(changeRequest.Cmd)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
353
apps/api/cmd/syncova-admin/main.go
Normal file
353
apps/api/cmd/syncova-admin/main.go
Normal file
@ -0,0 +1,353 @@
|
|||||||
|
// Command syncova-admin richtet den ersten Administrator ein und erzeugt Schlüssel.
|
||||||
|
//
|
||||||
|
// Die Erstinbetriebnahme läuft bewusst über ein Kommando auf dem Server und
|
||||||
|
// nicht über die Weboberfläche: ein vorkonfiguriertes Standardkonto wäre eine
|
||||||
|
// bekannte Schwachstelle jeder Installation (PROMPT.md §120, §122).
|
||||||
|
//
|
||||||
|
// Aufruf:
|
||||||
|
//
|
||||||
|
// syncova-admin create-admin --username <name> [--email <adresse>]
|
||||||
|
// syncova-admin generate-key
|
||||||
|
// syncova-admin reset-password --username <name>
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/audit"
|
||||||
|
"github.com/syncova/syncova/packages/auth"
|
||||||
|
"github.com/syncova/syncova/packages/platform/config"
|
||||||
|
"github.com/syncova/syncova/packages/platform/crypto"
|
||||||
|
"github.com/syncova/syncova/packages/platform/database"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serviceName benennt das Kommando in den Logs.
|
||||||
|
const serviceName = "syncova-admin"
|
||||||
|
|
||||||
|
// commandTimeout begrenzt die Laufzeit einer Datenbankoperation.
|
||||||
|
const commandTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// buildVersion wird beim Bauen über -ldflags gesetzt.
|
||||||
|
//
|
||||||
|
// Der Vorgabewert gilt nur für einen Bau von Hand; das Auslieferungspaket
|
||||||
|
// brennt die tatsächliche Fassung ein.
|
||||||
|
var buildVersion = "0.1.0-dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if runError := run(); runError != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %v\n", serviceName, runError)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run wertet das Unterkommando aus.
|
||||||
|
func run() error {
|
||||||
|
// Die Versionsabfrage steht vor dem Laden der Konfiguration: Wer wissen
|
||||||
|
// will, welche Fassung auf einem Server liegt, hat in dem Moment womöglich
|
||||||
|
// keine Datenbank — etwa auf einem frisch ausgepackten Paket oder mitten in
|
||||||
|
// einer Störung.
|
||||||
|
if len(os.Args) > 1 && isVersionArgument(os.Args[1]) {
|
||||||
|
fmt.Printf("%s %s\n", serviceName, buildVersion)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
return printUsage()
|
||||||
|
}
|
||||||
|
|
||||||
|
switch requestedCommand := os.Args[1]; requestedCommand {
|
||||||
|
case "generate-key":
|
||||||
|
// Die Schlüsselerzeugung braucht weder Konfiguration noch Datenbank.
|
||||||
|
return runGenerateKey()
|
||||||
|
|
||||||
|
case "create-admin":
|
||||||
|
return runCreateAdmin(os.Args[2:])
|
||||||
|
|
||||||
|
case "reset-password":
|
||||||
|
return runResetPassword(os.Args[2:])
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unbekanntes Kommando %q\n\n%s", requestedCommand, usageText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// usageText beschreibt die Verwendung des Kommandos.
|
||||||
|
func usageText() string {
|
||||||
|
return `Verwendung:
|
||||||
|
syncova-admin generate-key Erzeugt einen neuen Verschlüsselungsschlüssel
|
||||||
|
syncova-admin create-admin --username <name> Legt den ersten Administrator an
|
||||||
|
syncova-admin reset-password --username <name> Setzt ein Passwort zurück`
|
||||||
|
}
|
||||||
|
|
||||||
|
// printUsage gibt die Verwendung aus.
|
||||||
|
func printUsage() error {
|
||||||
|
return errors.New(usageText())
|
||||||
|
}
|
||||||
|
|
||||||
|
// runGenerateKey erzeugt einen Verschlüsselungsschlüssel.
|
||||||
|
func runGenerateKey() error {
|
||||||
|
generatedKey, keyError := crypto.GenerateMasterKey()
|
||||||
|
if keyError != nil {
|
||||||
|
return keyError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Ein neuer Verschlüsselungsschlüssel wurde erzeugt.")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("SYNCOVA_ENCRYPTION_KEYS=v1:%s\n", generatedKey)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Wichtig: Ohne diesen Schlüssel sind verschlüsselte Daten (z. B. MFA-Secrets)")
|
||||||
|
fmt.Println("dauerhaft unlesbar. Er gehört sicher verwahrt und darf nicht verloren gehen.")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminEnvironment bündelt die für Datenbankkommandos nötigen Bestandteile.
|
||||||
|
type adminEnvironment struct {
|
||||||
|
// authService ist die Domänenlogik der Identitätsverwaltung.
|
||||||
|
authService *auth.Service
|
||||||
|
// repository ist die Datenzugriffsschicht.
|
||||||
|
repository *auth.Repository
|
||||||
|
// databasePool ist der Verbindungspool; er muss geschlossen werden.
|
||||||
|
databasePool *database.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAdminEnvironment lädt Konfiguration und baut die Dienste auf.
|
||||||
|
func buildAdminEnvironment(setupContext context.Context) (*adminEnvironment, error) {
|
||||||
|
serviceConfig, configError := config.Load(serviceName)
|
||||||
|
if configError != nil {
|
||||||
|
return nil, configError
|
||||||
|
}
|
||||||
|
|
||||||
|
commandLogger := logging.New(os.Stderr, logging.Options{
|
||||||
|
ServiceName: serviceConfig.ServiceName,
|
||||||
|
Level: serviceConfig.Logging.Level,
|
||||||
|
Format: serviceConfig.Logging.Format,
|
||||||
|
})
|
||||||
|
|
||||||
|
databasePool, databaseError := database.Connect(setupContext, serviceConfig.Database, commandLogger)
|
||||||
|
if databaseError != nil {
|
||||||
|
return nil, databaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
secretStore, secretStoreError := crypto.NewLocalSecretStore(
|
||||||
|
serviceConfig.Encryption.Keys(), serviceConfig.Encryption.CurrentKeyVersion)
|
||||||
|
if secretStoreError != nil {
|
||||||
|
databasePool.Close()
|
||||||
|
return nil, fmt.Errorf("die verschlüsselung konnte nicht eingerichtet werden: %w", secretStoreError)
|
||||||
|
}
|
||||||
|
|
||||||
|
auditRecorder := audit.NewPostgresRecorder(databasePool.Connections(), commandLogger)
|
||||||
|
authRepository := auth.NewRepository(databasePool.Connections())
|
||||||
|
|
||||||
|
return &adminEnvironment{
|
||||||
|
authService: auth.NewService(authRepository, secretStore, auditRecorder, serviceConfig.Auth, commandLogger),
|
||||||
|
repository: authRepository,
|
||||||
|
databasePool: databasePool,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCreateAdmin legt den ersten Administrator an.
|
||||||
|
func runCreateAdmin(commandArguments []string) error {
|
||||||
|
commandFlags := flag.NewFlagSet("create-admin", flag.ContinueOnError)
|
||||||
|
username := commandFlags.String("username", "", "Anmeldename des Administrators")
|
||||||
|
email := commandFlags.String("email", "", "Mailadresse (optional)")
|
||||||
|
|
||||||
|
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if *username == "" {
|
||||||
|
return errors.New("--username ist erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
commandContext, cancelCommandContext := context.WithTimeout(context.Background(), commandTimeout)
|
||||||
|
defer cancelCommandContext()
|
||||||
|
|
||||||
|
adminEnvironment, environmentError := buildAdminEnvironment(commandContext)
|
||||||
|
if environmentError != nil {
|
||||||
|
return environmentError
|
||||||
|
}
|
||||||
|
defer adminEnvironment.databasePool.Close()
|
||||||
|
|
||||||
|
// Ein zweiter Administrator wird über die Oberfläche angelegt. Dieses
|
||||||
|
// Kommando dient ausschließlich der Erstinbetriebnahme.
|
||||||
|
existingAdministrators, countError := adminEnvironment.repository.CountAdministrators(commandContext, nil)
|
||||||
|
if countError != nil {
|
||||||
|
return countError
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingAdministrators > 0 {
|
||||||
|
return fmt.Errorf("es existieren bereits %d Administratoren. "+
|
||||||
|
"Weitere Benutzer werden über die Oberfläche oder die API angelegt", existingAdministrators)
|
||||||
|
}
|
||||||
|
|
||||||
|
password, passwordError := readPasswordTwice()
|
||||||
|
if passwordError != nil {
|
||||||
|
return passwordError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Anlegen erfolgt im Namen des Systems: es gibt noch keinen handelnden Benutzer.
|
||||||
|
systemActor := auth.User{Username: "system (erstinbetriebnahme)"}
|
||||||
|
|
||||||
|
createdUser, createError := adminEnvironment.authService.CreateUser(commandContext, auth.CreateUserRequest{
|
||||||
|
Username: *username,
|
||||||
|
Email: *email,
|
||||||
|
Password: password,
|
||||||
|
RoleNames: []string{"super_administrator"},
|
||||||
|
}, systemActor, auth.RequestContext{IPAddress: "", UserAgent: serviceName})
|
||||||
|
|
||||||
|
if createError != nil {
|
||||||
|
return createError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("Der Administrator %q wurde angelegt.\n", createdUser.Username)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Nächste Schritte:")
|
||||||
|
fmt.Println(" 1. An der Weboberfläche anmelden.")
|
||||||
|
fmt.Println(" 2. Unter Sicherheit einen zweiten Faktor einrichten (dringend empfohlen).")
|
||||||
|
fmt.Println(" 3. Weitere Benutzer mit passenden Rollen anlegen.")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runResetPassword setzt das Passwort eines Benutzers zurück.
|
||||||
|
//
|
||||||
|
// Der Weg dient dem Fall, dass sich niemand mehr anmelden kann.
|
||||||
|
func runResetPassword(commandArguments []string) error {
|
||||||
|
commandFlags := flag.NewFlagSet("reset-password", flag.ContinueOnError)
|
||||||
|
username := commandFlags.String("username", "", "Anmeldename des Benutzers")
|
||||||
|
|
||||||
|
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if *username == "" {
|
||||||
|
return errors.New("--username ist erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
commandContext, cancelCommandContext := context.WithTimeout(context.Background(), commandTimeout)
|
||||||
|
defer cancelCommandContext()
|
||||||
|
|
||||||
|
adminEnvironment, environmentError := buildAdminEnvironment(commandContext)
|
||||||
|
if environmentError != nil {
|
||||||
|
return environmentError
|
||||||
|
}
|
||||||
|
defer adminEnvironment.databasePool.Close()
|
||||||
|
|
||||||
|
targetUser, lookupError := adminEnvironment.repository.FindUserByUsername(commandContext, *username)
|
||||||
|
if lookupError != nil {
|
||||||
|
return lookupError
|
||||||
|
}
|
||||||
|
|
||||||
|
password, passwordError := readPasswordTwice()
|
||||||
|
if passwordError != nil {
|
||||||
|
return passwordError
|
||||||
|
}
|
||||||
|
|
||||||
|
systemActor := auth.User{Username: "system (kommandozeile)"}
|
||||||
|
|
||||||
|
if _, updateError := adminEnvironment.authService.UpdateUser(commandContext, targetUser.ID, auth.UpdateUserRequest{
|
||||||
|
Password: &password,
|
||||||
|
// Ein gesperrtes Konto wird beim Zurücksetzen wieder freigegeben.
|
||||||
|
Status: pointerTo(auth.UserStatusActive),
|
||||||
|
}, systemActor, auth.RequestContext{UserAgent: serviceName}); updateError != nil {
|
||||||
|
return updateError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("Das Passwort von %q wurde geändert.\n", targetUser.Username)
|
||||||
|
fmt.Println("Alle bestehenden Sitzungen dieses Kontos wurden beendet.")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPasswordTwice liest ein Passwort zweimal von der Konsole.
|
||||||
|
//
|
||||||
|
// Die Eingabe erfolgt verdeckt, damit das Passwort weder auf dem Bildschirm
|
||||||
|
// noch in der Shell-Historie erscheint.
|
||||||
|
func readPasswordTwice() (string, error) {
|
||||||
|
fmt.Print("Passwort: ")
|
||||||
|
firstEntry, firstError := readHiddenInput()
|
||||||
|
if firstError != nil {
|
||||||
|
return "", firstError
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Die Stärke wird vor der Wiederholung geprüft, damit ein zu schwaches
|
||||||
|
// Passwort nicht zweimal eingegeben werden muss.
|
||||||
|
if strengthError := auth.ValidatePasswordStrength(firstEntry); strengthError != nil {
|
||||||
|
return "", strengthError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Print("Passwort wiederholen: ")
|
||||||
|
secondEntry, secondError := readHiddenInput()
|
||||||
|
if secondError != nil {
|
||||||
|
return "", secondError
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if firstEntry != secondEntry {
|
||||||
|
return "", errors.New("die beiden Eingaben stimmen nicht überein")
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstEntry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// standardInputReader liest die Standardeingabe außerhalb eines Terminals.
|
||||||
|
//
|
||||||
|
// Der Reader ist paketweit, weil ein gepufferter Reader mehr Daten aus der
|
||||||
|
// Standardeingabe zieht als die angeforderte Zeile. Ein zweiter Reader fände
|
||||||
|
// die bereits gepufferten Zeilen nicht mehr vor und liefe sofort auf EOF —
|
||||||
|
// die Abfrage der Passwortwiederholung schlüge in jedem Skript fehl.
|
||||||
|
var standardInputReader *bufio.Reader
|
||||||
|
|
||||||
|
// readHiddenInput liest eine Zeile ohne Bildschirmausgabe.
|
||||||
|
func readHiddenInput() (string, error) {
|
||||||
|
// Bei einem Terminal wird die Eingabe verdeckt gelesen.
|
||||||
|
if term.IsTerminal(int(syscall.Stdin)) {
|
||||||
|
enteredBytes, readError := term.ReadPassword(int(syscall.Stdin))
|
||||||
|
if readError != nil {
|
||||||
|
return "", fmt.Errorf("die eingabe konnte nicht gelesen werden: %w", readError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(enteredBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne Terminal (etwa in einem Skript) wird von der Standardeingabe gelesen.
|
||||||
|
if standardInputReader == nil {
|
||||||
|
standardInputReader = bufio.NewReader(os.Stdin)
|
||||||
|
}
|
||||||
|
|
||||||
|
enteredLine, readError := standardInputReader.ReadString('\n')
|
||||||
|
if readError != nil && enteredLine == "" {
|
||||||
|
return "", fmt.Errorf("die eingabe konnte nicht gelesen werden: %w", readError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimRight(enteredLine, "\r\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pointerTo liefert einen Zeiger auf den übergebenen Wert.
|
||||||
|
func pointerTo[ValueType any](value ValueType) *ValueType {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
// isVersionArgument erkennt eine Versionsabfrage.
|
||||||
|
//
|
||||||
|
// Drei Schreibweisen, weil sich niemand merkt, welche ein bestimmtes Programm
|
||||||
|
// erwartet — und weil eine Fehlermeldung auf "--version" der denkbar
|
||||||
|
// schlechteste erste Eindruck ist.
|
||||||
|
func isVersionArgument(argument string) bool {
|
||||||
|
return argument == "version" || argument == "--version" || argument == "-version"
|
||||||
|
}
|
||||||
379
apps/api/cmd/syncova-api/main.go
Normal file
379
apps/api/cmd/syncova-api/main.go
Normal file
@ -0,0 +1,379 @@
|
|||||||
|
// Command syncova-api startet den Control-Plane-API-Dienst von Syncova.
|
||||||
|
//
|
||||||
|
// Der Dienst stellt die REST-Schnittstelle laut SYNCOVA_API.md bereit. Er
|
||||||
|
// verändert das Datenbankschema niemals selbst — Migrationen laufen ausschließlich
|
||||||
|
// über das Kommando syncova-migrate (SYNCOVA_DATABASE.md §18).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/apps/api/internal/httpapi"
|
||||||
|
"github.com/syncova/syncova/migrations"
|
||||||
|
"github.com/syncova/syncova/packages/agentregistry"
|
||||||
|
"github.com/syncova/syncova/packages/agenttasks"
|
||||||
|
"github.com/syncova/syncova/packages/alerting"
|
||||||
|
"github.com/syncova/syncova/packages/audit"
|
||||||
|
"github.com/syncova/syncova/packages/auth"
|
||||||
|
"github.com/syncova/syncova/packages/backupexecutor"
|
||||||
|
"github.com/syncova/syncova/packages/hypervisor"
|
||||||
|
"github.com/syncova/syncova/packages/jobs"
|
||||||
|
"github.com/syncova/syncova/packages/metrics"
|
||||||
|
"github.com/syncova/syncova/packages/platform/config"
|
||||||
|
"github.com/syncova/syncova/packages/platform/crypto"
|
||||||
|
"github.com/syncova/syncova/packages/platform/database"
|
||||||
|
"github.com/syncova/syncova/packages/platform/health"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
"github.com/syncova/syncova/packages/ransomware"
|
||||||
|
"github.com/syncova/syncova/packages/recovery"
|
||||||
|
"github.com/syncova/syncova/packages/reports"
|
||||||
|
"github.com/syncova/syncova/packages/repository"
|
||||||
|
"github.com/syncova/syncova/packages/retention"
|
||||||
|
"github.com/syncova/syncova/packages/security"
|
||||||
|
"github.com/syncova/syncova/packages/verification"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serviceName benennt den Dienst in Logs und Metriken.
|
||||||
|
const serviceName = "syncova-api"
|
||||||
|
|
||||||
|
// buildVersion wird beim Bauen über -ldflags gesetzt.
|
||||||
|
// Der Standardwert kennzeichnet einen Build außerhalb der Release-Pipeline.
|
||||||
|
var buildVersion = "0.1.0-dev"
|
||||||
|
|
||||||
|
// databaseStartupTimeout ist die Frist, in der die Datenbank beim Start erreichbar sein muss.
|
||||||
|
const databaseStartupTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// healthCheckTimeout begrenzt jede einzelne Komponentenprüfung.
|
||||||
|
const healthCheckTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Die Versionsabfrage steht **vor** allem anderen.
|
||||||
|
//
|
||||||
|
// Ein Betreiber, der wissen will, welche Fassung auf einem Server liegt,
|
||||||
|
// hat in dem Moment womöglich keine Datenbank und keine Konfiguration —
|
||||||
|
// etwa auf einem frisch ausgepackten Paket oder mitten in einer Störung.
|
||||||
|
// Eine Antwort, die erst nach vollständiger Konfiguration käme, wäre genau
|
||||||
|
// dann nicht zu bekommen, wenn man sie braucht.
|
||||||
|
if len(os.Args) > 1 && isVersionArgument(os.Args[1]) {
|
||||||
|
fmt.Printf("syncova-api %s\n", buildVersion)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// run() kapselt die gesamte Logik, damit defer-Aufrufe vor dem Prozessende greifen.
|
||||||
|
if runError := run(); runError != nil {
|
||||||
|
// Zu diesem Zeitpunkt existiert womöglich noch kein Logger,
|
||||||
|
// deshalb geht die Meldung direkt nach stderr.
|
||||||
|
fmt.Fprintf(os.Stderr, "syncova-api konnte nicht gestartet werden: %v\n", runError)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run startet alle Komponenten und wartet auf das Abschaltsignal.
|
||||||
|
func run() error {
|
||||||
|
serviceConfig, configError := config.Load(serviceName)
|
||||||
|
if configError != nil {
|
||||||
|
return configError
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceLogger := logging.New(os.Stdout, logging.Options{
|
||||||
|
ServiceName: serviceConfig.ServiceName,
|
||||||
|
Level: serviceConfig.Logging.Level,
|
||||||
|
Format: serviceConfig.Logging.Format,
|
||||||
|
})
|
||||||
|
|
||||||
|
serviceLogger.Info("syncova-api startet",
|
||||||
|
slog.String("version", buildVersion),
|
||||||
|
slog.String("environment", string(serviceConfig.Environment)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// SIGINT und SIGTERM lösen ein geordnetes Herunterfahren aus.
|
||||||
|
shutdownContext, stopSignalListener := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stopSignalListener()
|
||||||
|
|
||||||
|
// Das Schema wird vor dem ersten Request geprüft: ein Dienst, der gegen ein
|
||||||
|
// unpassendes Schema arbeitet, könnte Daten falsch interpretieren.
|
||||||
|
if schemaError := database.VerifySchemaIsUpToDate(migrations.FS, serviceConfig.Database.ConnectionString()); schemaError != nil {
|
||||||
|
return schemaError
|
||||||
|
}
|
||||||
|
|
||||||
|
databasePool, databaseError := database.AwaitAvailable(shutdownContext, serviceConfig.Database, serviceLogger, databaseStartupTimeout)
|
||||||
|
if databaseError != nil {
|
||||||
|
return databaseError
|
||||||
|
}
|
||||||
|
defer databasePool.Close()
|
||||||
|
|
||||||
|
// Die Datenbank ist als kritisch registriert: ohne sie ist der Dienst nicht betriebsbereit.
|
||||||
|
healthRegistry := health.NewRegistry(healthCheckTimeout)
|
||||||
|
healthRegistry.Register("database", true, databasePool.HealthCheck())
|
||||||
|
|
||||||
|
// Der Secret Store verschlüsselt MFA-Secrets und später Repository-Zugangsdaten.
|
||||||
|
secretStore, secretStoreError := crypto.NewLocalSecretStore(
|
||||||
|
serviceConfig.Encryption.Keys(), serviceConfig.Encryption.CurrentKeyVersion)
|
||||||
|
if secretStoreError != nil {
|
||||||
|
return fmt.Errorf("die verschlüsselung konnte nicht eingerichtet werden: %w", secretStoreError)
|
||||||
|
}
|
||||||
|
|
||||||
|
auditRecorder := audit.NewPostgresRecorder(databasePool.Connections(), serviceLogger)
|
||||||
|
authRepository := auth.NewRepository(databasePool.Connections())
|
||||||
|
authService := auth.NewService(authRepository, secretStore, auditRecorder, serviceConfig.Auth, serviceLogger)
|
||||||
|
|
||||||
|
agentStore := agentregistry.NewPostgresStore(databasePool.Connections())
|
||||||
|
agentService := agentregistry.NewService(agentStore, auditRecorder, serviceLogger)
|
||||||
|
|
||||||
|
jobStore := jobs.NewPostgresStore(databasePool.Connections())
|
||||||
|
restoreStore := recovery.NewStore(databasePool.Connections())
|
||||||
|
verificationStore := verification.NewStore(databasePool.Connections())
|
||||||
|
retentionStore := retention.NewStore(databasePool.Connections())
|
||||||
|
metricsStore := metrics.NewStore(databasePool.Connections())
|
||||||
|
alertStore := alerting.NewStore(databasePool.Connections())
|
||||||
|
securityInspector := security.NewInspector(databasePool.Connections(), buildVersion)
|
||||||
|
agentTaskStore := agenttasks.NewStore(databasePool.Connections())
|
||||||
|
|
||||||
|
// Die Virtualisierungsumgebungen brauchen denselben Schlüsselspeicher: Ihre
|
||||||
|
// API-Tokens und SSH-Schlüssel gehören nie im Klartext in die Datenbank.
|
||||||
|
hypervisorStore, hypervisorStoreError := hypervisor.NewStore(databasePool.Connections(), secretStore)
|
||||||
|
if hypervisorStoreError != nil {
|
||||||
|
return fmt.Errorf("die virtualisierungsverwaltung konnte nicht eingerichtet werden: %w", hypervisorStoreError)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiRouter := httpapi.NewRouter(httpapi.RouterDependencies{
|
||||||
|
Config: serviceConfig,
|
||||||
|
Logger: serviceLogger,
|
||||||
|
HealthRegistry: healthRegistry,
|
||||||
|
BuildVersion: buildVersion,
|
||||||
|
AuthService: authService,
|
||||||
|
AuthRepository: authRepository,
|
||||||
|
AuditRecorder: auditRecorder,
|
||||||
|
AgentService: agentService,
|
||||||
|
JobStore: jobStore,
|
||||||
|
RestoreStore: restoreStore,
|
||||||
|
|
||||||
|
VerificationStore: verificationStore,
|
||||||
|
RetentionStore: retentionStore,
|
||||||
|
MetricsStore: metricsStore,
|
||||||
|
AlertStore: alertStore,
|
||||||
|
SecretStore: secretStore,
|
||||||
|
SecurityInspector: securityInspector,
|
||||||
|
// Die Auffaelligkeitsbewertung liest ausschliesslich aus der Datenbank.
|
||||||
|
RansomwareDetector: ransomware.NewDetector(databasePool.Connections()),
|
||||||
|
// Der Berichtsersteller nutzt dieselbe Sicherheitspruefung wie das
|
||||||
|
// Security Center — zwei Berechnungen derselben Zahl liefen auseinander.
|
||||||
|
ReportGenerator: reports.NewGenerator(databasePool.Connections(), securityInspector),
|
||||||
|
AgentTaskStore: agentTaskStore,
|
||||||
|
HypervisorStore: hypervisorStore,
|
||||||
|
})
|
||||||
|
|
||||||
|
apiServer, serverError := httpapi.NewServer(serviceConfig.HTTP, apiRouter, serviceLogger)
|
||||||
|
if serverError != nil {
|
||||||
|
return serverError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Dienst, der ohne Verschlüsselung lauscht, sagt das bei jedem Start.
|
||||||
|
//
|
||||||
|
// Der Betrieb hinter einem Reverse Proxy, der TLS übernimmt, ist der
|
||||||
|
// Normalfall und völlig in Ordnung — solange der Dienst dann nur lokal
|
||||||
|
// erreichbar ist. Gefährlich ist die Kombination aus fehlender
|
||||||
|
// Verschlüsselung und einer Bindung an alle Schnittstellen: Dann wandern
|
||||||
|
// Anmeldedaten im Klartext durch das Netz, ohne dass es jemandem auffällt.
|
||||||
|
if !apiServer.UsesTLS() {
|
||||||
|
if strings.HasPrefix(serviceConfig.HTTP.ListenAddress, "127.0.0.1") ||
|
||||||
|
strings.HasPrefix(serviceConfig.HTTP.ListenAddress, "localhost") ||
|
||||||
|
strings.HasPrefix(serviceConfig.HTTP.ListenAddress, "[::1]") {
|
||||||
|
serviceLogger.Info("der dienst laeuft ohne tls und ist nur lokal erreichbar",
|
||||||
|
slog.String("adresse", serviceConfig.HTTP.ListenAddress))
|
||||||
|
} else {
|
||||||
|
serviceLogger.Warn("DER DIENST LAEUFT OHNE VERSCHLUESSELUNG UND IST VON AUSSEN ERREICHBAR",
|
||||||
|
slog.String("adresse", serviceConfig.HTTP.ListenAddress),
|
||||||
|
slog.String("abhilfe", "setzen sie SYNCOVA_HTTP_TLS_CERT_FILE und "+
|
||||||
|
"SYNCOVA_HTTP_TLS_KEY_FILE, oder binden sie den dienst an 127.0.0.1 "+
|
||||||
|
"und stellen sie einen reverse proxy davor"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Ausführungsschleife läuft neben dem HTTP-Server. Sie bekommt denselben
|
||||||
|
// Abbruchkontext, damit ein SIGTERM beide beendet — zuerst die Schleife, die
|
||||||
|
// ihre laufenden Vorgänge abbricht und deren Ergebnis festschreibt.
|
||||||
|
//
|
||||||
|
// Derselbe Secret Store, der die MFA-Geheimnisse schützt, verschlüsselt auch
|
||||||
|
// die Datenschlüssel der Repositories. Ein zweiter Schlüsselsatz brächte
|
||||||
|
// keinen Sicherheitsgewinn, aber eine zweite Stelle, an der er verloren
|
||||||
|
// gehen kann.
|
||||||
|
backupExecutor, executorError := backupexecutor.New(jobStore, backupexecutor.Options{
|
||||||
|
SecretStore: secretStore,
|
||||||
|
AgentTaskStore: agentTaskStore,
|
||||||
|
HypervisorStore: hypervisorStore,
|
||||||
|
CreatedByVersion: buildVersion,
|
||||||
|
}, serviceLogger)
|
||||||
|
if executorError != nil {
|
||||||
|
return fmt.Errorf("der backup-executor konnte nicht eingerichtet werden: %w", executorError)
|
||||||
|
}
|
||||||
|
|
||||||
|
executionLoop, loopError := jobs.NewLoop(jobStore, backupExecutor, jobs.LoopOptions{
|
||||||
|
InstanceName: schedulerInstanceName(),
|
||||||
|
}, serviceLogger)
|
||||||
|
if loopError != nil {
|
||||||
|
return fmt.Errorf("die ausführungsschleife konnte nicht eingerichtet werden: %w", loopError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Schleife gibt auch verwaiste Agentenauftraege frei.
|
||||||
|
executionLoop.SetAgentTaskReclaimer(agentTaskStore)
|
||||||
|
|
||||||
|
// Die Wiederherstellung läuft in einer eigenen Schleife. Sie mit den
|
||||||
|
// Sicherungen zu vermengen wäre falsch: Eine Wiederherstellung wird nie
|
||||||
|
// automatisch wiederholt, und ihre Nebenläufigkeit ist bewusst eine andere
|
||||||
|
// — im Ernstfall zählt die Geschwindigkeit *einer* Wiederherstellung.
|
||||||
|
restoreExecutor, restoreExecutorError := recovery.NewRestoreExecutor(
|
||||||
|
recovery.NewStoreRepositoryResolver(jobStore),
|
||||||
|
recovery.ExecutorOptions{SecretStore: secretStore},
|
||||||
|
serviceLogger)
|
||||||
|
if restoreExecutorError != nil {
|
||||||
|
return fmt.Errorf("die wiederherstellung konnte nicht eingerichtet werden: %w", restoreExecutorError)
|
||||||
|
}
|
||||||
|
|
||||||
|
recoveryLoop, recoveryLoopError := recovery.NewLoop(restoreStore, restoreExecutor, recovery.LoopOptions{
|
||||||
|
InstanceName: schedulerInstanceName(),
|
||||||
|
}, serviceLogger)
|
||||||
|
if recoveryLoopError != nil {
|
||||||
|
return fmt.Errorf("die wiederherstellungsschleife konnte nicht eingerichtet werden: %w", recoveryLoopError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Prüfung läuft in einer dritten Schleife. Sie mit den Sicherungen zu
|
||||||
|
// vermengen wäre falsch: Eine Prüfung wird nie wiederholt, weil sie
|
||||||
|
// fehlschlug, und sie darf eine laufende Sicherung nicht verdrängen — beide
|
||||||
|
// lesen denselben Datenträger.
|
||||||
|
verificationRunner, verificationRunnerError := verification.NewRepositoryRunner(
|
||||||
|
recovery.NewStoreRepositoryResolver(jobStore),
|
||||||
|
verification.RunnerOptions{SecretStore: secretStore},
|
||||||
|
serviceLogger)
|
||||||
|
if verificationRunnerError != nil {
|
||||||
|
return fmt.Errorf("die prüfung konnte nicht eingerichtet werden: %w", verificationRunnerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
verificationLoop, verificationLoopError := verification.NewLoop(verificationStore, verificationRunner,
|
||||||
|
verification.LoopOptions{InstanceName: schedulerInstanceName()}, serviceLogger)
|
||||||
|
if verificationLoopError != nil {
|
||||||
|
return fmt.Errorf("die prüfschleife konnte nicht eingerichtet werden: %w", verificationLoopError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Kennzahlenerfassung hält fest, was sonst verloren geht: Die Belegung
|
||||||
|
// eines Repositorys ist eine Momentaufnahme, die beim nächsten Schreiben
|
||||||
|
// überschrieben wird. Ohne diese Schleife gäbe es keine Verlaufsreihe — und
|
||||||
|
// damit weder Wachstumskurve noch Kapazitätsprognose.
|
||||||
|
metricsCollector, collectorError := metrics.NewCollector(metricsStore, jobStore,
|
||||||
|
repository.MeasureFilesystemUsage, metrics.CollectorOptions{}, serviceLogger)
|
||||||
|
if collectorError != nil {
|
||||||
|
return fmt.Errorf("die kennzahlenerfassung konnte nicht eingerichtet werden: %w", collectorError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Sicherheitsbewertung wird bei jedem Abruf neu berechnet; erfasst wird
|
||||||
|
// hier ihr **Verlauf**. Ohne ihn liesse sich nicht sagen, ob die Lage besser
|
||||||
|
// oder schlechter geworden ist.
|
||||||
|
metricsCollector = metricsCollector.WithSecurityScoreSource(securityInspector)
|
||||||
|
|
||||||
|
// Die Meldungsauswertung schliesst den Kreis: Sie erkennt die Zustände, die
|
||||||
|
// jemand ansehen sollte, und stellt sie zu. Ohne sie bliebe jeder Ausfall
|
||||||
|
// unbemerkt, bis jemand von sich aus in die Oberfläche sieht.
|
||||||
|
alertLoop, alertLoopError := alerting.NewLoop(alertStore,
|
||||||
|
alerting.NewEvaluator(databasePool.Connections()),
|
||||||
|
alerting.NewDeliveryDispatcher(serviceConfig.Hardening.AllowInternalNotificationTargets), secretStore,
|
||||||
|
alerting.LoopOptions{NotificationsEnabled: true}, serviceLogger)
|
||||||
|
if alertLoopError != nil {
|
||||||
|
return fmt.Errorf("die meldungsauswertung konnte nicht eingerichtet werden: %w", alertLoopError)
|
||||||
|
}
|
||||||
|
|
||||||
|
var loopWaitGroup sync.WaitGroup
|
||||||
|
loopWaitGroup.Add(5)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer loopWaitGroup.Done()
|
||||||
|
|
||||||
|
if runError := executionLoop.Run(shutdownContext); runError != nil {
|
||||||
|
serviceLogger.Error("die ausführungsschleife wurde nicht sauber beendet",
|
||||||
|
slog.String("grund", runError.Error()))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer loopWaitGroup.Done()
|
||||||
|
|
||||||
|
if runError := recoveryLoop.Run(shutdownContext); runError != nil {
|
||||||
|
serviceLogger.Error("die wiederherstellungsschleife wurde nicht sauber beendet",
|
||||||
|
slog.String("grund", runError.Error()))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer loopWaitGroup.Done()
|
||||||
|
|
||||||
|
if runError := verificationLoop.Run(shutdownContext); runError != nil {
|
||||||
|
serviceLogger.Error("die prüfschleife wurde nicht sauber beendet",
|
||||||
|
slog.String("grund", runError.Error()))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer loopWaitGroup.Done()
|
||||||
|
|
||||||
|
if runError := metricsCollector.Run(shutdownContext); runError != nil {
|
||||||
|
serviceLogger.Error("die kennzahlenerfassung wurde nicht sauber beendet",
|
||||||
|
slog.String("grund", runError.Error()))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer loopWaitGroup.Done()
|
||||||
|
|
||||||
|
if runError := alertLoop.Run(shutdownContext); runError != nil {
|
||||||
|
serviceLogger.Error("die meldungsauswertung wurde nicht sauber beendet",
|
||||||
|
slog.String("grund", runError.Error()))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
serveError := apiServer.Serve(shutdownContext)
|
||||||
|
|
||||||
|
// Auf die Schleife wird immer gewartet — auch wenn der HTTP-Server mit einem
|
||||||
|
// Fehler endete. Ein Prozess, der endet, während noch ein Lauf schreibt,
|
||||||
|
// hinterliesse einen Lauf auf „running" und blockierte den Auftrag bis zum
|
||||||
|
// Ablauf der Frist für verwaiste Läufe.
|
||||||
|
loopWaitGroup.Wait()
|
||||||
|
|
||||||
|
if serveError != nil {
|
||||||
|
return fmt.Errorf("der API-Server wurde unerwartet beendet: %w", serveError)
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceLogger.Info("syncova-api beendet")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// schedulerInstanceName bildet den Namen dieses Control-Servers.
|
||||||
|
//
|
||||||
|
// Der Rechnername allein genügt nicht: Zwei Prozesse auf derselben Maschine
|
||||||
|
// wären ununterscheidbar, und nach einem Absturz liesse sich nicht sagen,
|
||||||
|
// welcher der beiden die verwaisten Läufe hielt.
|
||||||
|
func schedulerInstanceName() string {
|
||||||
|
hostName, hostError := os.Hostname()
|
||||||
|
if hostError != nil {
|
||||||
|
hostName = "unbekannt"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s-%d", hostName, os.Getpid())
|
||||||
|
}
|
||||||
|
|
||||||
|
// isVersionArgument erkennt eine Versionsabfrage.
|
||||||
|
//
|
||||||
|
// Drei Schreibweisen, weil sich niemand merkt, welche ein bestimmtes Programm
|
||||||
|
// erwartet — und weil eine Fehlermeldung auf "--version" der denkbar
|
||||||
|
// schlechteste erste Eindruck ist.
|
||||||
|
func isVersionArgument(argument string) bool {
|
||||||
|
return argument == "version" || argument == "--version" || argument == "-version"
|
||||||
|
}
|
||||||
712
apps/api/cmd/syncova-bench/main.go
Normal file
712
apps/api/cmd/syncova-bench/main.go
Normal file
@ -0,0 +1,712 @@
|
|||||||
|
// Command syncova-bench misst Durchsatz und Ressourcenverbrauch
|
||||||
|
// (SYNCOVA_IMPLEMENTATION_PLAN.md §22).
|
||||||
|
//
|
||||||
|
// Das Werkzeug misst, statt zu schätzen — und es sagt bei jeder Zahl, unter
|
||||||
|
// welchen Bedingungen sie entstanden ist. Eine Durchsatzangabe ohne die Angabe
|
||||||
|
// von Maschine, Datenart und Einstellungen ist wertlos und wird trotzdem
|
||||||
|
// zitiert.
|
||||||
|
//
|
||||||
|
// Die Testdaten sind grundsätzlich **inkompressibel**. Das Projekt hat den
|
||||||
|
// gegenteiligen Fehler bereits gemacht: In Phase 4 zeigte ein Lauf eine
|
||||||
|
// 1021-fache Kompression, weil die Daten periodisch waren.
|
||||||
|
//
|
||||||
|
// Aufruf:
|
||||||
|
//
|
||||||
|
// syncova-bench run [--workdir <pfad>] [--scale klein|voll]
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"runtime/debug"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/agent"
|
||||||
|
"github.com/syncova/syncova/packages/backupengine"
|
||||||
|
"github.com/syncova/syncova/packages/benchmark"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
"github.com/syncova/syncova/packages/platform/ratelimit"
|
||||||
|
"github.com/syncova/syncova/packages/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serviceName benennt das Kommando in den Logs.
|
||||||
|
const serviceName = "syncova-bench"
|
||||||
|
|
||||||
|
// buildVersion wird beim Bauen über -ldflags gesetzt.
|
||||||
|
//
|
||||||
|
// Der Vorgabewert gilt nur für einen Bau von Hand; das Auslieferungspaket
|
||||||
|
// brennt die tatsächliche Fassung ein.
|
||||||
|
var buildVersion = "0.1.0-dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if runError := run(); runError != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %v\n", serviceName, runError)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run wertet das Unterkommando aus.
|
||||||
|
func run() error {
|
||||||
|
// Die Versionsabfrage steht vor dem Laden der Konfiguration: Wer wissen
|
||||||
|
// will, welche Fassung auf einem Server liegt, hat in dem Moment womöglich
|
||||||
|
// keine Datenbank — etwa auf einem frisch ausgepackten Paket oder mitten in
|
||||||
|
// einer Störung.
|
||||||
|
if len(os.Args) > 1 && isVersionArgument(os.Args[1]) {
|
||||||
|
fmt.Printf("%s %s\n", serviceName, buildVersion)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
benchFlags := flag.NewFlagSet("run", flag.ContinueOnError)
|
||||||
|
workDirectory := benchFlags.String("workdir", "", "Arbeitsverzeichnis für Quelle und Repository")
|
||||||
|
measurementScale := benchFlags.String("scale", "klein", "Umfang: klein oder voll")
|
||||||
|
|
||||||
|
arguments := os.Args[1:]
|
||||||
|
if len(arguments) > 0 && arguments[0] == "run" {
|
||||||
|
arguments = arguments[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
if parseError := benchFlags.Parse(arguments); parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if *workDirectory == "" {
|
||||||
|
return fmt.Errorf("--workdir ist erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
if directoryError := os.MkdirAll(*workDirectory, 0o700); directoryError != nil {
|
||||||
|
return fmt.Errorf("das arbeitsverzeichnis liess sich nicht anlegen: %w", directoryError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return runMeasurements(*workDirectory, *measurementScale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scaleProfile beschreibt den Umfang einer Messreihe.
|
||||||
|
//
|
||||||
|
// Zwei Stufen, weil eine Messung auf einer Entwicklungsmaschine anders
|
||||||
|
// aussehen muss als eine auf einer Appliance: „klein" läuft in Minuten und
|
||||||
|
// prüft, ob das Messwerk arbeitet; „voll" liefert Zahlen, die etwas bedeuten.
|
||||||
|
type scaleProfile struct {
|
||||||
|
// LargeSourceBytes ist die Größe der großen Einzelquelle.
|
||||||
|
LargeSourceBytes int64
|
||||||
|
// SmallFileCount ist die Zahl kleiner Dateien.
|
||||||
|
SmallFileCount int
|
||||||
|
// SmallFileBytes ist die Größe einer kleinen Datei.
|
||||||
|
SmallFileBytes int64
|
||||||
|
// ConcurrentJobs ist die Zahl gleichzeitiger Sicherungen.
|
||||||
|
ConcurrentJobs int
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveScale liefert das Profil einer Umfangsstufe.
|
||||||
|
func resolveScale(scaleName string) (scaleProfile, error) {
|
||||||
|
switch scaleName {
|
||||||
|
case "klein":
|
||||||
|
return scaleProfile{
|
||||||
|
LargeSourceBytes: 512 * 1024 * 1024,
|
||||||
|
SmallFileCount: 4000,
|
||||||
|
SmallFileBytes: 16 * 1024,
|
||||||
|
ConcurrentJobs: 4,
|
||||||
|
}, nil
|
||||||
|
case "voll":
|
||||||
|
return scaleProfile{
|
||||||
|
LargeSourceBytes: 2 * 1024 * 1024 * 1024,
|
||||||
|
SmallFileCount: 20000,
|
||||||
|
SmallFileBytes: 16 * 1024,
|
||||||
|
ConcurrentJobs: 8,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return scaleProfile{}, fmt.Errorf("unbekannter umfang %q (erlaubt: klein, voll)", scaleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMeasurements fährt die Messreihe.
|
||||||
|
func runMeasurements(workDirectory string, scaleName string) error {
|
||||||
|
profile, scaleError := resolveScale(scaleName)
|
||||||
|
if scaleError != nil {
|
||||||
|
return scaleError
|
||||||
|
}
|
||||||
|
|
||||||
|
quietLogger := logging.New(io.Discard, logging.Options{ServiceName: serviceName})
|
||||||
|
|
||||||
|
measurementReport := &benchmark.Report{
|
||||||
|
Title: fmt.Sprintf("Syncova Leistungsmessung (Umfang: %s)", scaleName),
|
||||||
|
Machine: benchmark.CurrentMachine(),
|
||||||
|
GeneratedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if memoryLimit := debug.SetMemoryLimit(-1); memoryLimit < 1<<62 {
|
||||||
|
measurementReport.Machine.MemoryLimitBytes = memoryLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Szenarien laufen nacheinander. Parallel gemessen beeinflussten sie
|
||||||
|
// sich gegenseitig — und eine Messung, die von einer anderen abhängt,
|
||||||
|
// misst nichts.
|
||||||
|
scenarioRunners := []struct {
|
||||||
|
name string
|
||||||
|
run func(string, scaleProfile, *slog.Logger) (*benchmark.Result, error)
|
||||||
|
}{
|
||||||
|
{"Eine große Quelle", measureLargeSource},
|
||||||
|
{"Viele kleine Dateien", measureManySmallFiles},
|
||||||
|
{"Zweiter Lauf über unveränderte Daten", measureDeduplicationRun},
|
||||||
|
{"Mehrere gleichzeitige Aufträge", measureConcurrentJobs},
|
||||||
|
{"Langsames Netz (1 MiB/s)", measureThrottledSource},
|
||||||
|
{"Wenig CPU (zwei Kerne)", measureConstrainedCPU},
|
||||||
|
{"Wenig Speicher (256 MiB)", measureConstrainedMemory},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenarioRunner := range scenarioRunners {
|
||||||
|
fmt.Fprintf(os.Stderr, "… %s\n", scenarioRunner.name)
|
||||||
|
|
||||||
|
scenarioResult, scenarioError := scenarioRunner.run(workDirectory, profile, quietLogger)
|
||||||
|
if scenarioError != nil {
|
||||||
|
return fmt.Errorf("das szenario %q schlug fehl: %w", scenarioRunner.name, scenarioError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Lauf, der die Prüfung nicht besteht, kommt nicht in den Bericht —
|
||||||
|
// er kommt hinein mit dem Vermerk, warum seine Zahl nichts aussagt.
|
||||||
|
if validationError := scenarioResult.Validate(); validationError != nil {
|
||||||
|
scenarioResult.AddNote("Diese Messung ist nicht belastbar: %v", validationError)
|
||||||
|
}
|
||||||
|
|
||||||
|
measurementReport.Results = append(measurementReport.Results, scenarioResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Was diese Maschine nicht messen kann, steht als nicht gemessen im
|
||||||
|
// Bericht — nicht als geschätzte Zahl.
|
||||||
|
measurementReport.AddSkipped("Eine große virtuelle Maschine",
|
||||||
|
"Ohne Proxmox-Verbund nicht messbar. Der Provider ist geschrieben, sein "+
|
||||||
|
"verpflichtender End-to-End-Nachweis aber nicht erbracht (Phase 7). Die große "+
|
||||||
|
"Einzelquelle oben misst denselben Datenpfad, nur ohne die Datenträgerabfrage "+
|
||||||
|
"des Hypervisors.")
|
||||||
|
measurementReport.AddSkipped("Langsames Repository",
|
||||||
|
"Ein künstlich verlangsamtes Ziel würde die Wartezeit messen, die man ihm "+
|
||||||
|
"vorgibt — eine Zahl, die man sich selbst ausgedacht hat. Aussagekräftig wäre "+
|
||||||
|
"eine Messung gegen echten Netzwerkspeicher; der steht hier nicht zur Verfügung.")
|
||||||
|
measurementReport.AddSkipped("Datenträger-Ein-/Ausgaben je Sekunde",
|
||||||
|
"Auf macOS ohne erweiterte Rechte nicht je Prozess auszulesen. Die Zahl der "+
|
||||||
|
"geschriebenen Blöcke steht ersatzweise in den Ergebnissen; sie ist die "+
|
||||||
|
"Größe, die diese Anlage beeinflussen kann.")
|
||||||
|
measurementReport.AddSkipped("Netzwerkdurchsatz",
|
||||||
|
"Alle Messungen liefen gegen ein lokales Repository. Eine Netzmessung ohne "+
|
||||||
|
"entfernte Gegenstelle wäre eine Messung des Rückschleifen-Geräts.")
|
||||||
|
|
||||||
|
measurementReport.WriteText(os.Stdout)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// measureLargeSource misst eine einzelne große Datei.
|
||||||
|
//
|
||||||
|
// Der Ersatz für „eine große VM": Derselbe Datenpfad — lesen, zerlegen, hashen,
|
||||||
|
// komprimieren, verschlüsseln, ablegen —, nur ohne die Datenträgerabfrage des
|
||||||
|
// Hypervisors.
|
||||||
|
func measureLargeSource(workDirectory string, profile scaleProfile, baseLogger *slog.Logger) (*benchmark.Result, error) {
|
||||||
|
sourcePath := filepath.Join(workDirectory, "grosse-quelle")
|
||||||
|
|
||||||
|
if prepareError := prepareIncompressibleFile(
|
||||||
|
filepath.Join(sourcePath, "abbild.bin"), profile.LargeSourceBytes); prepareError != nil {
|
||||||
|
return nil, prepareError
|
||||||
|
}
|
||||||
|
|
||||||
|
return runBackupScenario(scenarioOptions{
|
||||||
|
Name: "Eine große Quelle",
|
||||||
|
Description: "Eine einzelne große Datei — der Ersatz für eine virtuelle Maschine. " +
|
||||||
|
"Misst den reinen Datenpfad ohne Aufwand je Datei.",
|
||||||
|
WorkDirectory: workDirectory,
|
||||||
|
RepositoryName: "gross",
|
||||||
|
SourcePath: sourcePath,
|
||||||
|
DataShape: benchmark.DataShape{
|
||||||
|
Description: "inkompressible Zufallsdaten",
|
||||||
|
TotalBytes: profile.LargeSourceBytes,
|
||||||
|
FileCount: 1,
|
||||||
|
Compressible: false,
|
||||||
|
},
|
||||||
|
Logger: baseLogger,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// measureManySmallFiles misst viele kleine Dateien.
|
||||||
|
//
|
||||||
|
// Hier begrenzt nicht der Durchsatz, sondern der Aufwand je Datei: öffnen,
|
||||||
|
// lesen, Metadaten erfassen, schließen. Die aussagekräftige Zahl ist deshalb
|
||||||
|
// „Objekte je Sekunde", nicht „MiB/s".
|
||||||
|
func measureManySmallFiles(workDirectory string, profile scaleProfile, baseLogger *slog.Logger) (*benchmark.Result, error) {
|
||||||
|
sourcePath := filepath.Join(workDirectory, "viele-dateien")
|
||||||
|
|
||||||
|
if prepareError := prepareManyFiles(sourcePath,
|
||||||
|
profile.SmallFileCount, profile.SmallFileBytes); prepareError != nil {
|
||||||
|
return nil, prepareError
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarioResult, scenarioError := runBackupScenario(scenarioOptions{
|
||||||
|
Name: "Viele kleine Dateien",
|
||||||
|
Description: "Viele kleine Dateien in flacher Struktur. Hier begrenzt der Aufwand " +
|
||||||
|
"je Datei, nicht der Durchsatz.",
|
||||||
|
WorkDirectory: workDirectory,
|
||||||
|
RepositoryName: "viele",
|
||||||
|
SourcePath: sourcePath,
|
||||||
|
DataShape: benchmark.DataShape{
|
||||||
|
Description: "inkompressible Zufallsdaten",
|
||||||
|
TotalBytes: int64(profile.SmallFileCount) * profile.SmallFileBytes,
|
||||||
|
FileCount: profile.SmallFileCount,
|
||||||
|
Compressible: false,
|
||||||
|
},
|
||||||
|
Logger: baseLogger,
|
||||||
|
})
|
||||||
|
if scenarioError != nil {
|
||||||
|
return nil, scenarioError
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarioResult.AddNote("Die aussagekräftige Zahl ist hier „Objekte je Sekunde\". " +
|
||||||
|
"Der Durchsatz liegt bauartbedingt niedriger als bei einer großen Datei.")
|
||||||
|
|
||||||
|
return scenarioResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// measureDeduplicationRun misst einen zweiten Lauf über dieselben Daten.
|
||||||
|
//
|
||||||
|
// Er zeigt, was Deduplizierung tatsächlich einspart: Gelesen und gehasht wird
|
||||||
|
// weiterhin alles, abgelegt nichts. Der Gewinn ist Schreibarbeit, nicht Lesezeit
|
||||||
|
// — wer das verwechselt, hält Zusatzsicherungen für überflüssig.
|
||||||
|
func measureDeduplicationRun(workDirectory string, profile scaleProfile, baseLogger *slog.Logger) (*benchmark.Result, error) {
|
||||||
|
scenarioResult, scenarioError := runBackupScenario(scenarioOptions{
|
||||||
|
Name: "Zweiter Lauf über unveränderte Daten",
|
||||||
|
Description: "Dieselbe große Quelle ein zweites Mal in dasselbe Repository. " +
|
||||||
|
"Misst, was Deduplizierung einspart.",
|
||||||
|
WorkDirectory: workDirectory,
|
||||||
|
RepositoryName: "gross",
|
||||||
|
SourcePath: filepath.Join(workDirectory, "grosse-quelle"),
|
||||||
|
BackupSuffix: "-zweiter-lauf",
|
||||||
|
ReuseRepository: true,
|
||||||
|
DataShape: benchmark.DataShape{
|
||||||
|
Description: "inkompressible Zufallsdaten (bereits abgelegt)",
|
||||||
|
TotalBytes: profile.LargeSourceBytes,
|
||||||
|
FileCount: 1,
|
||||||
|
Compressible: false,
|
||||||
|
},
|
||||||
|
Logger: baseLogger,
|
||||||
|
})
|
||||||
|
if scenarioError != nil {
|
||||||
|
return nil, scenarioError
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarioResult.AddNote("Gelesen und gehasht wird weiterhin alles — gespart wird das " +
|
||||||
|
"Ablegen. Der Gewinn einer Zusatzsicherung ist Zeit, nicht Speicher.")
|
||||||
|
|
||||||
|
return scenarioResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// measureConcurrentJobs misst mehrere gleichzeitige Sicherungen.
|
||||||
|
//
|
||||||
|
// Jede schreibt in ein **eigenes** Repository: Ein gemeinsames Ziel hielte nur
|
||||||
|
// eine Schreibsperre bereit, und gemessen würde das Warten darauf. Genau das
|
||||||
|
// ist die getrennte Frage nach der Repository-Konkurrenz weiter unten.
|
||||||
|
func measureConcurrentJobs(workDirectory string, profile scaleProfile, baseLogger *slog.Logger) (*benchmark.Result, error) {
|
||||||
|
sourcePath := filepath.Join(workDirectory, "gleichzeitig")
|
||||||
|
bytesPerJob := int64(64 * 1024 * 1024)
|
||||||
|
|
||||||
|
for jobIndex := 0; jobIndex < profile.ConcurrentJobs; jobIndex++ {
|
||||||
|
jobSource := filepath.Join(sourcePath, fmt.Sprintf("auftrag-%02d", jobIndex))
|
||||||
|
|
||||||
|
if prepareError := prepareIncompressibleFile(
|
||||||
|
filepath.Join(jobSource, "daten.bin"), bytesPerJob); prepareError != nil {
|
||||||
|
return nil, prepareError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalBytes := bytesPerJob * int64(profile.ConcurrentJobs)
|
||||||
|
|
||||||
|
recorder := benchmark.StartRecording()
|
||||||
|
|
||||||
|
var waitGroup sync.WaitGroup
|
||||||
|
|
||||||
|
jobErrors := make([]error, profile.ConcurrentJobs)
|
||||||
|
processedBytes := make([]int64, profile.ConcurrentJobs)
|
||||||
|
|
||||||
|
for jobIndex := 0; jobIndex < profile.ConcurrentJobs; jobIndex++ {
|
||||||
|
waitGroup.Add(1)
|
||||||
|
|
||||||
|
go func(currentJob int) {
|
||||||
|
defer waitGroup.Done()
|
||||||
|
|
||||||
|
repositoryPath := filepath.Join(workDirectory, fmt.Sprintf("repo-gleichzeitig-%02d", currentJob))
|
||||||
|
|
||||||
|
if removeError := os.RemoveAll(repositoryPath); removeError != nil {
|
||||||
|
jobErrors[currentJob] = removeError
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runResult, runError := performBackup(repositoryPath,
|
||||||
|
filepath.Join(sourcePath, fmt.Sprintf("auftrag-%02d", currentJob)),
|
||||||
|
fmt.Sprintf("gleichzeitig-%02d-%d", currentJob, time.Now().UnixNano()),
|
||||||
|
nil, baseLogger)
|
||||||
|
if runError != nil {
|
||||||
|
jobErrors[currentJob] = runError
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
processedBytes[currentJob] = runResult.Progress.BytesProcessed
|
||||||
|
}(jobIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitGroup.Wait()
|
||||||
|
|
||||||
|
measuredUsage := recorder.Stop()
|
||||||
|
|
||||||
|
for _, jobError := range jobErrors {
|
||||||
|
if jobError != nil {
|
||||||
|
return nil, jobError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalProcessed int64
|
||||||
|
for _, jobBytes := range processedBytes {
|
||||||
|
totalProcessed += jobBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarioResult := &benchmark.Result{
|
||||||
|
ScenarioName: "Mehrere gleichzeitige Aufträge",
|
||||||
|
Description: fmt.Sprintf("%d Sicherungen gleichzeitig, jede in ein eigenes Repository.",
|
||||||
|
profile.ConcurrentJobs),
|
||||||
|
Machine: benchmark.CurrentMachine(),
|
||||||
|
Data: benchmark.DataShape{
|
||||||
|
Description: "inkompressible Zufallsdaten",
|
||||||
|
TotalBytes: totalBytes,
|
||||||
|
FileCount: profile.ConcurrentJobs,
|
||||||
|
Compressible: false,
|
||||||
|
},
|
||||||
|
Usage: measuredUsage,
|
||||||
|
BytesProcessed: totalProcessed,
|
||||||
|
FilesProcessed: profile.ConcurrentJobs,
|
||||||
|
MeasuredAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarioResult.AddNote("Jeder Auftrag schreibt in ein eigenes Repository. Ein gemeinsames " +
|
||||||
|
"Ziel hält nur eine Schreibsperre bereit — gemessen würde dann das Warten darauf.")
|
||||||
|
|
||||||
|
return scenarioResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// measureThrottledSource misst mit gedrosselter Leserate.
|
||||||
|
//
|
||||||
|
// Der Ersatz für „langsames Netz": Der Bandbreitenbegrenzer aus Phase 8 sitzt
|
||||||
|
// am Lesen der Quelle, und wegen des Gegendrucks der Pipeline bindet dieser eine
|
||||||
|
// Punkt die gesamte Last.
|
||||||
|
func measureThrottledSource(workDirectory string, profile scaleProfile, baseLogger *slog.Logger) (*benchmark.Result, error) {
|
||||||
|
const throttledBytesPerSecond = 1024 * 1024
|
||||||
|
const throttledSourceBytes = 8 * 1024 * 1024
|
||||||
|
|
||||||
|
sourcePath := filepath.Join(workDirectory, "gedrosselt")
|
||||||
|
|
||||||
|
if prepareError := prepareIncompressibleFile(
|
||||||
|
filepath.Join(sourcePath, "daten.bin"), throttledSourceBytes); prepareError != nil {
|
||||||
|
return nil, prepareError
|
||||||
|
}
|
||||||
|
|
||||||
|
bandwidthLimiter, limiterError := ratelimit.NewLimiter(throttledBytesPerSecond)
|
||||||
|
if limiterError != nil {
|
||||||
|
return nil, limiterError
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarioResult, scenarioError := runBackupScenario(scenarioOptions{
|
||||||
|
Name: "Langsames Netz (1 MiB/s)",
|
||||||
|
Description: "Dieselbe Pipeline mit gedrosselter Leserate. Zeigt, dass der " +
|
||||||
|
"Begrenzer wirkt und wo die Zeit dann hingeht.",
|
||||||
|
WorkDirectory: workDirectory,
|
||||||
|
RepositoryName: "gedrosselt",
|
||||||
|
SourcePath: sourcePath,
|
||||||
|
BandwidthLimiter: bandwidthLimiter,
|
||||||
|
DataShape: benchmark.DataShape{
|
||||||
|
Description: "inkompressible Zufallsdaten",
|
||||||
|
TotalBytes: throttledSourceBytes,
|
||||||
|
FileCount: 1,
|
||||||
|
Compressible: false,
|
||||||
|
},
|
||||||
|
Logger: baseLogger,
|
||||||
|
})
|
||||||
|
if scenarioError != nil {
|
||||||
|
return nil, scenarioError
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarioResult.AddNote("Erwartet werden rund 8 s für 8 MiB. Eine deutlich kürzere " +
|
||||||
|
"Laufzeit hieße, dass der Begrenzer nicht greift.")
|
||||||
|
|
||||||
|
return scenarioResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// measureConstrainedCPU misst mit begrenzter Kernzahl.
|
||||||
|
//
|
||||||
|
// GOMAXPROCS begrenzt die gleichzeitig laufenden Abläufe der Go-Laufzeit. Das
|
||||||
|
// ist keine Simulation, sondern die tatsächliche Beschränkung — dieselbe, die
|
||||||
|
// eine kleine virtuelle Maschine mitbringt.
|
||||||
|
func measureConstrainedCPU(workDirectory string, profile scaleProfile, baseLogger *slog.Logger) (*benchmark.Result, error) {
|
||||||
|
const constrainedCores = 2
|
||||||
|
|
||||||
|
previousMaxProcs := runtime.GOMAXPROCS(constrainedCores)
|
||||||
|
defer runtime.GOMAXPROCS(previousMaxProcs)
|
||||||
|
|
||||||
|
sourcePath := filepath.Join(workDirectory, "wenig-cpu")
|
||||||
|
constrainedBytes := profile.LargeSourceBytes / 4
|
||||||
|
|
||||||
|
if prepareError := prepareIncompressibleFile(
|
||||||
|
filepath.Join(sourcePath, "daten.bin"), constrainedBytes); prepareError != nil {
|
||||||
|
return nil, prepareError
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarioResult, scenarioError := runBackupScenario(scenarioOptions{
|
||||||
|
Name: "Wenig CPU (zwei Kerne)",
|
||||||
|
Description: "Dieselbe Pipeline mit auf zwei Kerne begrenzter Laufzeit. " +
|
||||||
|
"Keine Simulation — die Beschränkung ist echt.",
|
||||||
|
WorkDirectory: workDirectory,
|
||||||
|
RepositoryName: "wenig-cpu",
|
||||||
|
SourcePath: sourcePath,
|
||||||
|
DataShape: benchmark.DataShape{
|
||||||
|
Description: "inkompressible Zufallsdaten",
|
||||||
|
TotalBytes: constrainedBytes,
|
||||||
|
FileCount: 1,
|
||||||
|
Compressible: false,
|
||||||
|
},
|
||||||
|
Logger: baseLogger,
|
||||||
|
})
|
||||||
|
if scenarioError != nil {
|
||||||
|
return nil, scenarioError
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarioResult.Machine.GoMaxProcs = constrainedCores
|
||||||
|
|
||||||
|
return scenarioResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// measureConstrainedMemory misst mit enger Speichergrenze.
|
||||||
|
//
|
||||||
|
// GOMEMLIMIT zwingt die Speicherbereinigung zu häufigerer Arbeit. Die
|
||||||
|
// interessante Frage ist nicht, ob es langsamer wird — das wird es —, sondern
|
||||||
|
// ob die Streaming-Pipeline überhaupt durchläuft: Ein Verfahren, das die Quelle
|
||||||
|
// in den Speicher lädt, scheiterte hier.
|
||||||
|
func measureConstrainedMemory(workDirectory string, profile scaleProfile, baseLogger *slog.Logger) (*benchmark.Result, error) {
|
||||||
|
const constrainedMemoryBytes = 256 * 1024 * 1024
|
||||||
|
|
||||||
|
previousLimit := debug.SetMemoryLimit(constrainedMemoryBytes)
|
||||||
|
defer debug.SetMemoryLimit(previousLimit)
|
||||||
|
|
||||||
|
sourcePath := filepath.Join(workDirectory, "wenig-speicher")
|
||||||
|
constrainedBytes := profile.LargeSourceBytes / 2
|
||||||
|
|
||||||
|
if prepareError := prepareIncompressibleFile(
|
||||||
|
filepath.Join(sourcePath, "daten.bin"), constrainedBytes); prepareError != nil {
|
||||||
|
return nil, prepareError
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarioResult, scenarioError := runBackupScenario(scenarioOptions{
|
||||||
|
Name: "Wenig Speicher (256 MiB)",
|
||||||
|
Description: "Dieselbe Pipeline mit einer Speichergrenze weit unter der " +
|
||||||
|
"Quellgröße. Prüft, ob wirklich als Datenstrom gearbeitet wird.",
|
||||||
|
WorkDirectory: workDirectory,
|
||||||
|
RepositoryName: "wenig-speicher",
|
||||||
|
SourcePath: sourcePath,
|
||||||
|
DataShape: benchmark.DataShape{
|
||||||
|
Description: "inkompressible Zufallsdaten",
|
||||||
|
TotalBytes: constrainedBytes,
|
||||||
|
FileCount: 1,
|
||||||
|
Compressible: false,
|
||||||
|
},
|
||||||
|
Logger: baseLogger,
|
||||||
|
})
|
||||||
|
if scenarioError != nil {
|
||||||
|
return nil, scenarioError
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarioResult.Machine.MemoryLimitBytes = constrainedMemoryBytes
|
||||||
|
scenarioResult.AddNote("Die Quelle ist %s groß, die Speichergrenze %s. "+
|
||||||
|
"Ein Verfahren, das die Quelle in den Speicher lädt, käme hier nicht durch.",
|
||||||
|
benchmark.FormatBytes(float64(constrainedBytes)),
|
||||||
|
benchmark.FormatBytes(constrainedMemoryBytes))
|
||||||
|
|
||||||
|
return scenarioResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scenarioOptions beschreiben einen einzelnen Messlauf.
|
||||||
|
type scenarioOptions struct {
|
||||||
|
// Name benennt das Szenario.
|
||||||
|
Name string
|
||||||
|
// Description erklärt, welche Frage es beantwortet.
|
||||||
|
Description string
|
||||||
|
// WorkDirectory ist das Arbeitsverzeichnis.
|
||||||
|
WorkDirectory string
|
||||||
|
// RepositoryName ist der Name des Zielrepositorys.
|
||||||
|
RepositoryName string
|
||||||
|
// SourcePath ist die zu sichernde Quelle.
|
||||||
|
SourcePath string
|
||||||
|
// BackupSuffix unterscheidet mehrere Läufe im selben Repository.
|
||||||
|
BackupSuffix string
|
||||||
|
// ReuseRepository misst gegen ein bereits gefülltes Repository.
|
||||||
|
//
|
||||||
|
// Ausschliesslich fuer das Deduplizierungsszenario. Alle uebrigen Laeufe
|
||||||
|
// bekommen ein frisches Ziel — sonst misst ein zweiter Aufruf des
|
||||||
|
// Werkzeugs die Deduplizierung statt das Ablegen, und zwar unbemerkt:
|
||||||
|
// Beide Laeufe liefern plausible Zahlen, nur beantworten sie verschiedene
|
||||||
|
// Fragen. Im ersten Messlauf dieser Phase genau so passiert.
|
||||||
|
ReuseRepository bool
|
||||||
|
// BandwidthLimiter begrenzt die Leserate.
|
||||||
|
BandwidthLimiter *ratelimit.Limiter
|
||||||
|
// DataShape beschreibt die Daten.
|
||||||
|
DataShape benchmark.DataShape
|
||||||
|
// Logger nimmt die Protokollzeilen auf.
|
||||||
|
Logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// runBackupScenario führt einen gemessenen Sicherungslauf aus.
|
||||||
|
func runBackupScenario(options scenarioOptions) (*benchmark.Result, error) {
|
||||||
|
repositoryPath := filepath.Join(options.WorkDirectory, "repo-"+options.RepositoryName)
|
||||||
|
|
||||||
|
// Ein frisches Repository, sofern nicht ausdruecklich anders gewuenscht.
|
||||||
|
if !options.ReuseRepository {
|
||||||
|
if removeError := os.RemoveAll(repositoryPath); removeError != nil {
|
||||||
|
return nil, fmt.Errorf("das repository liess sich nicht zuruecksetzen: %w", removeError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Kennung traegt den Zeitpunkt: Ein Repository weist eine bereits
|
||||||
|
// vergebene Kennung zurueck, und ein Messwerkzeug, das sich nicht
|
||||||
|
// wiederholen laesst, ist keines — die zweite Messung ist die, die zaehlt.
|
||||||
|
backupIdentifier := fmt.Sprintf("bench-%s%s-%d",
|
||||||
|
options.RepositoryName, options.BackupSuffix, time.Now().UnixNano())
|
||||||
|
|
||||||
|
recorder := benchmark.StartRecording()
|
||||||
|
|
||||||
|
runResult, runError := performBackup(repositoryPath, options.SourcePath,
|
||||||
|
backupIdentifier, options.BandwidthLimiter, options.Logger)
|
||||||
|
|
||||||
|
measuredUsage := recorder.Stop()
|
||||||
|
|
||||||
|
if runError != nil {
|
||||||
|
return nil, runError
|
||||||
|
}
|
||||||
|
|
||||||
|
return &benchmark.Result{
|
||||||
|
ScenarioName: options.Name,
|
||||||
|
Description: options.Description,
|
||||||
|
Machine: benchmark.CurrentMachine(),
|
||||||
|
Data: options.DataShape,
|
||||||
|
Usage: measuredUsage,
|
||||||
|
BytesProcessed: runResult.Progress.BytesProcessed,
|
||||||
|
BytesStored: runResult.Progress.BytesWritten,
|
||||||
|
FilesProcessed: runResult.FilesBackedUp,
|
||||||
|
MeasuredAt: time.Now().UTC(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// performBackup führt eine Sicherung aus.
|
||||||
|
//
|
||||||
|
// Ohne Verschlüsselung: Ein Schlüssel verlangte einen Secret Store, und die
|
||||||
|
// Messung ginge dann durch AES-256-GCM — eine andere Frage als die nach dem
|
||||||
|
// Durchsatz der Pipeline. Der Aufschlag der Verschlüsselung gehört gesondert
|
||||||
|
// gemessen.
|
||||||
|
func performBackup(repositoryPath string, sourcePath string, backupIdentifier string,
|
||||||
|
bandwidthLimiter *ratelimit.Limiter, baseLogger *slog.Logger) (*agent.BackupRunResult, error) {
|
||||||
|
measurementContext := context.Background()
|
||||||
|
|
||||||
|
openedRepository, repositoryError := openOrCreateRepository(measurementContext,
|
||||||
|
repositoryPath, baseLogger)
|
||||||
|
if repositoryError != nil {
|
||||||
|
return nil, repositoryError
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = openedRepository.Close() }()
|
||||||
|
|
||||||
|
backupEngine := backupengine.NewEngine(openedRepository, nil, baseLogger)
|
||||||
|
backupRunner := agent.NewBackupRunner(backupEngine, baseLogger)
|
||||||
|
|
||||||
|
return backupRunner.RunBackup(measurementContext, agent.BackupRunOptions{
|
||||||
|
BackupID: backupIdentifier,
|
||||||
|
SourcePath: sourcePath,
|
||||||
|
SourceName: filepath.Base(sourcePath),
|
||||||
|
CompressionLevel: backupengine.CompressionBalanced,
|
||||||
|
ChainID: "bench-" + filepath.Base(repositoryPath),
|
||||||
|
BandwidthLimiter: bandwidthLimiter,
|
||||||
|
CreatedByVersion: serviceName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// openOrCreateRepository öffnet ein Repository oder legt es an.
|
||||||
|
func openOrCreateRepository(openContext context.Context, repositoryPath string,
|
||||||
|
baseLogger *slog.Logger) (*repository.LocalRepository, error) {
|
||||||
|
openedRepository, openError := repository.Open(openContext, repositoryPath,
|
||||||
|
repository.OpenOptions{}, baseLogger)
|
||||||
|
if openError == nil {
|
||||||
|
return openedRepository, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return repository.Create(openContext, repositoryPath,
|
||||||
|
repository.CreateOptions{Name: filepath.Base(repositoryPath)}, baseLogger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareIncompressibleFile legt eine Datei mit Zufallsdaten an.
|
||||||
|
//
|
||||||
|
// Zufallsdaten und nicht wiederholter Text: Bei komprimierbaren Daten misst man
|
||||||
|
// zstd, nicht die Anlage. Vorhandene Dateien werden wiederverwendet — das
|
||||||
|
// Erzeugen von zwei Gigabyte kostet mehr Zeit als die Messung selbst.
|
||||||
|
func prepareIncompressibleFile(filePath string, totalBytes int64) error {
|
||||||
|
if fileInformation, statError := os.Stat(filePath); statError == nil {
|
||||||
|
if fileInformation.Size() == totalBytes {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if directoryError := os.MkdirAll(filepath.Dir(filePath), 0o700); directoryError != nil {
|
||||||
|
return fmt.Errorf("das quellverzeichnis liess sich nicht anlegen: %w", directoryError)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetFile, createError := os.Create(filePath)
|
||||||
|
if createError != nil {
|
||||||
|
return fmt.Errorf("die quelldatei liess sich nicht anlegen: %w", createError)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = targetFile.Close() }()
|
||||||
|
|
||||||
|
if _, copyError := io.CopyN(targetFile, rand.Reader, totalBytes); copyError != nil {
|
||||||
|
return fmt.Errorf("die quelldatei liess sich nicht fuellen: %w", copyError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetFile.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareManyFiles legt viele kleine Dateien an.
|
||||||
|
func prepareManyFiles(directoryPath string, fileCount int, fileBytes int64) error {
|
||||||
|
if entries, readError := os.ReadDir(directoryPath); readError == nil && len(entries) == fileCount {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if directoryError := os.MkdirAll(directoryPath, 0o700); directoryError != nil {
|
||||||
|
return fmt.Errorf("das quellverzeichnis liess sich nicht anlegen: %w", directoryError)
|
||||||
|
}
|
||||||
|
|
||||||
|
for fileIndex := 0; fileIndex < fileCount; fileIndex++ {
|
||||||
|
filePath := filepath.Join(directoryPath, fmt.Sprintf("datei-%06d.bin", fileIndex))
|
||||||
|
|
||||||
|
if prepareError := prepareIncompressibleFile(filePath, fileBytes); prepareError != nil {
|
||||||
|
return prepareError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isVersionArgument erkennt eine Versionsabfrage.
|
||||||
|
//
|
||||||
|
// Drei Schreibweisen, weil sich niemand merkt, welche ein bestimmtes Programm
|
||||||
|
// erwartet — und weil eine Fehlermeldung auf "--version" der denkbar
|
||||||
|
// schlechteste erste Eindruck ist.
|
||||||
|
func isVersionArgument(argument string) bool {
|
||||||
|
return argument == "version" || argument == "--version" || argument == "-version"
|
||||||
|
}
|
||||||
479
apps/api/cmd/syncova-dr/main.go
Normal file
479
apps/api/cmd/syncova-dr/main.go
Normal file
@ -0,0 +1,479 @@
|
|||||||
|
// Command syncova-dr stellt eine Anlage nach einem Totalverlust wieder her
|
||||||
|
// (SYNCOVA_IMPLEMENTATION_PLAN.md §20).
|
||||||
|
//
|
||||||
|
// Das Werkzeug bedient man an dem Tag, an dem nichts mehr da ist. Daraus folgen
|
||||||
|
// drei Eigenschaften, die es von den uebrigen Kommandos unterscheiden:
|
||||||
|
//
|
||||||
|
// „inspect" braucht **keine Datenbank**. Nach einem Totalverlust will man zuerst
|
||||||
|
// sehen, was ueberhaupt noch da ist, bevor man einen Server aufsetzt.
|
||||||
|
//
|
||||||
|
// Jeder Schritt sagt, was er getan hat **und was noch zu tun bleibt**. Wer eine
|
||||||
|
// Anlage aus dem Nichts wiederherstellt, hat keine Betriebsanleitung neben sich
|
||||||
|
// liegen; die Ausgabe muss die Anleitung sein.
|
||||||
|
//
|
||||||
|
// Nichts laeuft von selbst wieder an. Auftraege kommen angehalten zurueck,
|
||||||
|
// Repositories als nicht erreichbar, Benachrichtigungswege abgeschaltet. Eine
|
||||||
|
// Anlage, die nach dem Wiederaufbau um zwei Uhr nachts von selbst auf ein halb
|
||||||
|
// hergestelltes System schreibt, waere schlimmer als eine, die stillsteht.
|
||||||
|
//
|
||||||
|
// Aufruf:
|
||||||
|
//
|
||||||
|
// syncova-dr export --repo <pfad> Sichert die Konfiguration ins Repository
|
||||||
|
// syncova-dr inspect --repo <pfad> Zeigt den Sicherungssatz (ohne Datenbank)
|
||||||
|
// syncova-dr restore --repo <pfad> [--catalog] Spielt Konfiguration und Katalog ein
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/syncova/syncova/packages/disasterrecovery"
|
||||||
|
"github.com/syncova/syncova/packages/platform/config"
|
||||||
|
"github.com/syncova/syncova/packages/platform/database"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
"github.com/syncova/syncova/packages/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serviceName benennt das Kommando in den Logs.
|
||||||
|
const serviceName = "syncova-dr"
|
||||||
|
|
||||||
|
// commandTimeout begrenzt die Laufzeit einer Datenbankoperation.
|
||||||
|
//
|
||||||
|
// Grosszuegig bemessen: Der Katalogaufbau liest jedes Manifest des Repositorys,
|
||||||
|
// und bei einer grossen Anlage sind das viele tausend Dateien.
|
||||||
|
const commandTimeout = 30 * time.Minute
|
||||||
|
|
||||||
|
// buildVersion wird beim Bauen über -ldflags gesetzt.
|
||||||
|
var buildVersion = "0.1.0-dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if runError := run(); runError != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %v\n", serviceName, runError)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run wertet das Unterkommando aus.
|
||||||
|
func run() error {
|
||||||
|
// Die Versionsabfrage steht vor allem anderen: Wer wissen will, welche
|
||||||
|
// Fassung auf einem Server liegt, hat in dem Moment womöglich keine
|
||||||
|
// Konfiguration — etwa auf einem frisch ausgepackten Paket.
|
||||||
|
if len(os.Args) > 1 && isVersionArgument(os.Args[1]) {
|
||||||
|
fmt.Printf("%s %s\n", serviceName, buildVersion)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
printUsage()
|
||||||
|
|
||||||
|
return fmt.Errorf("es wurde kein kommando angegeben")
|
||||||
|
}
|
||||||
|
|
||||||
|
commandName := os.Args[1]
|
||||||
|
commandArguments := os.Args[2:]
|
||||||
|
|
||||||
|
switch commandName {
|
||||||
|
case "export":
|
||||||
|
return runExport(commandArguments)
|
||||||
|
case "inspect":
|
||||||
|
return runInspect(commandArguments)
|
||||||
|
case "restore":
|
||||||
|
return runRestore(commandArguments)
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
printUsage()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
printUsage()
|
||||||
|
|
||||||
|
return fmt.Errorf("unbekanntes kommando %q", commandName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printUsage schreibt die Kurzhilfe.
|
||||||
|
func printUsage() {
|
||||||
|
fmt.Fprintf(os.Stderr, `%s: Verwendung:
|
||||||
|
syncova-dr export --repo <pfad> Sichert die Konfiguration ins Repository
|
||||||
|
syncova-dr inspect --repo <pfad> Zeigt den Sicherungssatz (ohne Datenbank)
|
||||||
|
syncova-dr restore --repo <pfad> [--catalog] Spielt Konfiguration und Katalog ein
|
||||||
|
|
||||||
|
Der Weg nach einem Totalverlust:
|
||||||
|
1. syncova-migrate up Schema anlegen
|
||||||
|
2. syncova-dr inspect --repo <pfad> Ansehen, was da ist
|
||||||
|
3. syncova-dr restore --repo <pfad> --catalog Konfiguration und Wiederherstellungspunkte
|
||||||
|
4. syncova-admin create-admin --username <n> Ersten Zugang anlegen
|
||||||
|
`, serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runExport sichert die Konfiguration ins Repository.
|
||||||
|
func runExport(commandArguments []string) error {
|
||||||
|
exportFlags := flag.NewFlagSet("export", flag.ContinueOnError)
|
||||||
|
repositoryPath := exportFlags.String("repo", "", "Pfad des Repositorys")
|
||||||
|
|
||||||
|
if parseError := exportFlags.Parse(commandArguments); parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if *repositoryPath == "" {
|
||||||
|
return fmt.Errorf("--repo ist erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
commandContext, cancelCommand := context.WithTimeout(context.Background(), commandTimeout)
|
||||||
|
defer cancelCommand()
|
||||||
|
|
||||||
|
environment, environmentError := buildEnvironment(commandContext)
|
||||||
|
if environmentError != nil {
|
||||||
|
return environmentError
|
||||||
|
}
|
||||||
|
|
||||||
|
defer environment.close()
|
||||||
|
|
||||||
|
openRepository, openError := repository.Open(commandContext, *repositoryPath,
|
||||||
|
repository.OpenOptions{}, environment.logger)
|
||||||
|
if openError != nil {
|
||||||
|
return fmt.Errorf("das repository liess sich nicht oeffnen: %w", openError)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = openRepository.Close() }()
|
||||||
|
|
||||||
|
repositoryDescriptor := openRepository.Descriptor()
|
||||||
|
|
||||||
|
snapshotExporter := disasterrecovery.NewExporter(environment.databasePool.Connections())
|
||||||
|
|
||||||
|
configurationSnapshot, buildError := snapshotExporter.BuildSnapshot(commandContext,
|
||||||
|
repositoryDescriptor.RepositoryID, serviceName, buildVersion)
|
||||||
|
if buildError != nil {
|
||||||
|
return buildError
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotPath, writeError := disasterrecovery.WriteSnapshot(openRepository.RootPath(),
|
||||||
|
configurationSnapshot)
|
||||||
|
if writeError != nil {
|
||||||
|
return writeError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Konfiguration gesichert: %s\n", snapshotPath)
|
||||||
|
fmt.Printf(" %s\n", configurationSnapshot.Summary())
|
||||||
|
fmt.Printf(" Schemastand %d, erzeugt %s\n",
|
||||||
|
configurationSnapshot.SchemaVersion,
|
||||||
|
configurationSnapshot.CreatedAt.Format(time.RFC3339))
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Nicht enthalten (und nach einer Wiederherstellung von Hand zu erledigen):")
|
||||||
|
|
||||||
|
for _, omission := range configurationSnapshot.OmittedForSecurity {
|
||||||
|
fmt.Printf(" – %s\n", wrapForTerminal(omission, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runInspect zeigt den Sicherungssatz eines Repositorys.
|
||||||
|
//
|
||||||
|
// **Ohne Datenbank.** Das ist der Zweck: Wer nach einem Ausfall vor einem
|
||||||
|
// Repository steht, muss sehen koennen, was darin ist, bevor er entscheidet, wie
|
||||||
|
// er weitermacht.
|
||||||
|
func runInspect(commandArguments []string) error {
|
||||||
|
inspectFlags := flag.NewFlagSet("inspect", flag.ContinueOnError)
|
||||||
|
repositoryPath := inspectFlags.String("repo", "", "Pfad des Repositorys")
|
||||||
|
|
||||||
|
if parseError := inspectFlags.Parse(commandArguments); parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if *repositoryPath == "" {
|
||||||
|
return fmt.Errorf("--repo ist erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
configurationSnapshot, readError := disasterrecovery.ReadSnapshot(*repositoryPath)
|
||||||
|
if readError != nil {
|
||||||
|
return readError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Sicherungssatz der Konfiguration")
|
||||||
|
fmt.Printf(" Repository: %s\n", configurationSnapshot.RepositoryID)
|
||||||
|
fmt.Printf(" Erzeugt: %s von %s\n",
|
||||||
|
configurationSnapshot.CreatedAt.Format(time.RFC3339), configurationSnapshot.CreatedBy)
|
||||||
|
fmt.Printf(" Programm: %s\n", configurationSnapshot.ProductVersion)
|
||||||
|
fmt.Printf(" Schemastand: %d\n", configurationSnapshot.SchemaVersion)
|
||||||
|
fmt.Printf(" Inhalt: %s\n", configurationSnapshot.Summary())
|
||||||
|
|
||||||
|
if len(configurationSnapshot.Repositories) > 0 {
|
||||||
|
fmt.Println("\nRepositories:")
|
||||||
|
|
||||||
|
for _, repositoryRecord := range configurationSnapshot.Repositories {
|
||||||
|
fmt.Printf(" %-24s %s\n", repositoryRecord.Name, repositoryRecord.Location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(configurationSnapshot.Jobs) > 0 {
|
||||||
|
fmt.Println("\nAufträge:")
|
||||||
|
|
||||||
|
for _, jobRecord := range configurationSnapshot.Jobs {
|
||||||
|
fmt.Printf(" %-24s %d Quelle(n), Zeitplan %s\n",
|
||||||
|
jobRecord.Name, len(jobRecord.Sources), jobRecord.ScheduleType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nNicht enthalten (nach der Wiederherstellung von Hand zu erledigen):")
|
||||||
|
|
||||||
|
for _, omission := range configurationSnapshot.OmittedForSecurity {
|
||||||
|
fmt.Printf(" – %s\n", wrapForTerminal(omission, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRestore spielt Konfiguration und Katalog ein.
|
||||||
|
func runRestore(commandArguments []string) error {
|
||||||
|
restoreFlags := flag.NewFlagSet("restore", flag.ContinueOnError)
|
||||||
|
repositoryPath := restoreFlags.String("repo", "", "Pfad des Repositorys")
|
||||||
|
includeCatalog := restoreFlags.Bool("catalog", false,
|
||||||
|
"Wiederherstellungspunkte aus den Manifesten übernehmen")
|
||||||
|
|
||||||
|
if parseError := restoreFlags.Parse(commandArguments); parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if *repositoryPath == "" {
|
||||||
|
return fmt.Errorf("--repo ist erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
commandContext, cancelCommand := context.WithTimeout(context.Background(), commandTimeout)
|
||||||
|
defer cancelCommand()
|
||||||
|
|
||||||
|
environment, environmentError := buildEnvironment(commandContext)
|
||||||
|
if environmentError != nil {
|
||||||
|
return environmentError
|
||||||
|
}
|
||||||
|
|
||||||
|
defer environment.close()
|
||||||
|
|
||||||
|
configurationSnapshot, readError := disasterrecovery.ReadSnapshot(*repositoryPath)
|
||||||
|
if readError != nil {
|
||||||
|
return readError
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotImporter := disasterrecovery.NewImporter(environment.databasePool.Connections())
|
||||||
|
|
||||||
|
importResult, importError := snapshotImporter.ImportSnapshot(commandContext, configurationSnapshot)
|
||||||
|
if importError != nil {
|
||||||
|
return importError
|
||||||
|
}
|
||||||
|
|
||||||
|
printImportResult(importResult)
|
||||||
|
|
||||||
|
if *includeCatalog {
|
||||||
|
if catalogError := restoreCatalog(commandContext, environment, *repositoryPath,
|
||||||
|
configurationSnapshot); catalogError != nil {
|
||||||
|
return catalogError
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Println("\nDie Wiederherstellungspunkte wurden NICHT übernommen (--catalog fehlt).")
|
||||||
|
fmt.Println("Ohne sie kennt die Anlage die vorhandenen Sicherungen nicht.")
|
||||||
|
}
|
||||||
|
|
||||||
|
printNextSteps(importResult, *includeCatalog)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreCatalog uebernimmt die Wiederherstellungspunkte aus dem Repository.
|
||||||
|
func restoreCatalog(commandContext context.Context, environment *commandEnvironment,
|
||||||
|
repositoryPath string, configurationSnapshot *disasterrecovery.Snapshot) error {
|
||||||
|
openRepository, openError := repository.Open(commandContext, repositoryPath,
|
||||||
|
repository.OpenOptions{}, environment.logger)
|
||||||
|
if openError != nil {
|
||||||
|
return fmt.Errorf("das repository liess sich nicht oeffnen: %w", openError)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = openRepository.Close() }()
|
||||||
|
|
||||||
|
repositoryDescriptor := openRepository.Descriptor()
|
||||||
|
|
||||||
|
// Das Repository muss dasselbe sein, das der Sicherungssatz beschreibt.
|
||||||
|
// Andernfalls landeten die Wiederherstellungspunkte unter einer fremden
|
||||||
|
// Repository-Kennung — und ein spaeterer Restore suchte sie am falschen Ort.
|
||||||
|
if repositoryDescriptor.RepositoryID != configurationSnapshot.RepositoryID {
|
||||||
|
return fmt.Errorf("das repository traegt die kennung %s, der sicherungssatz "+
|
||||||
|
"beschreibt %s", repositoryDescriptor.RepositoryID, configurationSnapshot.RepositoryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseRepositoryIdentifier, resolveError := resolveRepositoryIdentifier(
|
||||||
|
configurationSnapshot, repositoryDescriptor.RepositoryID)
|
||||||
|
if resolveError != nil {
|
||||||
|
return resolveError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nWiederherstellungspunkte werden aus den Manifesten aufgebaut …")
|
||||||
|
|
||||||
|
catalogImporter := disasterrecovery.NewCatalogImporter(environment.databasePool.Connections())
|
||||||
|
|
||||||
|
catalogResult, catalogError := catalogImporter.ImportCatalog(commandContext,
|
||||||
|
openRepository, databaseRepositoryIdentifier)
|
||||||
|
if catalogError != nil {
|
||||||
|
return catalogError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" %d Wiederherstellungspunkte übernommen, %d bereits bekannt, %d Ketten angelegt\n",
|
||||||
|
catalogResult.BackupsImported, catalogResult.BackupsAlreadyKnown,
|
||||||
|
catalogResult.ChainsCreated)
|
||||||
|
|
||||||
|
if len(catalogResult.UnresolvedParents) > 0 {
|
||||||
|
fmt.Printf(" %d Zusatzsicherung(en) ohne auffindbares Elternbackup. Sie bleiben "+
|
||||||
|
"nutzbar: Syncova-Manifeste sind vollständig.\n", len(catalogResult.UnresolvedParents))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRepositoryIdentifier findet die Datenbankkennung des Repositorys.
|
||||||
|
func resolveRepositoryIdentifier(configurationSnapshot *disasterrecovery.Snapshot,
|
||||||
|
repositoryUUID string) (uuid.UUID, error) {
|
||||||
|
for _, repositoryRecord := range configurationSnapshot.Repositories {
|
||||||
|
if repositoryRecord.RepositoryUUID != repositoryUUID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedIdentifier, parseError := uuid.Parse(repositoryRecord.ID)
|
||||||
|
if parseError != nil {
|
||||||
|
return uuid.Nil, fmt.Errorf("die kennung des repositorys %q ist unlesbar: %w",
|
||||||
|
repositoryRecord.Name, parseError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedIdentifier, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuid.Nil, fmt.Errorf("der sicherungssatz kennt kein repository mit der kennung %s. "+
|
||||||
|
"ohne diese zuordnung liessen sich die wiederherstellungspunkte keinem eingerichteten "+
|
||||||
|
"repository zuordnen", repositoryUUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// printImportResult schreibt das Ergebnis der Konfigurationswiederherstellung.
|
||||||
|
func printImportResult(importResult *disasterrecovery.ImportResult) {
|
||||||
|
fmt.Println("Konfiguration wiederhergestellt:")
|
||||||
|
fmt.Printf(" %d Repositories (als 'nicht erreichbar' — prüfen Sie die Pfade)\n",
|
||||||
|
importResult.RepositoriesRestored)
|
||||||
|
fmt.Printf(" %d Aufbewahrungsregeln\n", importResult.RetentionPoliciesRestored)
|
||||||
|
fmt.Printf(" %d Aufträge mit %d Quellen (ANGEHALTEN)\n",
|
||||||
|
importResult.JobsRestored, importResult.JobSourcesRestored)
|
||||||
|
fmt.Printf(" %d Wartungsfenster\n", importResult.MaintenanceWindowsRestored)
|
||||||
|
fmt.Printf(" %d Benachrichtigungswege (ABGESCHALTET — ohne Zugangsdaten)\n",
|
||||||
|
importResult.NotificationChannelsRestored)
|
||||||
|
fmt.Printf(" %d Konten (DEAKTIVIERT — ohne Passwort)\n", importResult.UsersRestored)
|
||||||
|
fmt.Printf(" %d Einstellungen\n", importResult.SettingsRestored)
|
||||||
|
|
||||||
|
if len(importResult.SkippedExisting) > 0 {
|
||||||
|
fmt.Printf("\n %d Objekt(e) waren bereits vorhanden und wurden NICHT überschrieben:\n",
|
||||||
|
len(importResult.SkippedExisting))
|
||||||
|
|
||||||
|
for _, skippedObject := range importResult.SkippedExisting {
|
||||||
|
fmt.Printf(" – %s\n", skippedObject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printNextSteps schreibt die verbleibenden Handgriffe.
|
||||||
|
//
|
||||||
|
// Sie stehen am Ende der Ausgabe, weil sie das sind, was der Betreiber als
|
||||||
|
// naechstes tut. Eine Wiederherstellung, die mit „fertig" endet und einen
|
||||||
|
// halben Tag spaeter an einem angehaltenen Auftrag scheitert, ist keine.
|
||||||
|
func printNextSteps(importResult *disasterrecovery.ImportResult, catalogImported bool) {
|
||||||
|
fmt.Println("\nWas jetzt noch zu tun ist:")
|
||||||
|
fmt.Println(" 1. Ersten Zugang anlegen: syncova-admin create-admin --username <name>")
|
||||||
|
fmt.Println(" 2. Pfade der Repositories prüfen und sie auf 'aktiv' setzen")
|
||||||
|
|
||||||
|
if !catalogImported {
|
||||||
|
fmt.Println(" 3. Wiederherstellungspunkte übernehmen: syncova-dr restore --repo <pfad> --catalog")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(" 4. Aufträge einzeln prüfen und wieder freigeben")
|
||||||
|
fmt.Println(" 5. Benachrichtigungswege neu einrichten und einschalten")
|
||||||
|
fmt.Println(" 6. Vor der ersten Sicherung eine Prüfung anstoßen — die Wiederherstellbarkeit")
|
||||||
|
fmt.Println(" der übernommenen Punkte ist NICHT belegt: sie wurde nicht neu nachgewiesen.")
|
||||||
|
|
||||||
|
if len(importResult.ManualStepsRequired) > 0 {
|
||||||
|
fmt.Println("\nAus dem Sicherungssatz:")
|
||||||
|
|
||||||
|
for _, manualStep := range importResult.ManualStepsRequired {
|
||||||
|
fmt.Printf(" – %s\n", wrapForTerminal(manualStep, " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// commandEnvironment haelt die aufgebauten Dienste.
|
||||||
|
type commandEnvironment struct {
|
||||||
|
// logger schreibt die Protokollzeilen.
|
||||||
|
logger *slog.Logger
|
||||||
|
// databasePool ist der Verbindungspool; er muss geschlossen werden.
|
||||||
|
databasePool *database.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// close gibt die Betriebsmittel frei.
|
||||||
|
func (environment *commandEnvironment) close() {
|
||||||
|
environment.databasePool.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEnvironment laedt Konfiguration und verbindet die Datenbank.
|
||||||
|
func buildEnvironment(setupContext context.Context) (*commandEnvironment, error) {
|
||||||
|
serviceConfig, configError := config.Load(serviceName)
|
||||||
|
if configError != nil {
|
||||||
|
return nil, configError
|
||||||
|
}
|
||||||
|
|
||||||
|
commandLogger := logging.New(os.Stderr, logging.Options{
|
||||||
|
ServiceName: serviceConfig.ServiceName,
|
||||||
|
Level: serviceConfig.Logging.Level,
|
||||||
|
Format: serviceConfig.Logging.Format,
|
||||||
|
})
|
||||||
|
|
||||||
|
databasePool, databaseError := database.Connect(setupContext, serviceConfig.Database, commandLogger)
|
||||||
|
if databaseError != nil {
|
||||||
|
return nil, databaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
return &commandEnvironment{logger: commandLogger, databasePool: databasePool}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrapForTerminal bricht einen langen Hinweis auf Terminalbreite um.
|
||||||
|
//
|
||||||
|
// Ohne Umbruch laeuft ein dreizeiliger Hinweis als eine Zeile durch das Fenster
|
||||||
|
// und ist genau dann unlesbar, wenn er gebraucht wird.
|
||||||
|
func wrapForTerminal(text string, continuationPrefix string) string {
|
||||||
|
const lineWidth = 76
|
||||||
|
|
||||||
|
words := strings.Fields(text)
|
||||||
|
if len(words) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrappedText strings.Builder
|
||||||
|
|
||||||
|
currentLineLength := 0
|
||||||
|
|
||||||
|
for wordIndex, word := range words {
|
||||||
|
if currentLineLength > 0 && currentLineLength+len(word)+1 > lineWidth {
|
||||||
|
wrappedText.WriteString("\n" + continuationPrefix)
|
||||||
|
currentLineLength = 0
|
||||||
|
} else if wordIndex > 0 {
|
||||||
|
wrappedText.WriteString(" ")
|
||||||
|
currentLineLength++
|
||||||
|
}
|
||||||
|
|
||||||
|
wrappedText.WriteString(word)
|
||||||
|
currentLineLength += len(word)
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrappedText.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// isVersionArgument erkennt eine Versionsabfrage.
|
||||||
|
func isVersionArgument(argument string) bool {
|
||||||
|
return argument == "version" || argument == "--version" || argument == "-version"
|
||||||
|
}
|
||||||
240
apps/api/cmd/syncova-migrate/main.go
Normal file
240
apps/api/cmd/syncova-migrate/main.go
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
// Command syncova-migrate verwaltet das Schema der Control-Plane-Datenbank.
|
||||||
|
//
|
||||||
|
// Migrationen laufen bewusst als eigenes Kommando und nicht beim Start des
|
||||||
|
// API-Dienstes: ein Produktionsstart darf das Schema niemals stillschweigend
|
||||||
|
// verändern (SYNCOVA_DATABASE.md §18).
|
||||||
|
//
|
||||||
|
// Aufruf:
|
||||||
|
//
|
||||||
|
// syncova-migrate up – wendet alle ausstehenden Migrationen an
|
||||||
|
// syncova-migrate status – zeigt den aktuellen Migrationsstand
|
||||||
|
// syncova-migrate down – nimmt genau eine Migration zurück
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/migrations"
|
||||||
|
"github.com/syncova/syncova/packages/platform/config"
|
||||||
|
"github.com/syncova/syncova/packages/platform/database"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serviceName benennt das Kommando in den Logs.
|
||||||
|
const serviceName = "syncova-migrate"
|
||||||
|
|
||||||
|
// confirmDownVariable ist die Umgebungsvariable, die einen Rücklauf in der
|
||||||
|
// Produktion ausdrücklich freigibt.
|
||||||
|
const confirmDownVariable = "SYNCOVA_MIGRATE_CONFIRM_DOWN"
|
||||||
|
|
||||||
|
// buildVersion wird beim Bauen über -ldflags gesetzt.
|
||||||
|
//
|
||||||
|
// Der Vorgabewert gilt nur für einen Bau von Hand; das Auslieferungspaket
|
||||||
|
// brennt die tatsächliche Fassung ein.
|
||||||
|
var buildVersion = "0.1.0-dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if runError := run(); runError != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %v\n", serviceName, runError)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run wertet das Unterkommando aus und führt es aus.
|
||||||
|
func run() error {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
return fmt.Errorf("kein Kommando angegeben.\n\nVerwendung:\n"+
|
||||||
|
" %s up Migrationen anwenden\n"+
|
||||||
|
" %s status Migrationsstand anzeigen\n"+
|
||||||
|
" %s down eine Migration zurücknehmen\n"+
|
||||||
|
" %s force <n> Stand nach abgebrochener Migration setzen",
|
||||||
|
serviceName, serviceName, serviceName, serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Versionsabfrage steht vor dem Laden der Konfiguration: Wer wissen
|
||||||
|
// will, welche Fassung auf einem Server liegt, hat in dem Moment womöglich
|
||||||
|
// keine Datenbank — etwa auf einem frisch ausgepackten Paket.
|
||||||
|
if len(os.Args) > 1 && isVersionArgument(os.Args[1]) {
|
||||||
|
fmt.Printf("%s %s\n", serviceName, buildVersion)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceConfig, configError := config.Load(serviceName)
|
||||||
|
if configError != nil {
|
||||||
|
return configError
|
||||||
|
}
|
||||||
|
|
||||||
|
commandLogger := logging.New(os.Stdout, logging.Options{
|
||||||
|
ServiceName: serviceConfig.ServiceName,
|
||||||
|
Level: serviceConfig.Logging.Level,
|
||||||
|
Format: serviceConfig.Logging.Format,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Die DSN enthält das Passwort und wird deshalb niemals geloggt oder ausgegeben.
|
||||||
|
connectionString := serviceConfig.Database.ConnectionString()
|
||||||
|
|
||||||
|
switch requestedCommand := os.Args[1]; requestedCommand {
|
||||||
|
case "up":
|
||||||
|
return runUp(connectionString, commandLogger, serviceConfig)
|
||||||
|
|
||||||
|
case "status":
|
||||||
|
return runStatus(connectionString, serviceConfig)
|
||||||
|
|
||||||
|
case "down":
|
||||||
|
return runDown(connectionString, commandLogger, serviceConfig)
|
||||||
|
|
||||||
|
case "force":
|
||||||
|
return runForce(connectionString, commandLogger, serviceConfig)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unbekanntes Kommando %q (erlaubt: up, status, down, force)", requestedCommand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runUp wendet alle ausstehenden Migrationen an.
|
||||||
|
func runUp(connectionString string, commandLogger *slog.Logger, serviceConfig config.Config) error {
|
||||||
|
commandLogger.Info("migrationen werden angewandt",
|
||||||
|
slog.String("target", serviceConfig.Database.RedactedConnectionString()))
|
||||||
|
|
||||||
|
appliedMigrations, migrationError := database.MigrateUp(migrations.FS, connectionString)
|
||||||
|
if migrationError != nil {
|
||||||
|
return migrationError
|
||||||
|
}
|
||||||
|
|
||||||
|
if !appliedMigrations {
|
||||||
|
commandLogger.Info("das schema war bereits aktuell")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
migrationState, stateError := database.CurrentState(migrations.FS, connectionString)
|
||||||
|
if stateError != nil {
|
||||||
|
return stateError
|
||||||
|
}
|
||||||
|
|
||||||
|
commandLogger.Info("migrationen angewandt", slog.Uint64("version", uint64(migrationState.Version)))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runStatus gibt den aktuellen Migrationsstand aus.
|
||||||
|
func runStatus(connectionString string, serviceConfig config.Config) error {
|
||||||
|
migrationState, stateError := database.CurrentState(migrations.FS, connectionString)
|
||||||
|
if stateError != nil {
|
||||||
|
return stateError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Datenbank: %s\n", serviceConfig.Database.RedactedConnectionString())
|
||||||
|
|
||||||
|
if !migrationState.HasAnyMigration {
|
||||||
|
fmt.Println("Schemastand: noch nicht migriert")
|
||||||
|
fmt.Println("Nächster Schritt: syncova-migrate up")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Schemastand: Version %d\n", migrationState.Version)
|
||||||
|
|
||||||
|
// Ein abgebrochener Migrationslauf muss deutlich sichtbar sein (PROMPT.md §140).
|
||||||
|
if migrationState.IsDirty {
|
||||||
|
fmt.Println("Zustand: ABGEBROCHENE MIGRATION – das Schema ist in einem unklaren Zustand.")
|
||||||
|
fmt.Println("Nächster Schritt: Prüfen Sie, welche Anweisungen der abgebrochenen Migration")
|
||||||
|
fmt.Println("bereits gewirkt haben, und stellen Sie das Schema von Hand auf einen bekannten")
|
||||||
|
fmt.Printf("Stand. Danach setzen Sie die Markierung mit: %s force <version>\n", serviceName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Zustand: konsistent")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmForceVariable bestätigt das Setzen des Migrationsstands.
|
||||||
|
const confirmForceVariable = "SYNCOVA_MIGRATE_CONFIRM_FORCE"
|
||||||
|
|
||||||
|
// runForce setzt den Migrationsstand nach einer abgebrochenen Migration.
|
||||||
|
//
|
||||||
|
// Das Kommando führt kein SQL aus, sondern behauptet einen Stand. Es ist damit
|
||||||
|
// gefährlicher als jedes andere: Wer es falsch anwendet, lässt die Anwendung
|
||||||
|
// gegen ein Schema arbeiten, das sie für ein anderes hält. Deshalb verlangt es
|
||||||
|
// dieselbe ausdrückliche Bestätigung wie ein Rücklauf — und gibt vorher aus,
|
||||||
|
// was es vorfindet.
|
||||||
|
func runForce(connectionString string, commandLogger *slog.Logger, serviceConfig config.Config) error {
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
return fmt.Errorf("es wurde keine Zielversion angegeben.\n\nVerwendung: %s force <version>", serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetVersion, parseError := strconv.Atoi(os.Args[2])
|
||||||
|
if parseError != nil || targetVersion < 0 {
|
||||||
|
return fmt.Errorf("%q ist keine gültige Migrationsversion", os.Args[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
migrationState, stateError := database.CurrentState(migrations.FS, connectionString)
|
||||||
|
if stateError != nil {
|
||||||
|
return stateError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein force auf einer sauberen Datenbank ist fast immer ein Irrtum: Es gibt
|
||||||
|
// dort nichts zu retten, und die Folge wäre ein übersprungenes Schema.
|
||||||
|
if migrationState.HasAnyMigration && !migrationState.IsDirty {
|
||||||
|
return fmt.Errorf("die Datenbank steht sauber auf Version %d; force ist nur nach einer abgebrochenen Migration gedacht",
|
||||||
|
migrationState.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Vorgefundener Stand: Version %d (abgebrochen: %t)\n", migrationState.Version, migrationState.IsDirty)
|
||||||
|
fmt.Printf("Neuer Stand: Version %d\n\n", targetVersion)
|
||||||
|
fmt.Println("ACHTUNG: Dieses Kommando ändert das Schema NICHT. Es behauptet nur einen Stand.")
|
||||||
|
fmt.Println("Prüfen Sie zuerst, ob das Schema dem Zielstand tatsächlich entspricht.")
|
||||||
|
|
||||||
|
if os.Getenv(confirmForceVariable) != "yes" {
|
||||||
|
return fmt.Errorf("\nabgebrochen: setzen Sie %s=yes, um fortzufahren", confirmForceVariable)
|
||||||
|
}
|
||||||
|
|
||||||
|
if forceError := database.ForceVersion(migrations.FS, connectionString, targetVersion); forceError != nil {
|
||||||
|
return forceError
|
||||||
|
}
|
||||||
|
|
||||||
|
commandLogger.Warn("migrationsstand wurde von hand gesetzt",
|
||||||
|
slog.Int("von_version", int(migrationState.Version)),
|
||||||
|
slog.Int("auf_version", targetVersion),
|
||||||
|
slog.String("target", serviceConfig.Database.RedactedConnectionString()))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDown nimmt genau eine Migration zurück.
|
||||||
|
func runDown(connectionString string, commandLogger *slog.Logger, serviceConfig config.Config) error {
|
||||||
|
// Ein Rücklauf ist potenziell datenzerstörend und in der Produktion nur
|
||||||
|
// nach ausdrücklicher Bestätigung zulässig (PROMPT.md §141).
|
||||||
|
if serviceConfig.Environment.IsProduction() && os.Getenv(confirmDownVariable) != "yes" {
|
||||||
|
return fmt.Errorf("das Zurücknehmen einer Migration kann Daten löschen. "+
|
||||||
|
"In der Produktion ist dafür %s=yes erforderlich", confirmDownVariable)
|
||||||
|
}
|
||||||
|
|
||||||
|
commandLogger.Warn("eine migration wird zurückgenommen",
|
||||||
|
slog.String("target", serviceConfig.Database.RedactedConnectionString()))
|
||||||
|
|
||||||
|
if migrationError := database.MigrateDownOneStep(migrations.FS, connectionString); migrationError != nil {
|
||||||
|
return migrationError
|
||||||
|
}
|
||||||
|
|
||||||
|
migrationState, stateError := database.CurrentState(migrations.FS, connectionString)
|
||||||
|
if stateError != nil {
|
||||||
|
return stateError
|
||||||
|
}
|
||||||
|
|
||||||
|
commandLogger.Info("migration zurückgenommen", slog.Uint64("version", uint64(migrationState.Version)))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isVersionArgument erkennt eine Versionsabfrage.
|
||||||
|
//
|
||||||
|
// Drei Schreibweisen, weil sich niemand merkt, welche ein bestimmtes Programm
|
||||||
|
// erwartet — und weil eine Fehlermeldung auf "--version" der denkbar
|
||||||
|
// schlechteste erste Eindruck ist.
|
||||||
|
func isVersionArgument(argument string) bool {
|
||||||
|
return argument == "version" || argument == "--version" || argument == "-version"
|
||||||
|
}
|
||||||
221
apps/api/cmd/syncova-proxmox/guest.go
Normal file
221
apps/api/cmd/syncova-proxmox/guest.go
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/hypervisor"
|
||||||
|
"github.com/syncova/syncova/packages/platform/config"
|
||||||
|
"github.com/syncova/syncova/packages/platform/crypto"
|
||||||
|
"github.com/syncova/syncova/packages/platform/database"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
"github.com/syncova/syncova/packages/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runListClustersCommand zeigt die eingerichteten Verbünde.
|
||||||
|
func runListClustersCommand(commandArguments []string) error {
|
||||||
|
commandFlags := flag.NewFlagSet("clusters", flag.ContinueOnError)
|
||||||
|
|
||||||
|
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
commandContext, stopSignalListener := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stopSignalListener()
|
||||||
|
|
||||||
|
environment, buildError := buildGuestEnvironment(commandContext)
|
||||||
|
if buildError != nil {
|
||||||
|
return buildError
|
||||||
|
}
|
||||||
|
|
||||||
|
defer environment.close()
|
||||||
|
|
||||||
|
clusterList, listError := environment.clusterStore.ListClusters(commandContext)
|
||||||
|
if listError != nil {
|
||||||
|
return listError
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(clusterList) == 0 {
|
||||||
|
fmt.Println("Es ist kein Virtualisierungsverbund eingerichtet.")
|
||||||
|
fmt.Println("Richten Sie einen über POST /api/v1/proxmox/clusters ein.")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
outputWriter := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(outputWriter, "KENNUNG\tNAME\tADRESSE\tZUGRIFFSWEG\tZUSTAND")
|
||||||
|
|
||||||
|
for _, singleCluster := range clusterList {
|
||||||
|
fmt.Fprintf(outputWriter, "%s\t%s\t%s\t%s\t%s\n",
|
||||||
|
singleCluster.ID, singleCluster.Name, singleCluster.APIEndpoint,
|
||||||
|
singleCluster.ArchiveTransport, singleCluster.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputWriter.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRestoreGuestCommand stellt einen gesicherten Gast wieder her.
|
||||||
|
//
|
||||||
|
// Das ist das Werkzeug für den verpflichtenden Meilenstein der Phase 7: VM
|
||||||
|
// entdecken → sichern → verifizieren → Test-VM löschen → wiederherstellen →
|
||||||
|
// **booten** → validieren. Die letzten beiden Schritte kann keine Software
|
||||||
|
// belegen, die keinen echten Knoten hat.
|
||||||
|
func runRestoreGuestCommand(commandArguments []string) error {
|
||||||
|
commandFlags := flag.NewFlagSet("restore-guest", flag.ContinueOnError)
|
||||||
|
clusterIdentifier := commandFlags.String("cluster", "", "Kennung des Zielverbunds")
|
||||||
|
repositoryPath := commandFlags.String("repository", "", "Pfad des Repositorys")
|
||||||
|
backupIdentifier := commandFlags.String("backup", "", "Kennung des Backups im Repository")
|
||||||
|
targetGuest := commandFlags.String("target-guest", "",
|
||||||
|
"Zielkennung, etwa qemu/900 — ohne Angabe wird der Ursprungsgast überschrieben")
|
||||||
|
targetNode := commandFlags.String("node", "", "Zielknoten; ohne Angabe der Ursprungsknoten")
|
||||||
|
targetStorage := commandFlags.String("storage", "", "Zielspeicher für die Platten")
|
||||||
|
overwriteExisting := commandFlags.Bool("overwrite", false,
|
||||||
|
"Einen vorhandenen Gast überschreiben — vernichtet dessen aktuellen Stand")
|
||||||
|
startAfterRestore := commandFlags.Bool("start", false,
|
||||||
|
"Den Gast nach der Wiederherstellung starten")
|
||||||
|
keepStagedArchive := commandFlags.Bool("keep-archive", false,
|
||||||
|
"Das bereitgestellte Archiv auf dem Knoten liegen lassen")
|
||||||
|
|
||||||
|
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if *clusterIdentifier == "" || *repositoryPath == "" || *backupIdentifier == "" {
|
||||||
|
return errors.New("--cluster, --repository und --backup sind erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedClusterID, parseError := uuid.Parse(*clusterIdentifier)
|
||||||
|
if parseError != nil {
|
||||||
|
return fmt.Errorf("die verbundkennung %q ist keine gueltige uuid", *clusterIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Überschreiben verlangt eine ausdrückliche Bestätigung.
|
||||||
|
//
|
||||||
|
// Dieselbe Überlegung wie bei der Wiederherstellung von Dateien (Phase 9):
|
||||||
|
// Ein versehentlich gesetztes Kennzeichen in einem Skript darf nicht
|
||||||
|
// genügen, um einen laufenden Gast zu ersetzen.
|
||||||
|
if *overwriteExisting && *targetGuest == "" {
|
||||||
|
fmt.Fprintln(os.Stderr,
|
||||||
|
"WARNUNG: Ohne --target-guest wird der Ursprungsgast überschrieben.\n"+
|
||||||
|
" Sein aktueller Stand geht dabei verloren.")
|
||||||
|
}
|
||||||
|
|
||||||
|
commandContext, stopSignalListener := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stopSignalListener()
|
||||||
|
|
||||||
|
environment, buildError := buildGuestEnvironment(commandContext)
|
||||||
|
if buildError != nil {
|
||||||
|
return buildError
|
||||||
|
}
|
||||||
|
|
||||||
|
defer environment.close()
|
||||||
|
|
||||||
|
restoreResult, restoreError := environment.clusterStore.RestoreGuest(commandContext,
|
||||||
|
hypervisor.GuestRestoreRequest{
|
||||||
|
ClusterID: parsedClusterID,
|
||||||
|
RepositoryPath: *repositoryPath,
|
||||||
|
BackupID: *backupIdentifier,
|
||||||
|
TargetGuestID: *targetGuest,
|
||||||
|
TargetNode: *targetNode,
|
||||||
|
TargetStorageID: *targetStorage,
|
||||||
|
OverwriteExisting: *overwriteExisting,
|
||||||
|
StartAfterRestore: *startAfterRestore,
|
||||||
|
KeepStagedArchive: *keepStagedArchive,
|
||||||
|
ProgressCallback: func(currentProgress providers.RestoreProgress) {
|
||||||
|
fmt.Printf(" [%3.0f %%] %s — %s\n",
|
||||||
|
currentProgress.PercentComplete, currentProgress.Stage, currentProgress.Message)
|
||||||
|
},
|
||||||
|
}, environment.secretStore, environment.logger)
|
||||||
|
if restoreError != nil {
|
||||||
|
return restoreError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nWiederhergestellt: %s auf Knoten %s\n", restoreResult.GuestID, restoreResult.NodeName)
|
||||||
|
fmt.Printf("Bereitgestellt: %s (%s)\n",
|
||||||
|
formatBytes(restoreResult.BytesStaged), restoreResult.ArchiveVolume)
|
||||||
|
fmt.Printf("Dauer: %s\n", restoreResult.Duration.Round(1e6))
|
||||||
|
|
||||||
|
if restoreResult.Started {
|
||||||
|
fmt.Println("Zustand: gestartet")
|
||||||
|
} else {
|
||||||
|
// Nicht gestartet ist kein Mangel, sondern die Vorgabe. Der Satz steht
|
||||||
|
// da, damit niemand auf die laufende Maschine wartet.
|
||||||
|
fmt.Println("Zustand: angehalten — starten Sie ihn, wenn das Netz dafür bereit ist")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, singleWarning := range restoreResult.Warnings {
|
||||||
|
fmt.Printf("Hinweis: %s\n", singleWarning)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// guestCommandEnvironment hält die für die Gastbefehle nötigen Dienste.
|
||||||
|
type guestCommandEnvironment struct {
|
||||||
|
// logger schreibt die Protokollzeilen.
|
||||||
|
logger *slog.Logger
|
||||||
|
// databasePool ist der Verbindungspool; er muss geschlossen werden.
|
||||||
|
databasePool *database.Pool
|
||||||
|
// clusterStore verwaltet die Verbünde.
|
||||||
|
clusterStore *hypervisor.Store
|
||||||
|
// secretStore entschlüsselt die Daten im Repository.
|
||||||
|
secretStore crypto.SecretStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// close gibt die Betriebsmittel frei.
|
||||||
|
func (environment *guestCommandEnvironment) close() {
|
||||||
|
environment.databasePool.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildGuestEnvironment lädt Konfiguration, Datenbank und Schlüsselmaterial.
|
||||||
|
//
|
||||||
|
// Anders als `discover` brauchen diese Befehle die Control Plane: Die
|
||||||
|
// Zugangsdaten des Verbunds liegen verschlüsselt in der Datenbank, und ohne
|
||||||
|
// Schlüsselmaterial lässt sich weder das API-Token entschlüsseln noch das
|
||||||
|
// Backup lesen.
|
||||||
|
func buildGuestEnvironment(setupContext context.Context) (*guestCommandEnvironment, error) {
|
||||||
|
serviceConfig, configError := config.Load(serviceName)
|
||||||
|
if configError != nil {
|
||||||
|
return nil, configError
|
||||||
|
}
|
||||||
|
|
||||||
|
commandLogger := logging.New(os.Stderr, logging.Options{
|
||||||
|
ServiceName: serviceConfig.ServiceName,
|
||||||
|
Level: serviceConfig.Logging.Level,
|
||||||
|
Format: serviceConfig.Logging.Format,
|
||||||
|
})
|
||||||
|
|
||||||
|
secretStore, secretStoreError := crypto.NewLocalSecretStore(
|
||||||
|
serviceConfig.Encryption.Keys(), serviceConfig.Encryption.CurrentKeyVersion)
|
||||||
|
if secretStoreError != nil {
|
||||||
|
return nil, fmt.Errorf("die verschluesselung konnte nicht eingerichtet werden: %w", secretStoreError)
|
||||||
|
}
|
||||||
|
|
||||||
|
databasePool, databaseError := database.Connect(setupContext, serviceConfig.Database, commandLogger)
|
||||||
|
if databaseError != nil {
|
||||||
|
return nil, databaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterStore, storeError := hypervisor.NewStore(databasePool.Connections(), secretStore)
|
||||||
|
if storeError != nil {
|
||||||
|
databasePool.Close()
|
||||||
|
|
||||||
|
return nil, storeError
|
||||||
|
}
|
||||||
|
|
||||||
|
return &guestCommandEnvironment{
|
||||||
|
logger: commandLogger,
|
||||||
|
databasePool: databasePool,
|
||||||
|
clusterStore: clusterStore,
|
||||||
|
secretStore: secretStore,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
282
apps/api/cmd/syncova-proxmox/main.go
Normal file
282
apps/api/cmd/syncova-proxmox/main.go
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
// Kommando syncova-proxmox prüft die Anbindung an einen Proxmox-VE-Verbund.
|
||||||
|
//
|
||||||
|
// Es ist das Werkzeug, mit dem sich der Provider gegen eine echte Umgebung
|
||||||
|
// belegen lässt. Auf dem Entwicklungsrechner steht keine zur Verfügung; ohne
|
||||||
|
// dieses Kommando bliebe die Anbindung allein gegen einen Nachbau geprüft.
|
||||||
|
//
|
||||||
|
// `discover` verändert nichts: alle Aufrufe sind lesend. Das Sichern gehört in
|
||||||
|
// den Auftrag und nicht in ein Werkzeug — die Wiederherstellung eines Gasts
|
||||||
|
// dagegen steht hier, weil sie im Ernstfall gebraucht wird, wenn die
|
||||||
|
// Weboberfläche womöglich gerade nicht läuft.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
"github.com/syncova/syncova/packages/providers"
|
||||||
|
"github.com/syncova/syncova/packages/providers/proxmox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serviceName benennt den Dienst in den Protokollen.
|
||||||
|
const serviceName = "syncova-proxmox"
|
||||||
|
|
||||||
|
// tokenSecretVariable ist die Umgebungsvariable mit dem Tokengeheimnis.
|
||||||
|
//
|
||||||
|
// Das Geheimnis kommt niemals als Aufrufparameter: Aufrufparameter stehen in
|
||||||
|
// der Prozessliste und in der Shell-Historie (PROMPT.md §141).
|
||||||
|
const tokenSecretVariable = "SYNCOVA_PROXMOX_TOKEN_SECRET"
|
||||||
|
|
||||||
|
// buildVersion wird beim Bauen gesetzt.
|
||||||
|
var buildVersion = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
fmt.Fprintln(os.Stderr, usageText())
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
var commandError error
|
||||||
|
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "discover":
|
||||||
|
commandError = runDiscoverCommand(os.Args[2:])
|
||||||
|
case "clusters":
|
||||||
|
commandError = runListClustersCommand(os.Args[2:])
|
||||||
|
case "restore-guest":
|
||||||
|
commandError = runRestoreGuestCommand(os.Args[2:])
|
||||||
|
case "version", "--version", "-version":
|
||||||
|
fmt.Printf("syncova-proxmox %s\n", buildVersion)
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
fmt.Println(usageText())
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "Unbekanntes Kommando: %s\n\n%s\n", os.Args[1], usageText())
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if commandError != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "syncova-proxmox: %v\n", commandError)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// usageText beschreibt die Verwendung.
|
||||||
|
func usageText() string {
|
||||||
|
return `Verwendung:
|
||||||
|
syncova-proxmox discover --url <https://pve:8006> --token <user@realm!name>
|
||||||
|
Erfasst Verbund, Knoten, Gäste und Platten
|
||||||
|
syncova-proxmox clusters Zeigt die eingerichteten Verbünde
|
||||||
|
syncova-proxmox restore-guest --cluster <id> --repository <pfad> --backup <id>
|
||||||
|
Stellt einen gesicherten Gast wieder her
|
||||||
|
syncova-proxmox version Zeigt die Version
|
||||||
|
|
||||||
|
Das Tokengeheimnis kommt aus ` + tokenSecretVariable + ` — niemals als Aufrufparameter.
|
||||||
|
|
||||||
|
Ein API-Token mit Leserechten genügt für die Erfassung:
|
||||||
|
pveum user add syncova@pve
|
||||||
|
pveum aclmod / --user syncova@pve --role PVEAuditor
|
||||||
|
pveum user token add syncova@pve backup --privsep 0
|
||||||
|
|
||||||
|
Die Kommandos clusters und restore-guest brauchen die Control Plane: Die
|
||||||
|
Zugangsdaten der Verbünde liegen verschlüsselt in der Datenbank. Sie lesen
|
||||||
|
dieselben Umgebungsvariablen wie der Dienst (SYNCOVA_DATABASE_*,
|
||||||
|
SYNCOVA_ENCRYPTION_KEYS).
|
||||||
|
|
||||||
|
Proxmox liefert ab Werk ein selbstsigniertes Zertifikat. Statt die Prüfung
|
||||||
|
abzuschalten, hinterlegen Sie seinen Fingerabdruck mit --fingerprint; er steht
|
||||||
|
in der Weboberfläche unter Certificates.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDiscoverCommand erfasst einen Proxmox-Verbund.
|
||||||
|
func runDiscoverCommand(commandArguments []string) error {
|
||||||
|
commandFlags := flag.NewFlagSet("discover", flag.ContinueOnError)
|
||||||
|
endpointURL := commandFlags.String("url", "", "Adresse des Proxmox-Endpunkts")
|
||||||
|
apiTokenID := commandFlags.String("token", "", "Kennung des API-Tokens, etwa syncova@pve!backup")
|
||||||
|
certificateFingerprint := commandFlags.String("fingerprint", "", "SHA-256-Fingerabdruck des Serverzertifikats")
|
||||||
|
skipTLSVerification := commandFlags.Bool("insecure", false, "Zertifikatsprüfung abschalten (nur für Labore)")
|
||||||
|
showDisks := commandFlags.Bool("disks", false, "Auch die Platten jedes Gasts auflisten")
|
||||||
|
|
||||||
|
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if *endpointURL == "" || *apiTokenID == "" {
|
||||||
|
return errors.New("--url und --token sind erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenSecret := strings.TrimSpace(os.Getenv(tokenSecretVariable))
|
||||||
|
if tokenSecret == "" {
|
||||||
|
return fmt.Errorf("das tokengeheimnis muss in %s stehen.\n"+
|
||||||
|
"Es wird bewusst nicht als Aufrufparameter angenommen: Parameter stehen in der Prozessliste", tokenSecretVariable)
|
||||||
|
}
|
||||||
|
|
||||||
|
if *certificateFingerprint == "" && !*skipTLSVerification {
|
||||||
|
fmt.Fprintln(os.Stderr,
|
||||||
|
"Hinweis: Ohne --fingerprint wird gegen die Zertifikatsspeicher des Systems geprüft.\n"+
|
||||||
|
" Das schlägt bei einem selbstsignierten Proxmox-Zertifikat fehl.")
|
||||||
|
}
|
||||||
|
|
||||||
|
commandLogger := logging.New(os.Stderr, logging.Options{
|
||||||
|
ServiceName: serviceName, Level: "warn", Format: "text",
|
||||||
|
})
|
||||||
|
|
||||||
|
proxmoxProvider, providerError := proxmox.NewProvider(proxmox.ProviderOptions{
|
||||||
|
ClientOptions: proxmox.ClientOptions{
|
||||||
|
BaseURL: *endpointURL,
|
||||||
|
APITokenID: *apiTokenID,
|
||||||
|
APITokenSecret: tokenSecret,
|
||||||
|
TLSFingerprintSHA256: *certificateFingerprint,
|
||||||
|
InsecureSkipTLSVerify: *skipTLSVerification,
|
||||||
|
},
|
||||||
|
}, commandLogger)
|
||||||
|
if providerError != nil {
|
||||||
|
return providerError
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveryContext, stopSignalListener := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stopSignalListener()
|
||||||
|
|
||||||
|
defer func() { _ = proxmoxProvider.Disconnect() }()
|
||||||
|
|
||||||
|
if connectError := proxmoxProvider.Connect(discoveryContext); connectError != nil {
|
||||||
|
return connectError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Verbunden mit %s\n\n", *endpointURL)
|
||||||
|
|
||||||
|
return printDiscovery(discoveryContext, proxmoxProvider, *showDisks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// printDiscovery gibt die Erfassung aus.
|
||||||
|
func printDiscovery(discoveryContext context.Context, proxmoxProvider *proxmox.Provider, showDisks bool) error {
|
||||||
|
discoveredClusters, clusterError := proxmoxProvider.ListClusters(discoveryContext)
|
||||||
|
if clusterError != nil {
|
||||||
|
return clusterError
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, discoveredCluster := range discoveredClusters {
|
||||||
|
quorumText := "beschlussfähig"
|
||||||
|
if !discoveredCluster.Quorate {
|
||||||
|
// Ein Verbund ohne Quorum nimmt keine ändernden Aufrufe an. Das
|
||||||
|
// jetzt zu wissen erspart eine Reihe unverständlicher Fehler.
|
||||||
|
quorumText = "NICHT beschlussfähig — ändernde Aufrufe werden abgelehnt"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Verbund: %s (%d Knoten, %s)\n", discoveredCluster.Name, discoveredCluster.HostCount, quorumText)
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveredHosts, hostError := proxmoxProvider.ListHosts(discoveryContext, "")
|
||||||
|
if hostError != nil {
|
||||||
|
return hostError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nKnoten:")
|
||||||
|
hostWriter := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(hostWriter, " NAME\tZUSTAND\tCPUS\tSPEICHER")
|
||||||
|
|
||||||
|
for _, discoveredHost := range discoveredHosts {
|
||||||
|
hostState := "erreichbar"
|
||||||
|
if !discoveredHost.Online {
|
||||||
|
hostState = "NICHT ERREICHBAR"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(hostWriter, " %s\t%s\t%d\t%s\n",
|
||||||
|
discoveredHost.Name, hostState, discoveredHost.CPUCount, formatBytes(discoveredHost.MemoryBytes))
|
||||||
|
}
|
||||||
|
_ = hostWriter.Flush()
|
||||||
|
|
||||||
|
discoveredGuests, guestError := proxmoxProvider.ListVMs(discoveryContext, "")
|
||||||
|
if guestError != nil {
|
||||||
|
return guestError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nGäste (%d):\n", len(discoveredGuests))
|
||||||
|
guestWriter := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(guestWriter, " KENNUNG\tNAME\tART\tKNOTEN\tZUSTAND\tETIKETTEN")
|
||||||
|
|
||||||
|
for _, discoveredGuest := range discoveredGuests {
|
||||||
|
guestKind := "VM"
|
||||||
|
if discoveredGuest.GuestType == providers.GuestTypeContainer {
|
||||||
|
guestKind = "Container"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(guestWriter, " %s\t%s\t%s\t%s\t%s\t%s\n",
|
||||||
|
discoveredGuest.Identifier, discoveredGuest.Name, guestKind,
|
||||||
|
discoveredGuest.HostID, discoveredGuest.PowerState, strings.Join(discoveredGuest.Tags, ", "))
|
||||||
|
}
|
||||||
|
_ = guestWriter.Flush()
|
||||||
|
|
||||||
|
if !showDisks {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return printDisks(discoveryContext, proxmoxProvider, discoveredGuests)
|
||||||
|
}
|
||||||
|
|
||||||
|
// printDisks gibt die Platten aller Gäste aus.
|
||||||
|
func printDisks(diskContext context.Context, proxmoxProvider *proxmox.Provider, discoveredGuests []providers.Guest) error {
|
||||||
|
fmt.Println("\nPlatten:")
|
||||||
|
|
||||||
|
var excludedDiskCount int
|
||||||
|
|
||||||
|
for _, discoveredGuest := range discoveredGuests {
|
||||||
|
guestDisks, diskError := proxmoxProvider.GetVMDisks(diskContext, discoveredGuest.Identifier)
|
||||||
|
if diskError != nil {
|
||||||
|
// Ein einzelner unlesbarer Gast darf die Übersicht nicht
|
||||||
|
// verhindern — verschwiegen wird er trotzdem nicht.
|
||||||
|
fmt.Printf(" %s (%s): NICHT LESBAR — %v\n", discoveredGuest.Identifier, discoveredGuest.Name, diskError)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" %s (%s):\n", discoveredGuest.Identifier, discoveredGuest.Name)
|
||||||
|
|
||||||
|
for _, guestDisk := range guestDisks {
|
||||||
|
exclusionNote := ""
|
||||||
|
if guestDisk.ExcludedFromBackup {
|
||||||
|
exclusionNote = " ← backup=0: NICHT im Backup enthalten"
|
||||||
|
excludedDiskCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" %-10s %-14s %8s %s%s\n",
|
||||||
|
guestDisk.Identifier, guestDisk.StorageID, formatBytes(guestDisk.SizeBytes),
|
||||||
|
guestDisk.Format, exclusionNote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if excludedDiskCount > 0 {
|
||||||
|
// Diese Warnung ist der eigentliche Wert der Plattenübersicht: Wer sie
|
||||||
|
// nicht kennt, hält eine unvollständige Maschine für vollständig.
|
||||||
|
fmt.Printf("\nAchtung: %d Platte(n) sind in Proxmox mit backup=0 von der Sicherung ausgenommen.\n"+
|
||||||
|
"Eine Wiederherstellung ergibt dann eine unvollständige Maschine.\n", excludedDiskCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// byteUnitSuffixes sind die Einheiten der Größenausgabe.
|
||||||
|
var byteUnitSuffixes = []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"}
|
||||||
|
|
||||||
|
// formatBytes gibt eine Bytezahl lesbar aus.
|
||||||
|
func formatBytes(byteCount int64) string {
|
||||||
|
if byteCount < 1024 {
|
||||||
|
return fmt.Sprintf("%d B", byteCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
scaledValue := float64(byteCount)
|
||||||
|
unitIndex := 0
|
||||||
|
|
||||||
|
for scaledValue >= 1024 && unitIndex < len(byteUnitSuffixes)-1 {
|
||||||
|
scaledValue /= 1024
|
||||||
|
unitIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%.1f %s", scaledValue, byteUnitSuffixes[unitIndex])
|
||||||
|
}
|
||||||
674
apps/api/cmd/syncova-repo/main.go
Normal file
674
apps/api/cmd/syncova-repo/main.go
Normal file
@ -0,0 +1,674 @@
|
|||||||
|
// Command syncova-repo verwaltet Backup-Repositories von der Kommandozeile.
|
||||||
|
//
|
||||||
|
// Das Kommando arbeitet ausschließlich auf dem Repository selbst und benötigt
|
||||||
|
// keine Datenbank. Genau das ist der Zweck: nach dem Verlust des Control Servers
|
||||||
|
// muss ein Repository allein mit diesem Werkzeug wieder nutzbar werden
|
||||||
|
// (PROMPT.md §46, SYNCOVA_ARCHITECTURE.md §11).
|
||||||
|
//
|
||||||
|
// Aufruf:
|
||||||
|
//
|
||||||
|
// syncova-repo create --path <pfad> [--name <name>] [--hardened]
|
||||||
|
// syncova-repo info --path <pfad>
|
||||||
|
// syncova-repo list --path <pfad>
|
||||||
|
// syncova-repo scan --path <pfad> [--deep]
|
||||||
|
// syncova-repo rebuild --path <pfad>
|
||||||
|
// syncova-repo health --path <pfad>
|
||||||
|
// syncova-repo prune --path <pfad> [--apply]
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/backupformat"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
"github.com/syncova/syncova/packages/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serviceName benennt das Kommando in den Logs.
|
||||||
|
const serviceName = "syncova-repo"
|
||||||
|
|
||||||
|
// buildVersion wird beim Bauen über -ldflags gesetzt.
|
||||||
|
var buildVersion = "0.1.0-dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if runError := run(); runError != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s: %v\n", serviceName, runError)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run wertet das Unterkommando aus.
|
||||||
|
func run() error {
|
||||||
|
// Die Versionsabfrage steht vor allem anderen: Wer wissen will, welche
|
||||||
|
// Fassung auf einem Server liegt, hat in dem Moment womöglich keine
|
||||||
|
// Konfiguration — etwa auf einem frisch ausgepackten Paket.
|
||||||
|
if len(os.Args) > 1 && isVersionArgument(os.Args[1]) {
|
||||||
|
fmt.Printf("%s %s\n", serviceName, buildVersion)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
return errors.New(usageText())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Werkzeug protokolliert nach stderr, damit die Ausgabe auf stdout
|
||||||
|
// weiterverarbeitet werden kann.
|
||||||
|
commandLogger := logging.New(os.Stderr, logging.Options{
|
||||||
|
ServiceName: serviceName,
|
||||||
|
Level: "warn",
|
||||||
|
Format: "text",
|
||||||
|
})
|
||||||
|
|
||||||
|
commandArguments := os.Args[2:]
|
||||||
|
|
||||||
|
switch requestedCommand := os.Args[1]; requestedCommand {
|
||||||
|
case "create":
|
||||||
|
return runCreate(commandArguments, commandLogger)
|
||||||
|
case "info":
|
||||||
|
return runInfo(commandArguments, commandLogger)
|
||||||
|
case "list":
|
||||||
|
return runList(commandArguments, commandLogger)
|
||||||
|
case "scan":
|
||||||
|
return runScan(commandArguments, commandLogger)
|
||||||
|
case "rebuild":
|
||||||
|
return runRebuild(commandArguments, commandLogger)
|
||||||
|
case "health":
|
||||||
|
return runHealth(commandArguments, commandLogger)
|
||||||
|
case "prune":
|
||||||
|
return runPrune(commandArguments, commandLogger)
|
||||||
|
case "break-lock":
|
||||||
|
return runBreakLock(commandArguments)
|
||||||
|
case "export":
|
||||||
|
return runExport(commandArguments, commandLogger)
|
||||||
|
case "import":
|
||||||
|
return runImport(commandArguments, commandLogger)
|
||||||
|
case "inspect":
|
||||||
|
return runInspect(commandArguments)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unbekanntes Kommando %q\n\n%s", requestedCommand, usageText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// usageText beschreibt die Verwendung des Kommandos.
|
||||||
|
func usageText() string {
|
||||||
|
return `Verwendung:
|
||||||
|
syncova-repo create --path <pfad> [--name <name>] [--hardened] Legt ein Repository an
|
||||||
|
syncova-repo info --path <pfad> Zeigt die Repository-Angaben
|
||||||
|
syncova-repo list --path <pfad> Listet alle Backups
|
||||||
|
syncova-repo scan --path <pfad> [--deep] Prüft die Unversehrtheit
|
||||||
|
syncova-repo rebuild --path <pfad> Baut den Katalog neu auf
|
||||||
|
syncova-repo health --path <pfad> Zeigt den Zustand
|
||||||
|
syncova-repo break-lock --path <pfad> Entfernt eine haengende Sperre
|
||||||
|
syncova-repo prune --path <pfad> [--apply] Entfernt verwaiste Chunks
|
||||||
|
syncova-repo export --path <pfad> --backup <id> --out <datei> Schreibt ein Backup als Container
|
||||||
|
syncova-repo import --path <pfad> --in <datei> Liest einen Container ein
|
||||||
|
syncova-repo inspect --in <datei> Prüft einen Container ohne Import`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePathFlag liest den Pfad eines Unterkommandos.
|
||||||
|
func parsePathFlag(commandName string, commandArguments []string, additionalFlags func(*flag.FlagSet)) (string, error) {
|
||||||
|
commandFlags := flag.NewFlagSet(commandName, flag.ContinueOnError)
|
||||||
|
repositoryPath := commandFlags.String("path", "", "Pfad des Repositorys")
|
||||||
|
|
||||||
|
if additionalFlags != nil {
|
||||||
|
additionalFlags(commandFlags)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
|
||||||
|
return "", parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if *repositoryPath == "" {
|
||||||
|
return "", errors.New("--path ist erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
return *repositoryPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCreate legt ein Repository an.
|
||||||
|
func runCreate(commandArguments []string, commandLogger *slog.Logger) error {
|
||||||
|
var repositoryName string
|
||||||
|
var isHardened bool
|
||||||
|
|
||||||
|
repositoryPath, parseError := parsePathFlag("create", commandArguments, func(commandFlags *flag.FlagSet) {
|
||||||
|
commandFlags.StringVar(&repositoryName, "name", "", "Sprechender Name des Repositorys")
|
||||||
|
commandFlags.BoolVar(&isHardened, "hardened", false, "Gehärtetes Repository mit Aufbewahrungsschutz")
|
||||||
|
})
|
||||||
|
if parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
repositoryKind := repository.KindLocal
|
||||||
|
if isHardened {
|
||||||
|
repositoryKind = repository.KindHardenedLinux
|
||||||
|
}
|
||||||
|
|
||||||
|
createdRepository, createError := repository.Create(context.Background(), repositoryPath, repository.CreateOptions{
|
||||||
|
Name: repositoryName,
|
||||||
|
Kind: repositoryKind,
|
||||||
|
CreatedByVersion: buildVersion,
|
||||||
|
}, commandLogger)
|
||||||
|
if createError != nil {
|
||||||
|
return createError
|
||||||
|
}
|
||||||
|
defer func() { _ = createdRepository.Close() }()
|
||||||
|
|
||||||
|
descriptor := createdRepository.Descriptor()
|
||||||
|
|
||||||
|
fmt.Printf("Repository angelegt.\n\n")
|
||||||
|
fmt.Printf(" Pfad: %s\n", createdRepository.RootPath())
|
||||||
|
fmt.Printf(" Kennung: %s\n", descriptor.RepositoryID)
|
||||||
|
fmt.Printf(" Art: %s\n", descriptor.Kind)
|
||||||
|
fmt.Printf(" Immutable: %s\n", formatBoolean(descriptor.Immutable))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runInfo zeigt die Angaben eines Repositorys.
|
||||||
|
func runInfo(commandArguments []string, commandLogger *slog.Logger) error {
|
||||||
|
repositoryPath, parseError := parsePathFlag("info", commandArguments, nil)
|
||||||
|
if parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
|
||||||
|
repository.OpenOptions{ReadOnly: true}, commandLogger)
|
||||||
|
if openError != nil {
|
||||||
|
return openError
|
||||||
|
}
|
||||||
|
defer func() { _ = openedRepository.Close() }()
|
||||||
|
|
||||||
|
descriptor := openedRepository.Descriptor()
|
||||||
|
|
||||||
|
fmt.Printf(" Pfad: %s\n", openedRepository.RootPath())
|
||||||
|
fmt.Printf(" Name: %s\n", descriptor.Name)
|
||||||
|
fmt.Printf(" Kennung: %s\n", descriptor.RepositoryID)
|
||||||
|
fmt.Printf(" Formatversion: %d\n", descriptor.FormatVersion)
|
||||||
|
fmt.Printf(" Art: %s\n", descriptor.Kind)
|
||||||
|
fmt.Printf(" Hashverfahren: %s\n", descriptor.HashAlgorithm)
|
||||||
|
fmt.Printf(" Immutable: %s\n", formatBoolean(descriptor.Immutable))
|
||||||
|
fmt.Printf(" Angelegt am: %s\n", descriptor.CreatedAt.Format(time.RFC3339))
|
||||||
|
fmt.Printf(" Angelegt mit: %s\n", descriptor.CreatedByVersion)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runList listet alle Backups eines Repositorys.
|
||||||
|
func runList(commandArguments []string, commandLogger *slog.Logger) error {
|
||||||
|
repositoryPath, parseError := parsePathFlag("list", commandArguments, nil)
|
||||||
|
if parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
|
||||||
|
repository.OpenOptions{ReadOnly: true}, commandLogger)
|
||||||
|
if openError != nil {
|
||||||
|
return openError
|
||||||
|
}
|
||||||
|
defer func() { _ = openedRepository.Close() }()
|
||||||
|
|
||||||
|
catalogEntries, listError := openedRepository.ListBackups(context.Background())
|
||||||
|
if listError != nil {
|
||||||
|
return listError
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(catalogEntries) == 0 {
|
||||||
|
fmt.Println("Das Repository enthält noch keine Backups.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
outputTable := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(outputTable, "BACKUP\tQUELLE\tART\tABGESCHLOSSEN\tDATEN\tABGELEGT\tSCHUTZ")
|
||||||
|
|
||||||
|
for _, catalogEntry := range catalogEntries {
|
||||||
|
retentionText := "-"
|
||||||
|
if catalogEntry.ImmutableUntil != nil {
|
||||||
|
retentionText = catalogEntry.ImmutableUntil.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(outputTable, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||||
|
catalogEntry.BackupID,
|
||||||
|
catalogEntry.SourceName,
|
||||||
|
catalogEntry.BackupType,
|
||||||
|
catalogEntry.CompletedAt.Format("2006-01-02 15:04"),
|
||||||
|
formatBytes(catalogEntry.LogicalBytes),
|
||||||
|
formatBytes(catalogEntry.StoredBytes),
|
||||||
|
retentionText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputTable.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// runScan prüft die Unversehrtheit eines Repositorys.
|
||||||
|
func runScan(commandArguments []string, commandLogger *slog.Logger) error {
|
||||||
|
var deepScan bool
|
||||||
|
|
||||||
|
repositoryPath, parseError := parsePathFlag("scan", commandArguments, func(commandFlags *flag.FlagSet) {
|
||||||
|
commandFlags.BoolVar(&deepScan, "deep", false, "Inhalt jedes Chunks neu berechnen (langsam, aber vollständig)")
|
||||||
|
})
|
||||||
|
if parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
|
||||||
|
repository.OpenOptions{ReadOnly: true}, commandLogger)
|
||||||
|
if openError != nil {
|
||||||
|
return openError
|
||||||
|
}
|
||||||
|
defer func() { _ = openedRepository.Close() }()
|
||||||
|
|
||||||
|
scanReport, scanError := openedRepository.Scan(context.Background(), repository.ScanOptions{
|
||||||
|
VerifyChunkContents: deepScan,
|
||||||
|
})
|
||||||
|
if scanError != nil {
|
||||||
|
return scanError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Geprüfte Backups: %d\n", scanReport.BackupsChecked)
|
||||||
|
fmt.Printf("Davon einwandfrei: %d\n", scanReport.BackupsHealthy)
|
||||||
|
fmt.Printf("Geprüfte Chunks: %d\n", scanReport.ChunksChecked)
|
||||||
|
|
||||||
|
if deepScan {
|
||||||
|
fmt.Printf("Gelesene Daten: %s\n", formatBytes(scanReport.BytesChecked))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Fehlende Chunks: %d\n", scanReport.MissingChunks)
|
||||||
|
fmt.Printf("Beschädigte Chunks: %d\n", scanReport.CorruptedChunks)
|
||||||
|
fmt.Printf("Verwaiste Chunks: %d\n", scanReport.OrphanedChunks)
|
||||||
|
fmt.Printf("\n%s\n", scanReport.Summary())
|
||||||
|
|
||||||
|
if len(scanReport.Findings) > 0 {
|
||||||
|
fmt.Println("\nBefunde:")
|
||||||
|
for _, scanFinding := range scanReport.Findings {
|
||||||
|
fmt.Printf(" [%s] %s\n", scanFinding.Severity, scanFinding.Message)
|
||||||
|
|
||||||
|
if scanFinding.RecommendedAction != "" {
|
||||||
|
fmt.Printf(" Empfehlung: %s\n", scanFinding.RecommendedAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein beschädigtes Repository liefert einen Fehlerstatus, damit ein
|
||||||
|
// Überwachungssystem daran anschlägt (PROMPT.md §140).
|
||||||
|
if !scanReport.IsHealthy() {
|
||||||
|
return errors.New("das Repository ist nicht vollständig wiederherstellbar")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRebuild baut den Katalog neu auf.
|
||||||
|
func runRebuild(commandArguments []string, commandLogger *slog.Logger) error {
|
||||||
|
repositoryPath, parseError := parsePathFlag("rebuild", commandArguments, nil)
|
||||||
|
if parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
|
||||||
|
repository.OpenOptions{}, commandLogger)
|
||||||
|
if openError != nil {
|
||||||
|
return openError
|
||||||
|
}
|
||||||
|
defer func() { _ = openedRepository.Close() }()
|
||||||
|
|
||||||
|
rebuiltCatalog, rebuildError := openedRepository.RebuildCatalog(context.Background())
|
||||||
|
if rebuildError != nil {
|
||||||
|
return rebuildError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Der Katalog wurde allein aus den Manifesten neu aufgebaut.\n\n")
|
||||||
|
fmt.Printf(" Gefundene Backups: %d\n", len(rebuiltCatalog.Entries))
|
||||||
|
fmt.Printf(" Repository: %s\n", rebuiltCatalog.RepositoryID)
|
||||||
|
fmt.Printf("\nEs wurde keine Datenbank benötigt.\n")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runHealth zeigt den Zustand eines Repositorys.
|
||||||
|
func runHealth(commandArguments []string, commandLogger *slog.Logger) error {
|
||||||
|
repositoryPath, parseError := parsePathFlag("health", commandArguments, nil)
|
||||||
|
if parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
|
||||||
|
repository.OpenOptions{ReadOnly: true}, commandLogger)
|
||||||
|
if openError != nil {
|
||||||
|
return openError
|
||||||
|
}
|
||||||
|
defer func() { _ = openedRepository.Close() }()
|
||||||
|
|
||||||
|
healthReport, healthError := openedRepository.Health(context.Background())
|
||||||
|
if healthError != nil {
|
||||||
|
return healthError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" Zustand: %s\n", healthReport.Status)
|
||||||
|
fmt.Printf(" Meldung: %s\n", healthReport.Message)
|
||||||
|
|
||||||
|
if healthReport.RecommendedAction != "" {
|
||||||
|
fmt.Printf(" Empfehlung: %s\n", healthReport.RecommendedAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" Backups: %d\n", healthReport.BackupCount)
|
||||||
|
fmt.Printf(" Kapazität: %s\n", formatBytes(healthReport.CapacityBytes))
|
||||||
|
fmt.Printf(" Belegt: %s (%.1f %%)\n", formatBytes(healthReport.UsedBytes), healthReport.UsedPercentage())
|
||||||
|
fmt.Printf(" Frei: %s\n", formatBytes(healthReport.FreeBytes))
|
||||||
|
fmt.Printf(" Antwortzeit: %.2f ms\n", healthReport.LatencyMilliseconds)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPrune entfernt verwaiste Chunks.
|
||||||
|
func runPrune(commandArguments []string, commandLogger *slog.Logger) error {
|
||||||
|
var applyChanges bool
|
||||||
|
|
||||||
|
repositoryPath, parseError := parsePathFlag("prune", commandArguments, func(commandFlags *flag.FlagSet) {
|
||||||
|
commandFlags.BoolVar(&applyChanges, "apply", false, "Änderungen tatsächlich ausführen")
|
||||||
|
})
|
||||||
|
if parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
|
||||||
|
repository.OpenOptions{}, commandLogger)
|
||||||
|
if openError != nil {
|
||||||
|
return openError
|
||||||
|
}
|
||||||
|
defer func() { _ = openedRepository.Close() }()
|
||||||
|
|
||||||
|
// Ohne --apply läuft nur eine Simulation: eine Bereinigung ist potenziell
|
||||||
|
// datenzerstörend und darf nicht versehentlich passieren (PROMPT.md §141).
|
||||||
|
removedCount, freedBytes, pruneError := openedRepository.PruneOrphanedChunks(context.Background(), !applyChanges)
|
||||||
|
if pruneError != nil {
|
||||||
|
return pruneError
|
||||||
|
}
|
||||||
|
|
||||||
|
if applyChanges {
|
||||||
|
fmt.Printf("Entfernte Chunks: %d (%s freigegeben)\n", removedCount, formatBytes(freedBytes))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Simulation: %d Chunks würden entfernt (%s würden frei).\n", removedCount, formatBytes(freedBytes))
|
||||||
|
fmt.Println("Zum tatsächlichen Ausführen: --apply ergänzen.")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatBytes stellt eine Bytezahl lesbar dar.
|
||||||
|
func formatBytes(byteCount int64) string {
|
||||||
|
const unitStep = 1024
|
||||||
|
|
||||||
|
if byteCount < unitStep {
|
||||||
|
return fmt.Sprintf("%d B", byteCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentValue := float64(byteCount)
|
||||||
|
unitNames := []string{"KiB", "MiB", "GiB", "TiB", "PiB"}
|
||||||
|
|
||||||
|
for _, unitName := range unitNames {
|
||||||
|
currentValue /= unitStep
|
||||||
|
|
||||||
|
if currentValue < unitStep {
|
||||||
|
return fmt.Sprintf("%.1f %s", currentValue, unitName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%.1f EiB", currentValue/unitStep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatBoolean stellt einen Schalter in Worten dar.
|
||||||
|
func formatBoolean(flagValue bool) string {
|
||||||
|
if flagValue {
|
||||||
|
return "ja"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "nein"
|
||||||
|
}
|
||||||
|
|
||||||
|
// runExport schreibt ein Backup als portablen Container.
|
||||||
|
func runExport(commandArguments []string, commandLogger *slog.Logger) error {
|
||||||
|
var backupID string
|
||||||
|
var outputPath string
|
||||||
|
|
||||||
|
repositoryPath, parseError := parsePathFlag("export", commandArguments, func(commandFlags *flag.FlagSet) {
|
||||||
|
commandFlags.StringVar(&backupID, "backup", "", "Kennung des zu exportierenden Backups")
|
||||||
|
commandFlags.StringVar(&outputPath, "out", "", "Zieldatei des Containers")
|
||||||
|
})
|
||||||
|
if parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if backupID == "" || outputPath == "" {
|
||||||
|
return errors.New("--backup und --out sind erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
|
||||||
|
repository.OpenOptions{ReadOnly: true}, commandLogger)
|
||||||
|
if openError != nil {
|
||||||
|
return openError
|
||||||
|
}
|
||||||
|
defer func() { _ = openedRepository.Close() }()
|
||||||
|
|
||||||
|
// Die Zieldatei wird exklusiv angelegt: ein bestehender Container darf
|
||||||
|
// nicht versehentlich überschrieben werden.
|
||||||
|
outputFile, createError := os.OpenFile(outputPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||||
|
if createError != nil {
|
||||||
|
if errors.Is(createError, os.ErrExist) {
|
||||||
|
return fmt.Errorf("die datei %q existiert bereits", outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("die zieldatei konnte nicht angelegt werden: %w", createError)
|
||||||
|
}
|
||||||
|
|
||||||
|
exportedBytes, exportError := openedRepository.ExportBackup(context.Background(), backupID, outputFile, buildVersion)
|
||||||
|
|
||||||
|
if closeError := outputFile.Close(); closeError != nil && exportError == nil {
|
||||||
|
exportError = fmt.Errorf("die zieldatei konnte nicht geschlossen werden: %w", closeError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exportError != nil {
|
||||||
|
// Ein unvollständiger Container wäre eine Falle: er sähe aus wie ein
|
||||||
|
// Backup, wäre aber keines.
|
||||||
|
_ = os.Remove(outputPath)
|
||||||
|
return exportError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Backup %s als Container geschrieben.\n\n", backupID)
|
||||||
|
fmt.Printf(" Datei: %s\n", outputPath)
|
||||||
|
fmt.Printf(" Größe: %s\n", formatBytes(exportedBytes))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runImport liest einen Container in ein Repository ein.
|
||||||
|
func runImport(commandArguments []string, commandLogger *slog.Logger) error {
|
||||||
|
var inputPath string
|
||||||
|
|
||||||
|
repositoryPath, parseError := parsePathFlag("import", commandArguments, func(commandFlags *flag.FlagSet) {
|
||||||
|
commandFlags.StringVar(&inputPath, "in", "", "Einzulesende Containerdatei")
|
||||||
|
})
|
||||||
|
if parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if inputPath == "" {
|
||||||
|
return errors.New("--in ist erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
openedRepository, openError := repository.Open(context.Background(), repositoryPath,
|
||||||
|
repository.OpenOptions{}, commandLogger)
|
||||||
|
if openError != nil {
|
||||||
|
return openError
|
||||||
|
}
|
||||||
|
defer func() { _ = openedRepository.Close() }()
|
||||||
|
|
||||||
|
inputFile, openFileError := os.Open(inputPath)
|
||||||
|
if openFileError != nil {
|
||||||
|
return fmt.Errorf("die containerdatei konnte nicht geöffnet werden: %w", openFileError)
|
||||||
|
}
|
||||||
|
defer func() { _ = inputFile.Close() }()
|
||||||
|
|
||||||
|
importResult, importError := openedRepository.ImportBackup(context.Background(), inputFile)
|
||||||
|
if importError != nil {
|
||||||
|
return importError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Container eingelesen.\n\n")
|
||||||
|
fmt.Printf(" Backup: %s\n", importResult.BackupID)
|
||||||
|
fmt.Printf(" Neue Chunks: %d (%s)\n", importResult.ChunksImported, formatBytes(importResult.BytesImported))
|
||||||
|
fmt.Printf(" Bereits vorhanden: %d\n", importResult.ChunksAlreadyPresent)
|
||||||
|
|
||||||
|
if importResult.SourceRepositoryID != "" {
|
||||||
|
fmt.Printf(" Ursprungs-Repo: %s\n", importResult.SourceRepositoryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runInspect prüft einen Container, ohne ihn einzulesen.
|
||||||
|
//
|
||||||
|
// Der Weg dient der Kontrolle vor einer Wiederherstellung: er beantwortet die
|
||||||
|
// Frage, ob ein übertragener Container überhaupt brauchbar ist.
|
||||||
|
func runInspect(commandArguments []string) error {
|
||||||
|
commandFlags := flag.NewFlagSet("inspect", flag.ContinueOnError)
|
||||||
|
inputPath := commandFlags.String("in", "", "Zu prüfende Containerdatei")
|
||||||
|
|
||||||
|
if parseError := commandFlags.Parse(commandArguments); parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if *inputPath == "" {
|
||||||
|
return errors.New("--in ist erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
inputFile, openError := os.Open(*inputPath)
|
||||||
|
if openError != nil {
|
||||||
|
return fmt.Errorf("die containerdatei konnte nicht geöffnet werden: %w", openError)
|
||||||
|
}
|
||||||
|
defer func() { _ = inputFile.Close() }()
|
||||||
|
|
||||||
|
containerReader, readerError := backupformat.NewReader(inputFile)
|
||||||
|
if readerError != nil {
|
||||||
|
return readerError
|
||||||
|
}
|
||||||
|
|
||||||
|
containerHeader := containerReader.Header()
|
||||||
|
|
||||||
|
fmt.Printf(" Formatversion: %d\n", containerReader.FormatVersion())
|
||||||
|
fmt.Printf(" Backup: %s\n", containerHeader.BackupID)
|
||||||
|
fmt.Printf(" Kette: %s\n", containerHeader.ChainID)
|
||||||
|
|
||||||
|
if containerHeader.ParentBackupID != "" {
|
||||||
|
fmt.Printf(" Elternbackup: %s\n", containerHeader.ParentBackupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" Quelle: %s (%s)\n", containerHeader.SourceID, containerHeader.SourceType)
|
||||||
|
fmt.Printf(" Erzeugt am: %s\n", containerHeader.CreatedAt.Format(time.RFC3339))
|
||||||
|
fmt.Printf(" Erzeugt mit: %s\n", containerHeader.CreatedByVersion)
|
||||||
|
|
||||||
|
if containerHeader.Encryption.IsEncrypted() {
|
||||||
|
fmt.Printf(" Verschlüsselt: %s (Schlüssel %s)\n",
|
||||||
|
containerHeader.Encryption.Algorithm, containerHeader.Encryption.KeyVersion)
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" Verschlüsselt: nein\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle Abschnitte werden gelesen, damit sämtliche Prüfsummen geprüft werden.
|
||||||
|
sectionCount := 0
|
||||||
|
var totalContentBytes int64
|
||||||
|
|
||||||
|
for {
|
||||||
|
nextSection, sectionError := containerReader.NextSection()
|
||||||
|
if errors.Is(sectionError, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if sectionError != nil {
|
||||||
|
fmt.Printf("\nDer Container ist NICHT verwendbar: %v\n", sectionError)
|
||||||
|
return errors.New("der container hat die prüfung nicht bestanden")
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionCount++
|
||||||
|
totalContentBytes += int64(len(nextSection.Content))
|
||||||
|
|
||||||
|
fmt.Printf(" Abschnitt: %-20s %s\n", nextSection.Type, formatBytes(int64(len(nextSection.Content))))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n Abschnitte: %d\n", sectionCount)
|
||||||
|
fmt.Printf(" Inhalt: %s\n", formatBytes(totalContentBytes))
|
||||||
|
|
||||||
|
if !containerReader.IsComplete() {
|
||||||
|
fmt.Println("\nDer Container trägt keinen Abschlussvermerk und beschreibt kein vollständiges Backup.")
|
||||||
|
return errors.New("der container ist unvollständig")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\nDer Container ist vollständig und unversehrt (abgeschlossen am %s).\n",
|
||||||
|
containerReader.Footer().CompletedAt.Format(time.RFC3339))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isVersionArgument erkennt eine Versionsabfrage.
|
||||||
|
func isVersionArgument(argument string) bool {
|
||||||
|
return argument == "version" || argument == "--version" || argument == "-version"
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmBreakLockVariable gibt das Entfernen einer Sperre frei.
|
||||||
|
const confirmBreakLockVariable = "SYNCOVA_REPO_CONFIRM_BREAK_LOCK"
|
||||||
|
|
||||||
|
// runBreakLock entfernt eine hängengebliebene Repository-Sperre.
|
||||||
|
//
|
||||||
|
// Der Eingriff ist ausdrücklich manuell und verlangt eine Bestätigung über die
|
||||||
|
// Umgebung. Der Grund ist unangenehm: Bricht man die Sperre eines noch
|
||||||
|
// **laufenden** Vorgangs, arbeiten zwei Schreiber gleichzeitig am selben
|
||||||
|
// Repository. Das Ergebnis ist keine Fehlermeldung, sondern ein beschädigter
|
||||||
|
// Bestand — und der fällt erst bei einer Wiederherstellung auf.
|
||||||
|
//
|
||||||
|
// Sperren werden deshalb nie automatisch gelöst. Wer dieses Kommando braucht,
|
||||||
|
// hat einen abgestürzten Vorgang und muss sich vorher vergewissern, dass wirklich
|
||||||
|
// keiner mehr läuft.
|
||||||
|
func runBreakLock(commandArguments []string) error {
|
||||||
|
repositoryPath, parseError := parsePathFlag("break-lock", commandArguments, nil)
|
||||||
|
if parseError != nil {
|
||||||
|
return parseError
|
||||||
|
}
|
||||||
|
|
||||||
|
lockHolder, readError := repository.ReadLockHolder(repositoryPath)
|
||||||
|
if readError != nil {
|
||||||
|
return readError
|
||||||
|
}
|
||||||
|
|
||||||
|
if lockHolder == "" {
|
||||||
|
fmt.Println("Auf diesem Repository liegt keine Sperre.")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Die Sperre wurde gesetzt von: %s\n", lockHolder)
|
||||||
|
|
||||||
|
if os.Getenv(confirmBreakLockVariable) != "ja" {
|
||||||
|
return fmt.Errorf("das Entfernen einer Sperre kann ein Repository beschädigen, "+
|
||||||
|
"wenn der zugehörige Vorgang noch läuft.\n"+
|
||||||
|
"Vergewissern Sie sich, dass kein Sicherungs- oder Wiederherstellungslauf "+
|
||||||
|
"aktiv ist, und wiederholen Sie den Aufruf mit %s=ja", confirmBreakLockVariable)
|
||||||
|
}
|
||||||
|
|
||||||
|
if breakError := repository.BreakLock(repositoryPath); breakError != nil {
|
||||||
|
return breakError
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Die Sperre wurde entfernt.")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
368
apps/api/internal/httpapi/agent_handler.go
Normal file
368
apps/api/internal/httpapi/agent_handler.go
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/agentregistry"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// agentContextKeyType ist der private Typ des Context-Schlüssels für den Agent.
|
||||||
|
type agentContextKeyType struct{}
|
||||||
|
|
||||||
|
// agentContextKey speichert den angemeldeten Agent im Request-Context.
|
||||||
|
var agentContextKey = agentContextKeyType{}
|
||||||
|
|
||||||
|
// AuthenticatedAgentFromContext liest den angemeldeten Agent aus dem Context.
|
||||||
|
func AuthenticatedAgentFromContext(currentContext context.Context) (agentregistry.Agent, bool) {
|
||||||
|
authenticatedAgent, isPresent := currentContext.Value(agentContextKey).(agentregistry.Agent)
|
||||||
|
return authenticatedAgent, isPresent
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentHandler bedient die Agent-Endpunkte (SYNCOVA_API.md §6).
|
||||||
|
type agentHandler struct {
|
||||||
|
// agentService ist die Domänenlogik der Agent-Verwaltung.
|
||||||
|
agentService *agentregistry.Service
|
||||||
|
// logger protokolliert technische Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// enrollmentTokenRequest ist der Rumpf von POST /agents/enrollment-tokens.
|
||||||
|
type enrollmentTokenRequest struct {
|
||||||
|
// AgentName ist der vorgesehene Name des aufzunehmenden Agents.
|
||||||
|
AgentName string `json:"agent_name"`
|
||||||
|
// ValidityMinutes ist die Gültigkeitsdauer in Minuten; 0 wählt den Standardwert.
|
||||||
|
ValidityMinutes int `json:"validity_minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentRegistrationRequest ist der Rumpf von POST /agents/register.
|
||||||
|
type agentRegistrationRequest struct {
|
||||||
|
// EnrollmentToken ist das Aufnahme-Token.
|
||||||
|
EnrollmentToken string `json:"enrollment_token"`
|
||||||
|
// Hostname ist der Rechnername des Systems.
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
// Platform ist das Betriebssystem.
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
// Architecture ist die Rechnerarchitektur.
|
||||||
|
Architecture string `json:"architecture"`
|
||||||
|
// Version ist die Programmversion des Agents.
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentHeartbeatRequest ist der Rumpf von POST /agents/heartbeat.
|
||||||
|
type agentHeartbeatRequest struct {
|
||||||
|
// Version ist die aktuelle Programmversion des Agents.
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleIssueEnrollmentToken bedient POST /agents/enrollment-tokens.
|
||||||
|
func (handler *agentHandler) handleIssueEnrollmentToken(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
var tokenPayload enrollmentTokenRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &tokenPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenPayload.AgentName == "" {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewValidationError("Der Name des Agents ist erforderlich."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
validity := time.Duration(tokenPayload.ValidityMinutes) * time.Minute
|
||||||
|
|
||||||
|
enrollmentToken, issueError := handler.agentService.IssueEnrollmentToken(request.Context(),
|
||||||
|
tokenPayload.AgentName, validity, actingUser, RequestContextFrom(request))
|
||||||
|
if issueError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAgentError(issueError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Token erscheint genau einmal in dieser Antwort und wird bewusst nicht
|
||||||
|
// geloggt (PROMPT.md §12).
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusCreated, enrollmentToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRegisterAgent bedient POST /agents/register.
|
||||||
|
//
|
||||||
|
// Der Endpunkt ist ohne Benutzeranmeldung erreichbar: ein sich aufnehmender
|
||||||
|
// Agent besitzt noch kein Betriebstoken. Sein Nachweis ist das Aufnahme-Token.
|
||||||
|
func (handler *agentHandler) handleRegisterAgent(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
var registrationPayload agentRegistrationRequest
|
||||||
|
if decodeError := decodeJSONBody(request, ®istrationPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if registrationPayload.EnrollmentToken == "" {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewValidationError("Das Aufnahme-Token ist erforderlich."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
registrationResult, registerError := handler.agentService.Register(request.Context(), agentregistry.RegistrationRequest{
|
||||||
|
EnrollmentToken: registrationPayload.EnrollmentToken,
|
||||||
|
Hostname: registrationPayload.Hostname,
|
||||||
|
Platform: agentregistry.Platform(registrationPayload.Platform),
|
||||||
|
Architecture: registrationPayload.Architecture,
|
||||||
|
Version: registrationPayload.Version,
|
||||||
|
IPAddress: clientIPAddress(request),
|
||||||
|
})
|
||||||
|
|
||||||
|
if registerError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAgentError(registerError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusCreated, registrationResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHeartbeat bedient POST /agents/heartbeat.
|
||||||
|
//
|
||||||
|
// Der Endpunkt wird vom Agent selbst aufgerufen und verlangt dessen
|
||||||
|
// Betriebstoken, nicht die Sitzung eines Benutzers.
|
||||||
|
func (handler *agentHandler) handleHeartbeat(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
authenticatedAgent, isAuthenticated := AuthenticatedAgentFromContext(request.Context())
|
||||||
|
if !isAuthenticated {
|
||||||
|
WriteError(responseWriter, request, requestLogger, newUnauthenticatedError("Für diesen Zugriff ist ein Agent-Token erforderlich."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var heartbeatPayload agentHeartbeatRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &heartbeatPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if heartbeatError := handler.agentService.RecordHeartbeat(request.Context(), authenticatedAgent,
|
||||||
|
agentregistry.HeartbeatRequest{
|
||||||
|
Version: heartbeatPayload.Version,
|
||||||
|
IPAddress: clientIPAddress(request),
|
||||||
|
}); heartbeatError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAgentError(heartbeatError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"acknowledged": true,
|
||||||
|
"server_time": time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListAgents bedient GET /agents.
|
||||||
|
func (handler *agentHandler) handleListAgents(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
page, pageSize := parsePagination(request)
|
||||||
|
|
||||||
|
listedAgents, totalCount, listError := handler.agentService.ListAgents(request.Context(), agentregistry.AgentFilter{
|
||||||
|
Status: request.URL.Query().Get("status"),
|
||||||
|
Platform: request.URL.Query().Get("platform"),
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
if listError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WritePaginatedSuccess(responseWriter, request, listedAgents, PaginationMeta{
|
||||||
|
Page: page, PageSize: pageSize, Total: totalCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetAgent bedient GET /agents/{id}.
|
||||||
|
func (handler *agentHandler) handleGetAgent(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
agentID, parseError := parsePathUUID(request, "id")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
foundAgent, loadError := handler.agentService.GetAgent(request.Context(), agentID)
|
||||||
|
if loadError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAgentError(loadError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, foundAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRevokeAgent bedient POST /agents/{id}/revoke.
|
||||||
|
func (handler *agentHandler) handleRevokeAgent(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
agentID, parseError := parsePathUUID(request, "id")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if revokeError := handler.agentService.RevokeAgent(request.Context(), agentID,
|
||||||
|
actingUser, RequestContextFrom(request)); revokeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAgentError(revokeError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{"status": "gesperrt"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRotateCredentials bedient POST /agents/{id}/rotate-credentials.
|
||||||
|
func (handler *agentHandler) handleRotateCredentials(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
agentID, parseError := parsePathUUID(request, "id")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newAgentToken, rotateError := handler.agentService.RotateCredentials(request.Context(), agentID,
|
||||||
|
actingUser, RequestContextFrom(request))
|
||||||
|
if rotateError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAgentError(rotateError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das neue Token erscheint genau einmal. Erreicht es den Agent nicht, kann
|
||||||
|
// er sich nicht mehr melden und muss neu aufgenommen werden.
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"agent_token": newAgentToken,
|
||||||
|
"hinweis": "Dieses Token wird nur einmal angezeigt. Es muss dem Agent übergeben werden, sonst kann er sich nicht mehr melden.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAgentHealth bedient GET /agents/{id}/health.
|
||||||
|
func (handler *agentHandler) handleAgentHealth(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
agentID, parseError := parsePathUUID(request, "id")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
foundAgent, loadError := handler.agentService.GetAgent(request.Context(), agentID)
|
||||||
|
if loadError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAgentError(loadError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTime := time.Now()
|
||||||
|
isOffline := foundAgent.IsOffline(currentTime, allowedAgentSilence)
|
||||||
|
|
||||||
|
// Der Zustand wird ehrlich benannt: ein stummer Agent bedeutet, dass von
|
||||||
|
// diesem System keine Backups mehr kommen (PROMPT.md §38).
|
||||||
|
agentStatus := "healthy"
|
||||||
|
statusMessage := "Der Agent meldet sich regelmäßig."
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case foundAgent.Status == agentregistry.AgentStatusRevoked:
|
||||||
|
agentStatus = "revoked"
|
||||||
|
statusMessage = "Der Agent ist gesperrt und sichert nichts mehr."
|
||||||
|
|
||||||
|
case isOffline:
|
||||||
|
agentStatus = "offline"
|
||||||
|
statusMessage = "Der Agent hat sich zu lange nicht gemeldet. Von diesem System kommen derzeit keine Backups."
|
||||||
|
}
|
||||||
|
|
||||||
|
healthResponse := map[string]any{
|
||||||
|
"agent_id": foundAgent.ID,
|
||||||
|
"status": agentStatus,
|
||||||
|
"message": statusMessage,
|
||||||
|
"version": foundAgent.Version,
|
||||||
|
}
|
||||||
|
|
||||||
|
if heartbeatAge, hasHeartbeat := foundAgent.HeartbeatAge(currentTime); hasHeartbeat {
|
||||||
|
healthResponse["last_heartbeat_at"] = foundAgent.LastHeartbeatAt
|
||||||
|
healthResponse["heartbeat_age_seconds"] = int64(heartbeatAge.Seconds())
|
||||||
|
} else {
|
||||||
|
// Ein Agent ohne Meldung ist etwas anderes als einer mit alter Meldung.
|
||||||
|
healthResponse["last_heartbeat_at"] = nil
|
||||||
|
healthResponse["hinweis"] = "Dieser Agent hat sich seit seiner Aufnahme noch nie gemeldet."
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, healthResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowedAgentSilence ist die Frist, nach der ein stummer Agent als offline gilt.
|
||||||
|
//
|
||||||
|
// Der Wert liegt deutlich über dem üblichen Meldeabstand, damit eine einzelne
|
||||||
|
// ausgefallene Meldung noch keinen Alarm auslöst.
|
||||||
|
const allowedAgentSilence = 15 * time.Minute
|
||||||
|
|
||||||
|
// AgentAuthenticationMiddleware prüft das Betriebstoken eines Agents.
|
||||||
|
//
|
||||||
|
// Sie steht neben der Benutzeranmeldung, nicht darüber: ein Agent erhält
|
||||||
|
// niemals Benutzerrechte (PROMPT.md §59).
|
||||||
|
func AgentAuthenticationMiddleware(agentService *agentregistry.Service, baseLogger *slog.Logger) Middleware {
|
||||||
|
return func(nextHandler http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), baseLogger)
|
||||||
|
|
||||||
|
agentToken, extractError := extractBearerToken(request)
|
||||||
|
if extractError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, newUnauthenticatedError(
|
||||||
|
"Für diesen Zugriff ist ein Agent-Token erforderlich."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticatedAgent, authenticateError := agentService.Authenticate(request.Context(), agentToken)
|
||||||
|
if authenticateError != nil {
|
||||||
|
// Ob unbekannt, abgelaufen oder gesperrt: die Antwort ist dieselbe.
|
||||||
|
WriteError(responseWriter, request, requestLogger, newUnauthenticatedError(
|
||||||
|
"Das Agent-Token ist ungültig oder wurde widerrufen."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichedContext := context.WithValue(request.Context(), agentContextKey, authenticatedAgent)
|
||||||
|
|
||||||
|
nextHandler.ServeHTTP(responseWriter, request.WithContext(enrichedContext))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateAgentError übersetzt Domänenfehler in API-Antworten.
|
||||||
|
func translateAgentError(domainError error) *APIError {
|
||||||
|
switch {
|
||||||
|
case errors.Is(domainError, agentregistry.ErrEnrollmentTokenInvalid):
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusUnauthorized,
|
||||||
|
Code: "ENROLLMENT_TOKEN_INVALID",
|
||||||
|
Message: "Das Aufnahme-Token ist ungültig, abgelaufen oder wurde bereits verwendet.",
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(domainError, agentregistry.ErrAgentTokenInvalid):
|
||||||
|
return newUnauthenticatedError("Das Agent-Token ist ungültig oder wurde widerrufen.")
|
||||||
|
|
||||||
|
case errors.Is(domainError, agentregistry.ErrAgentNotFound):
|
||||||
|
return NewNotFoundError("Der Agent existiert nicht.")
|
||||||
|
|
||||||
|
case errors.Is(domainError, agentregistry.ErrAgentRevoked):
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusConflict,
|
||||||
|
Code: "AGENT_REVOKED",
|
||||||
|
Message: "Der Agent ist gesperrt.",
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(domainError, agentregistry.ErrUnsupportedPlatform):
|
||||||
|
return NewValidationError("Die angegebene Plattform wird nicht unterstützt (erlaubt: windows, linux, darwin).")
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NewInternalError(domainError)
|
||||||
|
}
|
||||||
|
}
|
||||||
252
apps/api/internal/httpapi/agent_task_handler.go
Normal file
252
apps/api/internal/httpapi/agent_task_handler.go
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/syncova/syncova/packages/agenttasks"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// agentTaskHandler bedient die Auftragsübermittlung an Agenten (Phase 5).
|
||||||
|
//
|
||||||
|
// Alle drei Endpunkte hängen am **Betriebstoken des Agenten**, nicht an der
|
||||||
|
// Sitzung eines Benutzers. Und jeder von ihnen prüft, dass der Auftrag dem
|
||||||
|
// anfragenden Agenten gehört: Ohne diese Prüfung könnte ein übernommener Agent
|
||||||
|
// die Aufträge aller anderen Systeme abholen — samt Quellpfaden und
|
||||||
|
// Repositorypfaden, also einer Landkarte der gesamten Anlage.
|
||||||
|
type agentTaskHandler struct {
|
||||||
|
// taskStore ist die Datenzugriffsschicht der Aufträge.
|
||||||
|
taskStore *agenttasks.Store
|
||||||
|
// logger protokolliert technische Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimedTaskResponse ist ein abgeholter Auftrag.
|
||||||
|
type claimedTaskResponse struct {
|
||||||
|
// TaskID ist die Kennung des Auftrags.
|
||||||
|
TaskID uuid.UUID `json:"task_id"`
|
||||||
|
// TaskType ist die Art des Auftrags.
|
||||||
|
TaskType string `json:"task_type"`
|
||||||
|
// Backup ist der Auftragsinhalt einer Sicherung.
|
||||||
|
Backup agenttasks.BackupPayload `json:"backup"`
|
||||||
|
// Restore ist der Auftragsinhalt einer Wiederherstellung.
|
||||||
|
Restore agenttasks.RestorePayload `json:"restore"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleClaimTask bedient POST /agents/tasks/claim.
|
||||||
|
//
|
||||||
|
// Antwortet mit 204, wenn nichts anliegt. Kein Fehler und kein leeres Objekt:
|
||||||
|
// Der Agent fragt regelmäßig, und „nichts zu tun" ist der Normalfall — er darf
|
||||||
|
// sich nicht von einer Störung unterscheiden lassen müssen.
|
||||||
|
func (handler *agentTaskHandler) handleClaimTask(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
authenticatedAgent, isAuthenticated := AuthenticatedAgentFromContext(request.Context())
|
||||||
|
if !isAuthenticated {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
newUnauthenticatedError("Für diesen Zugriff ist ein Agent-Token erforderlich."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claimedTask, claimError := handler.taskStore.ClaimNextTask(request.Context(), authenticatedAgent.ID)
|
||||||
|
if claimError != nil {
|
||||||
|
if errors.Is(claimError, agenttasks.ErrNoTaskAvailable) {
|
||||||
|
responseWriter.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestLogger.Error("ein auftrag liess sich nicht uebernehmen",
|
||||||
|
slog.String("agent", authenticatedAgent.ID.String()),
|
||||||
|
slog.String("grund", claimError.Error()))
|
||||||
|
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(claimError))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestLogger.Info("ein agent hat einen auftrag uebernommen",
|
||||||
|
slog.String("agent", authenticatedAgent.Name),
|
||||||
|
slog.String("auftrag", claimedTask.ID.String()),
|
||||||
|
slog.String("art", string(claimedTask.TaskType)))
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, claimedTaskResponse{
|
||||||
|
TaskID: claimedTask.ID,
|
||||||
|
TaskType: string(claimedTask.TaskType),
|
||||||
|
Backup: claimedTask.Backup,
|
||||||
|
Restore: claimedTask.Restore,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskProgressRequest ist der Rumpf von POST /agents/tasks/{id}/progress.
|
||||||
|
type taskProgressRequest struct {
|
||||||
|
// BytesProcessed ist die bisher gelesene Datenmenge.
|
||||||
|
BytesProcessed int64 `json:"bytes_processed"`
|
||||||
|
// FilesProcessed ist die bisher erfasste Objektzahl.
|
||||||
|
FilesProcessed int64 `json:"files_processed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReportProgress bedient POST /agents/tasks/{id}/progress.
|
||||||
|
//
|
||||||
|
// Der Fortschritt ist zugleich die Lebendmeldung des laufenden Auftrags. Bleibt
|
||||||
|
// sie aus, gibt der Server den Auftrag nach einer Frist als gescheitert frei —
|
||||||
|
// sonst bliebe er nach einem Absturz des Agenten dauerhaft auf „läuft" stehen
|
||||||
|
// und blockierte den Agenten für immer.
|
||||||
|
func (handler *agentTaskHandler) handleReportProgress(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
authenticatedAgent, isAuthenticated := AuthenticatedAgentFromContext(request.Context())
|
||||||
|
if !isAuthenticated {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
newUnauthenticatedError("Für diesen Zugriff ist ein Agent-Token erforderlich."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
taskIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError("Die Auftragskennung ist keine gueltige UUID."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var progressPayload taskProgressRequest
|
||||||
|
|
||||||
|
if decodeError := decodeJSONBody(request, &progressPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
progressError := handler.taskStore.ReportProgress(request.Context(), taskIdentifier,
|
||||||
|
authenticatedAgent.ID, progressPayload.BytesProcessed, progressPayload.FilesProcessed)
|
||||||
|
if progressError != nil {
|
||||||
|
handler.writeTaskError(responseWriter, request, requestLogger, progressError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responseWriter.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskResultRequest ist der Rumpf von POST /agents/tasks/{id}/result.
|
||||||
|
type taskResultRequest struct {
|
||||||
|
// Status ist der erreichte Zustand.
|
||||||
|
Status string `json:"status"`
|
||||||
|
// BytesProcessed ist die gelesene Datenmenge.
|
||||||
|
BytesProcessed int64 `json:"bytes_processed"`
|
||||||
|
// BytesWritten ist die abgelegte Datenmenge.
|
||||||
|
BytesWritten int64 `json:"bytes_written"`
|
||||||
|
// FilesProcessed ist die Zahl erfasster Objekte.
|
||||||
|
FilesProcessed int64 `json:"files_processed"`
|
||||||
|
// FilesSkipped ist die Zahl übergangener Objekte.
|
||||||
|
FilesSkipped int64 `json:"files_skipped"`
|
||||||
|
// BackupIDInRepository ist die Kennung des entstandenen Backups.
|
||||||
|
BackupIDInRepository string `json:"backup_id_in_repository,omitempty"`
|
||||||
|
// ErrorCode ist der maschinenlesbare Fehlercode.
|
||||||
|
ErrorCode string `json:"error_code,omitempty"`
|
||||||
|
// ErrorMessage beschreibt den Fehler.
|
||||||
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
|
// FailureClass ist die Einstufung des Fehlers.
|
||||||
|
FailureClass string `json:"failure_class,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReportResult bedient POST /agents/tasks/{id}/result.
|
||||||
|
func (handler *agentTaskHandler) handleReportResult(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
authenticatedAgent, isAuthenticated := AuthenticatedAgentFromContext(request.Context())
|
||||||
|
if !isAuthenticated {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
newUnauthenticatedError("Für diesen Zugriff ist ein Agent-Token erforderlich."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
taskIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError("Die Auftragskennung ist keine gueltige UUID."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var resultPayload taskResultRequest
|
||||||
|
|
||||||
|
if decodeError := decodeJSONBody(request, &resultPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
taskResult := agenttasks.TaskResult{
|
||||||
|
Status: agenttasks.TaskStatus(resultPayload.Status),
|
||||||
|
BytesProcessed: resultPayload.BytesProcessed,
|
||||||
|
BytesWritten: resultPayload.BytesWritten,
|
||||||
|
FilesProcessed: resultPayload.FilesProcessed,
|
||||||
|
FilesSkipped: resultPayload.FilesSkipped,
|
||||||
|
BackupIDInRepository: resultPayload.BackupIDInRepository,
|
||||||
|
ErrorCode: resultPayload.ErrorCode,
|
||||||
|
ErrorMessage: resultPayload.ErrorMessage,
|
||||||
|
FailureClass: resultPayload.FailureClass,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isReportableStatus(taskResult.Status) {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewValidationError(
|
||||||
|
"Ein Ergebnis muss einen abgeschlossenen Zustand melden: succeeded, "+
|
||||||
|
"partial_failure, failed oder cancelled."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
completeError := handler.taskStore.CompleteTask(request.Context(), taskIdentifier,
|
||||||
|
authenticatedAgent.ID, taskResult)
|
||||||
|
if completeError != nil {
|
||||||
|
handler.writeTaskError(responseWriter, request, requestLogger, completeError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestLogger.Info("ein agent hat einen auftrag abgeschlossen",
|
||||||
|
slog.String("agent", authenticatedAgent.Name),
|
||||||
|
slog.String("auftrag", taskIdentifier.String()),
|
||||||
|
slog.String("ergebnis", resultPayload.Status),
|
||||||
|
slog.Int64("bytes", resultPayload.BytesProcessed),
|
||||||
|
slog.Int64("uebergangen", resultPayload.FilesSkipped))
|
||||||
|
|
||||||
|
responseWriter.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isReportableStatus meldet einen zulässigen Ergebniszustand.
|
||||||
|
//
|
||||||
|
// Ein Agent darf kein „läuft" als Ergebnis melden: Das wäre ein Auftrag, der
|
||||||
|
// nie endet, und der Lauf wartete darauf, bis die Frist ihn freigibt.
|
||||||
|
func isReportableStatus(status agenttasks.TaskStatus) bool {
|
||||||
|
return status.IsTerminal()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTaskError bildet einen Speicherfehler auf eine Antwort ab.
|
||||||
|
func (handler *agentTaskHandler) writeTaskError(responseWriter http.ResponseWriter,
|
||||||
|
request *http.Request, requestLogger *slog.Logger, occurredError error) {
|
||||||
|
// Ein fremder oder bereits abgeschlossener Auftrag ergibt dieselbe Antwort.
|
||||||
|
//
|
||||||
|
// Die Unterscheidung verriete einem übernommenen Agenten, welche
|
||||||
|
// Auftragskennungen es überhaupt gibt — dieselbe Überlegung wie bei der
|
||||||
|
// Anmeldung, die nicht verrät, ob ein Konto existiert.
|
||||||
|
if errors.Is(occurredError, agenttasks.ErrTaskNotOwned) {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewNotFoundError("Zu dieser Kennung gibt es keinen laufenden Auftrag dieses Agenten."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestLogger.Error("ein auftrag liess sich nicht fortschreiben",
|
||||||
|
slog.String("grund", occurredError.Error()))
|
||||||
|
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(occurredError))
|
||||||
|
}
|
||||||
361
apps/api/internal/httpapi/alert_handler.go
Normal file
361
apps/api/internal/httpapi/alert_handler.go
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/syncova/syncova/packages/alerting"
|
||||||
|
"github.com/syncova/syncova/packages/audit"
|
||||||
|
"github.com/syncova/syncova/packages/auth"
|
||||||
|
"github.com/syncova/syncova/packages/platform/crypto"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
"github.com/syncova/syncova/packages/platform/netguard"
|
||||||
|
)
|
||||||
|
|
||||||
|
// alertHandler bedient Meldungen und Benachrichtigungen (Phase 14).
|
||||||
|
type alertHandler struct {
|
||||||
|
// alertStore ist die Datenzugriffsschicht der Meldungen.
|
||||||
|
alertStore *alerting.Store
|
||||||
|
// secretStore verschlüsselt die Zugangsgeheimnisse der Kanäle.
|
||||||
|
secretStore crypto.SecretStore
|
||||||
|
// auditRecorder protokolliert Änderungen an Kanälen.
|
||||||
|
auditRecorder audit.Recorder
|
||||||
|
// addressGuard wehrt interne Zieladressen ab (SSRF, Phase 19).
|
||||||
|
addressGuard *netguard.Guard
|
||||||
|
// logger protokolliert technische Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// acknowledgeRequest ist der Rumpf von POST /alerts/{id}/acknowledge.
|
||||||
|
type acknowledgeRequest struct {
|
||||||
|
// Note ist eine Bemerkung des Bestätigenden.
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRequest ist der Rumpf von POST /alerts/{id}/resolve.
|
||||||
|
type resolveRequest struct {
|
||||||
|
// Note begründet die Auflösung.
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// channelRequest ist der Rumpf von POST /notification-channels.
|
||||||
|
type channelRequest struct {
|
||||||
|
// Name ist die sprechende Bezeichnung.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// ChannelType ist die Art der Zustellung.
|
||||||
|
ChannelType string `json:"channel_type"`
|
||||||
|
// MinimumSeverity ist der niedrigste zugestellte Schweregrad.
|
||||||
|
MinimumSeverity string `json:"minimum_severity,omitempty"`
|
||||||
|
// Configuration trägt die Zustelldaten ohne Geheimnisse.
|
||||||
|
Configuration map[string]any `json:"configuration"`
|
||||||
|
// Secret ist das Zugangsgeheimnis.
|
||||||
|
//
|
||||||
|
// Es geht nur hinein, nie heraus: Keine Antwort dieses Handlers enthält es
|
||||||
|
// jemals wieder (PROMPT.md §140).
|
||||||
|
Secret string `json:"secret,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListAlerts bedient GET /alerts.
|
||||||
|
func (handler *alertHandler) handleListAlerts(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
listFilter := alerting.AlertFilter{
|
||||||
|
Status: request.URL.Query().Get("status"),
|
||||||
|
Severity: request.URL.Query().Get("severity"),
|
||||||
|
OnlyActive: request.URL.Query().Get("only_active") == "true",
|
||||||
|
Page: parsePositiveInteger(request.URL.Query().Get("page"), 1),
|
||||||
|
PageSize: parsePositiveInteger(request.URL.Query().Get("page_size"), 50),
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedAlerts, totalCount, listError := handler.alertStore.ListAlerts(request.Context(), listFilter)
|
||||||
|
if listError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WritePaginatedSuccess(responseWriter, request, loadedAlerts, PaginationMeta{
|
||||||
|
Page: listFilter.Page,
|
||||||
|
PageSize: listFilter.PageSize,
|
||||||
|
Total: int64(totalCount),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAlertSummary bedient GET /alerts/summary.
|
||||||
|
//
|
||||||
|
// Getrennt von der Liste, weil die Übersicht sie bei jedem Aufruf braucht: Eine
|
||||||
|
// Seite von fünfzig Meldungen zu laden, um drei Zahlen zu bilden, wäre Aufwand
|
||||||
|
// ohne Nutzen.
|
||||||
|
func (handler *alertHandler) handleAlertSummary(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
summary, summaryError := handler.alertStore.Summary(request.Context())
|
||||||
|
if summaryError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(summaryError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Regelwerk kommt mit: Wer die Meldungslage beurteilt, muss wissen,
|
||||||
|
// worauf überhaupt geachtet wird — und worauf nicht.
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"summary": summary,
|
||||||
|
"rules": alerting.Rules(),
|
||||||
|
"available_rule_count": alerting.AvailableRuleCount(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetAlert bedient GET /alerts/{id}.
|
||||||
|
func (handler *alertHandler) handleGetAlert(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
alertIdentifier, parseError := parsePathIdentifier(request, "Die Meldungskennung ist keine gültige UUID.")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedAlert, readError := handler.alertStore.GetAlert(request.Context(), alertIdentifier)
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAlertError(readError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, loadedAlert)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAcknowledgeAlert bedient POST /alerts/{id}/acknowledge.
|
||||||
|
func (handler *alertHandler) handleAcknowledgeAlert(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
alertIdentifier, parseError := parsePathIdentifier(request, "Die Meldungskennung ist keine gültige UUID.")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var acknowledgePayload acknowledgeRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &acknowledgePayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acknowledgedAlert, acknowledgeError := handler.alertStore.AcknowledgeAlert(request.Context(),
|
||||||
|
alertIdentifier, actingUser.ID, acknowledgePayload.Note)
|
||||||
|
if acknowledgeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAlertError(acknowledgeError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"alert": acknowledgedAlert,
|
||||||
|
"message": "Die Meldung ist zur Kenntnis genommen. Sie bleibt offen, bis ihre Ursache " +
|
||||||
|
"verschwindet — Bestätigen heisst nicht Erledigen.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResolveAlert bedient POST /alerts/{id}/resolve.
|
||||||
|
func (handler *alertHandler) handleResolveAlert(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
alertIdentifier, parseError := parsePathIdentifier(request, "Die Meldungskennung ist keine gültige UUID.")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvePayload resolveRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &resolvePayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedAlert, resolveError := handler.alertStore.ResolveAlert(request.Context(),
|
||||||
|
alertIdentifier, actingUser.ID, resolvePayload.Note)
|
||||||
|
if resolveError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAlertError(resolveError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Hinweis ist wichtig: Die Auswertung prüft im nächsten Durchgang
|
||||||
|
// erneut. Besteht die Ursache fort, entsteht eine neue Meldung — von Hand
|
||||||
|
// schliessen macht ein Problem nicht weg.
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"alert": resolvedAlert,
|
||||||
|
"message": "Die Meldung wurde geschlossen. Besteht die Ursache weiterhin, erzeugt die " +
|
||||||
|
"nächste Auswertung eine neue Meldung.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListChannels bedient GET /notification-channels.
|
||||||
|
func (handler *alertHandler) handleListChannels(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
loadedChannels, listError := handler.alertStore.ListChannels(request.Context())
|
||||||
|
if listError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, loadedChannels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateChannel bedient POST /notification-channels.
|
||||||
|
func (handler *alertHandler) handleCreateChannel(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
var channelPayload channelRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &channelPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Zieladresse wird geprüft, bevor der Kanal entsteht.
|
||||||
|
//
|
||||||
|
// Die Zustellung prüft ein zweites Mal — dort erst kurz vor dem
|
||||||
|
// Verbindungsaufbau, weil ein Name zwischenzeitlich auf eine andere Adresse
|
||||||
|
// zeigen kann. Diese Prüfung hier hat einen anderen Zweck: Der Betreiber
|
||||||
|
// soll die Meldung sofort sehen und nicht erst, wenn eine Meldung
|
||||||
|
// ausbleibt.
|
||||||
|
if guardError := handler.checkChannelTarget(request.Context(), channelPayload); guardError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, guardError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createdChannel, createError := handler.alertStore.CreateChannel(request.Context(),
|
||||||
|
alerting.CreateChannelRequest{
|
||||||
|
Name: channelPayload.Name,
|
||||||
|
ChannelType: alerting.ChannelType(channelPayload.ChannelType),
|
||||||
|
MinimumSeverity: alerting.Severity(channelPayload.MinimumSeverity),
|
||||||
|
Configuration: channelPayload.Configuration,
|
||||||
|
Secret: channelPayload.Secret,
|
||||||
|
CreatedBy: &actingUser.ID,
|
||||||
|
}, handler.secretStore)
|
||||||
|
if createError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAlertError(createError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Kanal wird auditiert: Wer Benachrichtigungen umleitet, kann damit
|
||||||
|
// erreichen, dass niemand mehr von einem Ausfall erfährt.
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionNotificationChannelCreated,
|
||||||
|
&createdChannel.ID, map[string]any{
|
||||||
|
"name": createdChannel.Name,
|
||||||
|
"channel_type": string(createdChannel.ChannelType),
|
||||||
|
"minimum_severity": string(createdChannel.MinimumSeverity),
|
||||||
|
})
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusCreated, createdChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteChannel bedient DELETE /notification-channels/{id}.
|
||||||
|
func (handler *alertHandler) handleDeleteChannel(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
channelIdentifier, parseError := parsePathIdentifier(request, "Die Kanalkennung ist keine gültige UUID.")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existingChannel, readError := handler.alertStore.GetChannel(request.Context(), channelIdentifier)
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAlertError(readError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if deleteError := handler.alertStore.DeleteChannel(request.Context(), channelIdentifier); deleteError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAlertError(deleteError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionNotificationChannelDeleted,
|
||||||
|
&channelIdentifier, map[string]any{"name": existingChannel.Name})
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"message": "Der Benachrichtigungskanal wurde gelöscht. Meldungen entstehen weiterhin, " +
|
||||||
|
"werden aber nicht mehr über diesen Weg zugestellt.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordAudit schreibt ein Auditereignis.
|
||||||
|
func (handler *alertHandler) recordAudit(request *http.Request, actingUser auth.User, auditAction audit.Action, entityIdentifier *uuid.UUID, auditDetails map[string]any) {
|
||||||
|
if handler.auditRecorder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
correlationIdentifier, _ := logging.CorrelationIDFromContext(request.Context())
|
||||||
|
|
||||||
|
recordError := handler.auditRecorder.Record(request.Context(), audit.Event{
|
||||||
|
UserID: &actingUser.ID,
|
||||||
|
ActorUsername: actingUser.Username,
|
||||||
|
Action: auditAction,
|
||||||
|
EntityType: "notification_channel",
|
||||||
|
EntityID: entityIdentifier,
|
||||||
|
Result: audit.ResultSuccess,
|
||||||
|
IPAddress: clientIPAddress(request),
|
||||||
|
UserAgent: request.UserAgent(),
|
||||||
|
CorrelationID: correlationIdentifier,
|
||||||
|
Details: auditDetails,
|
||||||
|
})
|
||||||
|
|
||||||
|
if recordError != nil {
|
||||||
|
logging.WithContext(request.Context(), handler.logger).Error(
|
||||||
|
"das auditereignis konnte nicht geschrieben werden",
|
||||||
|
slog.String("aktion", string(auditAction)),
|
||||||
|
slog.String("grund", recordError.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateAlertError bildet Fehler der Fachschicht auf API-Fehler ab.
|
||||||
|
func translateAlertError(occurredError error) *APIError {
|
||||||
|
switch {
|
||||||
|
case errors.Is(occurredError, alerting.ErrAlertNotFound):
|
||||||
|
return NewNotFoundError("Die Meldung wurde nicht gefunden.")
|
||||||
|
|
||||||
|
case errors.Is(occurredError, alerting.ErrChannelNotFound):
|
||||||
|
return NewNotFoundError("Der Benachrichtigungskanal wurde nicht gefunden.")
|
||||||
|
|
||||||
|
case errors.Is(occurredError, alerting.ErrAlertNotOpen):
|
||||||
|
// 409 und nicht 404: Die Meldung gibt es, sie ist nur schon erledigt.
|
||||||
|
conflictError := NewValidationError("Diese Meldung ist bereits erledigt.")
|
||||||
|
conflictError.Code = ErrorCodeConflict
|
||||||
|
conflictError.StatusCode = http.StatusConflict
|
||||||
|
|
||||||
|
return conflictError
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NewInternalError(occurredError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkChannelTarget prüft die Zieladresse eines Benachrichtigungswegs.
|
||||||
|
//
|
||||||
|
// Geprüft werden Webhook-Adresse und SMTP-Server. Fehlt die Angabe, greift
|
||||||
|
// diese Prüfung nicht — die fachliche Vollständigkeit prüft der Store.
|
||||||
|
func (handler *alertHandler) checkChannelTarget(checkContext context.Context,
|
||||||
|
channelPayload channelRequest) *APIError {
|
||||||
|
if handler.addressGuard == nil || channelPayload.Configuration == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if webhookURL, hasURL := channelPayload.Configuration["url"].(string); hasURL && webhookURL != "" {
|
||||||
|
if guardError := handler.addressGuard.CheckURL(checkContext, webhookURL); guardError != nil {
|
||||||
|
return NewValidationError(guardError.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if smtpHost, hasHost := channelPayload.Configuration["host"].(string); hasHost && smtpHost != "" {
|
||||||
|
if guardError := handler.addressGuard.CheckHost(checkContext, smtpHost); guardError != nil {
|
||||||
|
return NewValidationError(guardError.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
352
apps/api/internal/httpapi/auth_handler.go
Normal file
352
apps/api/internal/httpapi/auth_handler.go
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/syncova/syncova/packages/auth"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// authHandler bedient die Anmelde-Endpunkte (SYNCOVA_API.md §2, §3).
|
||||||
|
type authHandler struct {
|
||||||
|
// authService ist die Domänenlogik der Identitätsverwaltung.
|
||||||
|
authService *auth.Service
|
||||||
|
// logger protokolliert technische Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginRequest ist der Rumpf von POST /auth/login.
|
||||||
|
type loginRequest struct {
|
||||||
|
// Username ist der Anmeldename.
|
||||||
|
Username string `json:"username"`
|
||||||
|
// Password ist das Passwort.
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mfaVerifyRequest ist der Rumpf von POST /auth/mfa/verify.
|
||||||
|
type mfaVerifyRequest struct {
|
||||||
|
// ChallengeID benennt die offene Herausforderung.
|
||||||
|
ChallengeID string `json:"challenge_id"`
|
||||||
|
// Code ist der TOTP- oder Wiederherstellungscode.
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshRequest ist der Rumpf von POST /auth/refresh.
|
||||||
|
type refreshRequest struct {
|
||||||
|
// RefreshToken ist das Erneuerungstoken.
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogin bedient POST /auth/login.
|
||||||
|
func (handler *authHandler) handleLogin(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
var loginPayload loginRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &loginPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if loginPayload.Username == "" || loginPayload.Password == "" {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewValidationError("Benutzername und Passwort sind erforderlich."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loginResult, loginError := handler.authService.Login(
|
||||||
|
request.Context(), loginPayload.Username, loginPayload.Password, RequestContextFrom(request))
|
||||||
|
|
||||||
|
if loginError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAuthError(loginError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, loginResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVerifyMFA bedient POST /auth/mfa/verify.
|
||||||
|
func (handler *authHandler) handleVerifyMFA(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
var verifyPayload mfaVerifyRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &verifyPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
challengeID, parseError := uuid.Parse(verifyPayload.ChallengeID)
|
||||||
|
if parseError != nil {
|
||||||
|
// Eine unlesbare Kennung wird wie eine abgelaufene behandelt, damit
|
||||||
|
// die Antwort keine Rückschlüsse auf gültige Kennungen erlaubt.
|
||||||
|
WriteError(responseWriter, request, requestLogger, &APIError{
|
||||||
|
StatusCode: http.StatusUnauthorized,
|
||||||
|
Code: ErrorCodeUnauthenticated,
|
||||||
|
Message: auth.ErrChallengeNotFound.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if verifyPayload.Code == "" {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewValidationError("Der Code ist erforderlich."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loginResult, verifyError := handler.authService.VerifyMFA(
|
||||||
|
request.Context(), challengeID, verifyPayload.Code, RequestContextFrom(request))
|
||||||
|
|
||||||
|
if verifyError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAuthError(verifyError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, loginResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRefresh bedient POST /auth/refresh.
|
||||||
|
func (handler *authHandler) handleRefresh(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
var refreshPayload refreshRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &refreshPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if refreshPayload.RefreshToken == "" {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewValidationError("Das Erneuerungstoken ist erforderlich."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenPair, refreshError := handler.authService.Refresh(
|
||||||
|
request.Context(), refreshPayload.RefreshToken, RequestContextFrom(request))
|
||||||
|
|
||||||
|
if refreshError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAuthError(refreshError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, tokenPair)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogout bedient POST /auth/logout.
|
||||||
|
func (handler *authHandler) handleLogout(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
authenticatedUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
sessionID, hasSession := SessionIDFromContext(request.Context())
|
||||||
|
|
||||||
|
if !hasSession {
|
||||||
|
WriteError(responseWriter, request, requestLogger, newUnauthenticatedError("Es besteht keine aktive Sitzung."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if logoutError := handler.authService.Logout(request.Context(), sessionID, authenticatedUser, RequestContextFrom(request)); logoutError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(logoutError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]string{
|
||||||
|
"status": "abgemeldet",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetCurrentUser bedient GET /me.
|
||||||
|
func (handler *authHandler) handleGetCurrentUser(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
authenticatedUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, authenticatedUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mfaConfirmRequest ist der Rumpf von POST /me/mfa/confirm.
|
||||||
|
type mfaConfirmRequest struct {
|
||||||
|
// Code ist der zur Bestätigung eingegebene TOTP-Code.
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBeginMFAEnrollment bedient POST /me/mfa/enroll.
|
||||||
|
func (handler *authHandler) handleBeginMFAEnrollment(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
authenticatedUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
enrollment, enrollError := handler.authService.BeginMFAEnrollment(request.Context(), authenticatedUser)
|
||||||
|
if enrollError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAuthError(enrollError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secret und Wiederherstellungscodes werden genau einmal ausgeliefert.
|
||||||
|
// Sie erscheinen bewusst nicht im Log (PROMPT.md §12).
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, enrollment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConfirmMFAEnrollment bedient POST /me/mfa/confirm.
|
||||||
|
func (handler *authHandler) handleConfirmMFAEnrollment(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
authenticatedUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
var confirmPayload mfaConfirmRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &confirmPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if confirmError := handler.authService.ConfirmMFAEnrollment(
|
||||||
|
request.Context(), authenticatedUser, confirmPayload.Code, RequestContextFrom(request)); confirmError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAuthError(confirmError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"mfa_enabled": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeJSONBody liest einen JSON-Rumpf und lehnt unbekannte Felder ab.
|
||||||
|
//
|
||||||
|
// Unbekannte Felder deuten auf einen Tippfehler oder eine falsche API-Version
|
||||||
|
// hin; sie stillschweigend zu übergehen führte zu Einstellungen, die der
|
||||||
|
// Aufrufer gesetzt zu haben glaubt (PROMPT.md §140).
|
||||||
|
func decodeJSONBody(request *http.Request, targetStructure any) *APIError {
|
||||||
|
jsonDecoder := json.NewDecoder(request.Body)
|
||||||
|
jsonDecoder.DisallowUnknownFields()
|
||||||
|
|
||||||
|
if decodeError := jsonDecoder.Decode(targetStructure); decodeError != nil {
|
||||||
|
// Ein überschrittenes Größenlimit erhält eine eigene, klare Meldung.
|
||||||
|
var maxBytesError *http.MaxBytesError
|
||||||
|
if errors.As(decodeError, &maxBytesError) {
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusRequestEntityTooLarge,
|
||||||
|
Code: ErrorCodePayloadTooLarge,
|
||||||
|
Message: "Die Anfrage ist zu groß.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(decodeError, io.EOF) {
|
||||||
|
return NewBadRequestError("Die Anfrage enthält keinen Inhalt.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewBadRequestError("Die Anfrage konnte nicht gelesen werden. Erwartet wird gültiges JSON.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateAuthError übersetzt Domänenfehler in API-Antworten.
|
||||||
|
//
|
||||||
|
// Anmeldefehler erhalten bewusst dieselbe Meldung, damit sich aus der Antwort
|
||||||
|
// nicht ableiten lässt, ob ein Konto existiert (PROMPT.md §45).
|
||||||
|
func translateAuthError(domainError error) *APIError {
|
||||||
|
switch {
|
||||||
|
case errors.Is(domainError, auth.ErrInvalidCredentials):
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusUnauthorized,
|
||||||
|
Code: ErrorCodeUnauthenticated,
|
||||||
|
Message: "Benutzername oder Passwort ist falsch.",
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(domainError, auth.ErrAccountLocked):
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusUnauthorized,
|
||||||
|
Code: "ACCOUNT_LOCKED",
|
||||||
|
Message: "Das Konto ist wegen zu vieler Fehlversuche vorübergehend gesperrt. Bitte später erneut versuchen.",
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(domainError, auth.ErrAccountDisabled):
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusUnauthorized,
|
||||||
|
Code: "ACCOUNT_DISABLED",
|
||||||
|
Message: "Das Konto ist deaktiviert. Bitte an die Administration wenden.",
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(domainError, auth.ErrInvalidMFACode):
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusUnauthorized,
|
||||||
|
Code: "MFA_CODE_INVALID",
|
||||||
|
Message: "Der Code ist falsch oder wurde bereits verwendet.",
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(domainError, auth.ErrChallengeNotFound):
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusUnauthorized,
|
||||||
|
Code: "MFA_CHALLENGE_EXPIRED",
|
||||||
|
Message: "Die Anmeldung ist abgelaufen. Bitte erneut anmelden.",
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(domainError, auth.ErrChallengeAttemptsExceeded):
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusTooManyRequests,
|
||||||
|
Code: ErrorCodeRateLimited,
|
||||||
|
Message: "Zu viele Fehlversuche. Bitte erneut anmelden.",
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(domainError, auth.ErrSessionInvalid):
|
||||||
|
return newUnauthenticatedError("Die Sitzung ist ungültig oder abgelaufen. Bitte erneut anmelden.")
|
||||||
|
|
||||||
|
case errors.Is(domainError, auth.ErrUserNotFound):
|
||||||
|
return NewNotFoundError("Der Benutzer existiert nicht.")
|
||||||
|
|
||||||
|
case errors.Is(domainError, auth.ErrRoleNotFound):
|
||||||
|
return NewValidationError(domainError.Error())
|
||||||
|
|
||||||
|
case errors.Is(domainError, auth.ErrUsernameTaken):
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusConflict,
|
||||||
|
Code: ErrorCodeConflict,
|
||||||
|
Message: "Dieser Benutzername ist bereits vergeben.",
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(domainError, auth.ErrEmailTaken):
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusConflict,
|
||||||
|
Code: ErrorCodeConflict,
|
||||||
|
Message: "Diese Mailadresse ist bereits vergeben.",
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(domainError, auth.ErrWeakPassword):
|
||||||
|
return NewValidationError(domainError.Error())
|
||||||
|
|
||||||
|
case errors.Is(domainError, auth.ErrLastAdministrator):
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusConflict,
|
||||||
|
Code: "LAST_ADMINISTRATOR",
|
||||||
|
Message: "Der letzte Administrator kann nicht entfernt oder entrechtet werden. Andernfalls wäre die Installation nicht mehr verwaltbar.",
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(domainError, auth.ErrSystemRoleImmutable):
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusConflict,
|
||||||
|
Code: "SYSTEM_ROLE_IMMUTABLE",
|
||||||
|
Message: "Mitgelieferte Rollen können nicht geändert oder gelöscht werden.",
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(domainError, auth.ErrRoleInUse):
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusConflict,
|
||||||
|
Code: "ROLE_IN_USE",
|
||||||
|
Message: domainError.Error(),
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(domainError, auth.ErrMFAAlreadyEnabled):
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusConflict,
|
||||||
|
Code: "MFA_ALREADY_ENABLED",
|
||||||
|
Message: "Für dieses Konto ist bereits ein zweiter Faktor eingerichtet.",
|
||||||
|
}
|
||||||
|
|
||||||
|
case errors.Is(domainError, auth.ErrMFANotEnrolled):
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusConflict,
|
||||||
|
Code: "MFA_NOT_ENROLLED",
|
||||||
|
Message: "Für dieses Konto ist kein zweiter Faktor eingerichtet.",
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unbekannte Fehler sind technische Probleme; ihre Ursache bleibt intern.
|
||||||
|
return NewInternalError(domainError)
|
||||||
|
}
|
||||||
|
}
|
||||||
334
apps/api/internal/httpapi/auth_middleware.go
Normal file
334
apps/api/internal/httpapi/auth_middleware.go
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/syncova/syncova/packages/audit"
|
||||||
|
"github.com/syncova/syncova/packages/auth"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// authenticatedUserContextKeyType ist der private Typ des Context-Schlüssels
|
||||||
|
// für den angemeldeten Benutzer.
|
||||||
|
type authenticatedUserContextKeyType struct{}
|
||||||
|
|
||||||
|
// sessionContextKeyType ist der private Typ des Context-Schlüssels für die Sitzung.
|
||||||
|
type sessionContextKeyType struct{}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// authenticatedUserContextKey speichert den angemeldeten Benutzer.
|
||||||
|
authenticatedUserContextKey = authenticatedUserContextKeyType{}
|
||||||
|
// sessionContextKey speichert die Kennung der aktiven Sitzung.
|
||||||
|
sessionContextKey = sessionContextKeyType{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthenticatedUserFromContext liest den angemeldeten Benutzer aus dem Context.
|
||||||
|
//
|
||||||
|
// Der zweite Rückgabewert ist false, wenn der Request nicht authentifiziert ist.
|
||||||
|
func AuthenticatedUserFromContext(currentContext context.Context) (auth.User, bool) {
|
||||||
|
authenticatedUser, isPresent := currentContext.Value(authenticatedUserContextKey).(auth.User)
|
||||||
|
return authenticatedUser, isPresent
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionIDFromContext liest die Kennung der aktiven Sitzung aus dem Context.
|
||||||
|
func SessionIDFromContext(currentContext context.Context) (uuid.UUID, bool) {
|
||||||
|
sessionID, isPresent := currentContext.Value(sessionContextKey).(uuid.UUID)
|
||||||
|
return sessionID, isPresent
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestContextFrom baut den Herkunftskontext für das Auditprotokoll.
|
||||||
|
func RequestContextFrom(request *http.Request) auth.RequestContext {
|
||||||
|
correlationID, _ := logging.CorrelationIDFromContext(request.Context())
|
||||||
|
|
||||||
|
return auth.RequestContext{
|
||||||
|
IPAddress: clientIPAddress(request),
|
||||||
|
UserAgent: request.UserAgent(),
|
||||||
|
CorrelationID: correlationID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientIPAddress ermittelt die Absenderadresse eines Requests.
|
||||||
|
//
|
||||||
|
// Weitergeleitete Adressen aus Headern werden bewusst NICHT ausgewertet: sie
|
||||||
|
// sind frei fälschbar und würden ein Auditprotokoll wertlos machen, solange
|
||||||
|
// nicht bekannt ist, welchem Proxy zu trauen ist.
|
||||||
|
func clientIPAddress(request *http.Request) string {
|
||||||
|
remoteHost, _, splitError := net.SplitHostPort(request.RemoteAddr)
|
||||||
|
if splitError != nil {
|
||||||
|
return request.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
return remoteHost
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthenticationMiddleware prüft das Zugriffstoken jedes Requests.
|
||||||
|
//
|
||||||
|
// Ohne gültiges Token endet der Request mit 401; der nachgelagerte Handler wird
|
||||||
|
// dann gar nicht erst erreicht (PROMPT.md §45).
|
||||||
|
func AuthenticationMiddleware(authService *auth.Service, baseLogger *slog.Logger) Middleware {
|
||||||
|
return func(nextHandler http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), baseLogger)
|
||||||
|
|
||||||
|
accessToken, extractError := extractBearerToken(request)
|
||||||
|
if extractError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, newUnauthenticatedError(
|
||||||
|
"Für diesen Zugriff ist eine Anmeldung erforderlich."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticatedUser, sessionID, authenticateError := authService.Authenticate(request.Context(), accessToken)
|
||||||
|
if authenticateError != nil {
|
||||||
|
// Die Ursache wird nicht offengelegt: ob ein Token unbekannt,
|
||||||
|
// abgelaufen oder widerrufen ist, geht den Aufrufer nichts an.
|
||||||
|
if errors.Is(authenticateError, auth.ErrAccountDisabled) {
|
||||||
|
WriteError(responseWriter, request, requestLogger, newUnauthenticatedError(
|
||||||
|
"Das Konto ist nicht mehr aktiv. Bitte an die Administration wenden."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine nicht erreichbare Datenbank ist kein Anmeldeproblem.
|
||||||
|
//
|
||||||
|
// Die Tokenpruefung braucht die Datenbank; faellt sie aus,
|
||||||
|
// scheitert jede Pruefung. Als „Sitzung abgelaufen" gemeldet
|
||||||
|
// schickt das den Betreiber auf die falsche Spur: Er meldet
|
||||||
|
// sich neu an, was ebenfalls scheitert, und sucht den Fehler
|
||||||
|
// bei der Anmeldung statt bei der Datenbank. In Phase 21 real
|
||||||
|
// beobachtet.
|
||||||
|
if errors.Is(authenticateError, context.DeadlineExceeded) ||
|
||||||
|
isDatabaseUnavailable(authenticateError) {
|
||||||
|
requestLogger.Error("die anmeldung liess sich nicht pruefen",
|
||||||
|
slog.String("grund", authenticateError.Error()))
|
||||||
|
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewServiceUnavailableError(
|
||||||
|
"Die Anmeldung lässt sich derzeit nicht prüfen: Die Datenbank ist "+
|
||||||
|
"nicht erreichbar. Das ist kein Problem Ihrer Sitzung."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteError(responseWriter, request, requestLogger, newUnauthenticatedError(
|
||||||
|
"Die Sitzung ist abgelaufen. Bitte erneut anmelden."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichedContext := context.WithValue(request.Context(), authenticatedUserContextKey, authenticatedUser)
|
||||||
|
enrichedContext = context.WithValue(enrichedContext, sessionContextKey, sessionID)
|
||||||
|
|
||||||
|
nextHandler.ServeHTTP(responseWriter, request.WithContext(enrichedContext))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequirePermission erzwingt eine Berechtigung für einen Handler.
|
||||||
|
//
|
||||||
|
// Die Prüfung erfolgt ausschließlich serverseitig; Angaben des Frontends werden
|
||||||
|
// niemals als Berechtigungsnachweis akzeptiert (PROMPT.md §42).
|
||||||
|
func RequirePermission(requiredPermission string, authService *auth.Service, auditRecorder audit.Recorder, baseLogger *slog.Logger, protectedHandler http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), baseLogger)
|
||||||
|
|
||||||
|
authenticatedUser, isAuthenticated := AuthenticatedUserFromContext(request.Context())
|
||||||
|
if !isAuthenticated {
|
||||||
|
// Dieser Fall bedeutet einen Fehler im Routenaufbau: der Handler
|
||||||
|
// wurde ohne vorgeschaltete Authentifizierung eingebunden.
|
||||||
|
requestLogger.Error("berechtigungsprüfung ohne authentifizierung aufgerufen",
|
||||||
|
slog.String("permission", requiredPermission),
|
||||||
|
slog.String("path", request.URL.Path))
|
||||||
|
|
||||||
|
WriteError(responseWriter, request, requestLogger, newUnauthenticatedError(
|
||||||
|
"Für diesen Zugriff ist eine Anmeldung erforderlich."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !authenticatedUser.HasPermission(requiredPermission) {
|
||||||
|
// Ein abgewiesener Zugriff ist ein Sicherheitsereignis (PROMPT.md §125).
|
||||||
|
correlationID, _ := logging.CorrelationIDFromContext(request.Context())
|
||||||
|
if recordError := auditRecorder.Record(request.Context(), audit.Event{
|
||||||
|
UserID: &authenticatedUser.ID,
|
||||||
|
ActorUsername: authenticatedUser.Username,
|
||||||
|
Action: audit.ActionPermissionDenied,
|
||||||
|
Result: audit.ResultDenied,
|
||||||
|
IPAddress: clientIPAddress(request),
|
||||||
|
UserAgent: request.UserAgent(),
|
||||||
|
CorrelationID: correlationID,
|
||||||
|
Details: map[string]any{
|
||||||
|
"benötigte_berechtigung": requiredPermission,
|
||||||
|
"pfad": request.URL.Path,
|
||||||
|
},
|
||||||
|
}); recordError != nil {
|
||||||
|
requestLogger.Error("abgewiesener zugriff konnte nicht protokolliert werden",
|
||||||
|
slog.String("error", recordError.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteError(responseWriter, request, requestLogger, &APIError{
|
||||||
|
StatusCode: http.StatusForbidden,
|
||||||
|
Code: ErrorCodePermissionDenied,
|
||||||
|
Message: "Für diese Aktion fehlt die erforderliche Berechtigung.",
|
||||||
|
Details: map[string]any{"required_permission": requiredPermission},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
protectedHandler(responseWriter, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractBearerToken liest das Zugriffstoken aus dem Authorization-Header.
|
||||||
|
func extractBearerToken(request *http.Request) (string, error) {
|
||||||
|
authorizationHeader := request.Header.Get("Authorization")
|
||||||
|
if authorizationHeader == "" {
|
||||||
|
return "", errors.New("kein authorization-header vorhanden")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Schema wird ohne Beachtung der Groß-/Kleinschreibung verglichen.
|
||||||
|
const bearerPrefix = "bearer "
|
||||||
|
if len(authorizationHeader) <= len(bearerPrefix) || !strings.EqualFold(authorizationHeader[:len(bearerPrefix)], bearerPrefix) {
|
||||||
|
return "", errors.New("das authorization-schema ist nicht Bearer")
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := strings.TrimSpace(authorizationHeader[len(bearerPrefix):])
|
||||||
|
if accessToken == "" {
|
||||||
|
return "", errors.New("das token ist leer")
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newUnauthenticatedError baut eine Antwort für einen fehlenden Nachweis.
|
||||||
|
func newUnauthenticatedError(errorMessage string) *APIError {
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusUnauthorized,
|
||||||
|
Code: ErrorCodeUnauthenticated,
|
||||||
|
Message: errorMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rate Limiting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// RateLimiter begrenzt die Anzahl Versuche je Absender.
|
||||||
|
//
|
||||||
|
// Der Zähler liegt im Arbeitsspeicher: für die Control Plane mit einem Knoten
|
||||||
|
// genügt das. Bei mehreren Knoten müsste er in eine gemeinsame Ablage wandern.
|
||||||
|
type RateLimiter struct {
|
||||||
|
// mutex schützt die Zählerkarte gegen gleichzeitige Zugriffe.
|
||||||
|
mutex sync.Mutex
|
||||||
|
// attemptsByKey hält die Zeitpunkte der Versuche je Absender.
|
||||||
|
attemptsByKey map[string][]time.Time
|
||||||
|
// maxAttempts ist die erlaubte Anzahl Versuche im Zeitfenster.
|
||||||
|
maxAttempts int
|
||||||
|
// window ist die Länge des betrachteten Zeitfensters.
|
||||||
|
window time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRateLimiter erzeugt einen Begrenzer.
|
||||||
|
func NewRateLimiter(maxAttempts int, window time.Duration) *RateLimiter {
|
||||||
|
return &RateLimiter{
|
||||||
|
attemptsByKey: make(map[string][]time.Time),
|
||||||
|
maxAttempts: maxAttempts,
|
||||||
|
window: window,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow meldet, ob ein weiterer Versuch zulässig ist, und vermerkt ihn.
|
||||||
|
func (limiter *RateLimiter) Allow(limiterKey string) bool {
|
||||||
|
limiter.mutex.Lock()
|
||||||
|
defer limiter.mutex.Unlock()
|
||||||
|
|
||||||
|
currentTime := time.Now()
|
||||||
|
windowStart := currentTime.Add(-limiter.window)
|
||||||
|
|
||||||
|
// Versuche ausserhalb des Zeitfensters werden verworfen.
|
||||||
|
recentAttempts := make([]time.Time, 0, len(limiter.attemptsByKey[limiterKey]))
|
||||||
|
for _, attemptTime := range limiter.attemptsByKey[limiterKey] {
|
||||||
|
if attemptTime.After(windowStart) {
|
||||||
|
recentAttempts = append(recentAttempts, attemptTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(recentAttempts) >= limiter.maxAttempts {
|
||||||
|
limiter.attemptsByKey[limiterKey] = recentAttempts
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
limiter.attemptsByKey[limiterKey] = append(recentAttempts, currentTime)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup entfernt Einträge, deren Versuche vollständig abgelaufen sind.
|
||||||
|
//
|
||||||
|
// Ohne diese Bereinigung wüchse die Karte mit jeder neuen Absenderadresse.
|
||||||
|
func (limiter *RateLimiter) Cleanup() {
|
||||||
|
limiter.mutex.Lock()
|
||||||
|
defer limiter.mutex.Unlock()
|
||||||
|
|
||||||
|
windowStart := time.Now().Add(-limiter.window)
|
||||||
|
|
||||||
|
for limiterKey, attemptTimes := range limiter.attemptsByKey {
|
||||||
|
hasRecentAttempt := false
|
||||||
|
for _, attemptTime := range attemptTimes {
|
||||||
|
if attemptTime.After(windowStart) {
|
||||||
|
hasRecentAttempt = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasRecentAttempt {
|
||||||
|
delete(limiter.attemptsByKey, limiterKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimitMiddleware begrenzt Anfragen je Absenderadresse.
|
||||||
|
//
|
||||||
|
// Sie schützt die Anmeldung vor dem Durchprobieren von Passwörtern (PROMPT.md §45).
|
||||||
|
func RateLimitMiddleware(limiter *RateLimiter, baseLogger *slog.Logger) Middleware {
|
||||||
|
return func(nextHandler http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
if !limiter.Allow(clientIPAddress(request)) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), baseLogger)
|
||||||
|
requestLogger.Warn("anfrage wegen zu vieler versuche abgewiesen",
|
||||||
|
slog.String("path", request.URL.Path))
|
||||||
|
|
||||||
|
WriteError(responseWriter, request, requestLogger, &APIError{
|
||||||
|
StatusCode: http.StatusTooManyRequests,
|
||||||
|
Code: ErrorCodeRateLimited,
|
||||||
|
Message: "Zu viele Versuche. Bitte einige Minuten warten und erneut versuchen.",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nextHandler.ServeHTTP(responseWriter, request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDatabaseUnavailable erkennt einen Ausfall der Datenbank.
|
||||||
|
//
|
||||||
|
// Geprueft wird der Verbindungsfehler, nicht der Meldungstext: Ein
|
||||||
|
// Textvergleich braeche bei der ersten geaenderten Fehlermeldung des Treibers,
|
||||||
|
// und zwar unbemerkt — der Ausfall erschiene dann wieder als Anmeldeproblem.
|
||||||
|
func isDatabaseUnavailable(occurredError error) bool {
|
||||||
|
// Ein Netzfehler auf dem Weg zur Datenbank: kein Rechner, keine Route,
|
||||||
|
// keine Verbindung.
|
||||||
|
var networkError net.Error
|
||||||
|
if errors.As(occurredError, &networkError) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectError *pgconn.ConnectError
|
||||||
|
|
||||||
|
return errors.As(occurredError, &connectError)
|
||||||
|
}
|
||||||
172
apps/api/internal/httpapi/auth_middleware_test.go
Normal file
172
apps/api/internal/httpapi/auth_middleware_test.go
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtractBearerToken(testInstance *testing.T) {
|
||||||
|
validHeaders := map[string]string{
|
||||||
|
"Bearer abc123": "abc123",
|
||||||
|
"bearer abc123": "abc123",
|
||||||
|
"BEARER abc123": "abc123",
|
||||||
|
"Bearer abc123": "abc123",
|
||||||
|
}
|
||||||
|
|
||||||
|
for headerValue, expectedToken := range validHeaders {
|
||||||
|
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
|
||||||
|
testRequest.Header.Set("Authorization", headerValue)
|
||||||
|
|
||||||
|
extractedToken, extractError := extractBearerToken(testRequest)
|
||||||
|
if extractError != nil {
|
||||||
|
testInstance.Errorf("der Header %q wurde abgelehnt: %v", headerValue, extractError)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if extractedToken != expectedToken {
|
||||||
|
testInstance.Errorf("der Header %q lieferte %q statt %q", headerValue, extractedToken, expectedToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractBearerTokenRejectsInvalidHeaders(testInstance *testing.T) {
|
||||||
|
invalidHeaders := map[string]string{
|
||||||
|
"leer": "",
|
||||||
|
"nur Schema": "Bearer",
|
||||||
|
"leeres Token": "Bearer ",
|
||||||
|
"falsches Schema": "Basic abc123",
|
||||||
|
"ohne Schema": "abc123",
|
||||||
|
}
|
||||||
|
|
||||||
|
for headerDescription, headerValue := range invalidHeaders {
|
||||||
|
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
|
||||||
|
if headerValue != "" {
|
||||||
|
testRequest.Header.Set("Authorization", headerValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein unbrauchbarer Header darf niemals als gültiger Nachweis durchgehen.
|
||||||
|
if _, extractError := extractBearerToken(testRequest); extractError == nil {
|
||||||
|
testInstance.Errorf("%s: der Header %q wurde akzeptiert", headerDescription, headerValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIPAddressIgnoresForwardedHeaders(testInstance *testing.T) {
|
||||||
|
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
|
||||||
|
testRequest.RemoteAddr = "192.0.2.10:54321"
|
||||||
|
|
||||||
|
// Diese Header sind frei fälschbar. Würden sie ausgewertet, könnte ein
|
||||||
|
// Angreifer die Herkunft im Auditprotokoll beliebig verfälschen.
|
||||||
|
testRequest.Header.Set("X-Forwarded-For", "10.0.0.1")
|
||||||
|
testRequest.Header.Set("X-Real-IP", "10.0.0.2")
|
||||||
|
|
||||||
|
determinedAddress := clientIPAddress(testRequest)
|
||||||
|
if determinedAddress != "192.0.2.10" {
|
||||||
|
testInstance.Fatalf("es soll die echte Absenderadresse verwendet werden, war %q", determinedAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimiterAllowsUpToLimit(testInstance *testing.T) {
|
||||||
|
testLimiter := NewRateLimiter(3, time.Minute)
|
||||||
|
|
||||||
|
for attemptIndex := 1; attemptIndex <= 3; attemptIndex++ {
|
||||||
|
if !testLimiter.Allow("192.0.2.1") {
|
||||||
|
testInstance.Fatalf("Versuch %d soll erlaubt sein", attemptIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der vierte Versuch überschreitet das Limit.
|
||||||
|
if testLimiter.Allow("192.0.2.1") {
|
||||||
|
testInstance.Fatal("der vierte Versuch soll abgelehnt werden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimiterSeparatesSenders(testInstance *testing.T) {
|
||||||
|
testLimiter := NewRateLimiter(2, time.Minute)
|
||||||
|
|
||||||
|
testLimiter.Allow("192.0.2.1")
|
||||||
|
testLimiter.Allow("192.0.2.1")
|
||||||
|
|
||||||
|
// Ein anderer Absender darf durch fremde Versuche nicht ausgesperrt werden.
|
||||||
|
if !testLimiter.Allow("192.0.2.2") {
|
||||||
|
testInstance.Fatal("ein anderer Absender soll unabhängig gezählt werden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimiterForgetsOldAttempts(testInstance *testing.T) {
|
||||||
|
// Ein sehr kurzes Fenster macht das Ablaufen im Test beobachtbar.
|
||||||
|
testLimiter := NewRateLimiter(1, 50*time.Millisecond)
|
||||||
|
|
||||||
|
if !testLimiter.Allow("192.0.2.1") {
|
||||||
|
testInstance.Fatal("der erste Versuch soll erlaubt sein")
|
||||||
|
}
|
||||||
|
|
||||||
|
if testLimiter.Allow("192.0.2.1") {
|
||||||
|
testInstance.Fatal("der zweite Versuch soll im selben Fenster abgelehnt werden")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(60 * time.Millisecond)
|
||||||
|
|
||||||
|
if !testLimiter.Allow("192.0.2.1") {
|
||||||
|
testInstance.Fatal("nach Ablauf des Fensters soll wieder ein Versuch erlaubt sein")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimiterCleanupRemovesStaleEntries(testInstance *testing.T) {
|
||||||
|
testLimiter := NewRateLimiter(5, 50*time.Millisecond)
|
||||||
|
|
||||||
|
testLimiter.Allow("192.0.2.1")
|
||||||
|
testLimiter.Allow("192.0.2.2")
|
||||||
|
|
||||||
|
time.Sleep(60 * time.Millisecond)
|
||||||
|
testLimiter.Cleanup()
|
||||||
|
|
||||||
|
testLimiter.mutex.Lock()
|
||||||
|
remainingEntries := len(testLimiter.attemptsByKey)
|
||||||
|
testLimiter.mutex.Unlock()
|
||||||
|
|
||||||
|
// Ohne Bereinigung wüchse die Karte mit jeder neuen Absenderadresse.
|
||||||
|
if remainingEntries != 0 {
|
||||||
|
testInstance.Fatalf("die Bereinigung soll abgelaufene Einträge entfernen, es blieben %d", remainingEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRateLimiterIsSafeForConcurrentUse(testInstance *testing.T) {
|
||||||
|
testLimiter := NewRateLimiter(1000, time.Minute)
|
||||||
|
|
||||||
|
// Der Begrenzer wird aus vielen Requests gleichzeitig aufgerufen.
|
||||||
|
var waitGroup sync.WaitGroup
|
||||||
|
for goroutineIndex := 0; goroutineIndex < 50; goroutineIndex++ {
|
||||||
|
waitGroup.Add(1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer waitGroup.Done()
|
||||||
|
|
||||||
|
for callIndex := 0; callIndex < 20; callIndex++ {
|
||||||
|
testLimiter.Allow("192.0.2.1")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
waitGroup.Wait()
|
||||||
|
|
||||||
|
testLimiter.mutex.Lock()
|
||||||
|
recordedAttempts := len(testLimiter.attemptsByKey["192.0.2.1"])
|
||||||
|
testLimiter.mutex.Unlock()
|
||||||
|
|
||||||
|
if recordedAttempts != 1000 {
|
||||||
|
testInstance.Fatalf("es sollen 1000 Versuche gezählt sein, waren %d", recordedAttempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthenticatedUserFromContextWithoutUser(testInstance *testing.T) {
|
||||||
|
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
|
||||||
|
|
||||||
|
// Ohne Authentifizierung darf kein Benutzer im Context liegen.
|
||||||
|
if _, isPresent := AuthenticatedUserFromContext(testRequest.Context()); isPresent {
|
||||||
|
testInstance.Fatal("ohne Anmeldung darf kein Benutzer gefunden werden")
|
||||||
|
}
|
||||||
|
}
|
||||||
124
apps/api/internal/httpapi/contract_routes.txt
Normal file
124
apps/api/internal/httpapi/contract_routes.txt
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
# Eingefrorener API-Vertrag (Phase 22)
|
||||||
|
#
|
||||||
|
# Eine Zeile je Endpunkt: <METHODE> <pfad> <berechtigung>
|
||||||
|
#
|
||||||
|
# "/api/v1" ist ausgeliefert. Diese Datei zu aendern ist erlaubt — aber es ist
|
||||||
|
# eine bewusste Handlung, und genau darum geht es. Drei Faelle:
|
||||||
|
#
|
||||||
|
# Neuer Endpunkt -> eintragen. Er nimmt niemandem etwas weg.
|
||||||
|
# Geaenderte Berechtigung -> pruefen. Eine gelockerte Pruefung ist die
|
||||||
|
# gefaehrlichste Aenderung ueberhaupt: Der Endpunkt
|
||||||
|
# funktioniert weiter, nur duerfen ihn ploetzlich
|
||||||
|
# mehr Leute aufrufen.
|
||||||
|
# Entfallener Endpunkt -> gehoert nicht hierher. Bestehende Aufrufer brechen;
|
||||||
|
# das ist ein Fall fuer /api/v2.
|
||||||
|
#
|
||||||
|
# "-" bedeutet: ohne Anmeldung erreichbar. Das sind genau die Endpunkte, die es
|
||||||
|
# sein muessen — Anmeldung selbst und die Aufnahme eines Agenten, der noch kein
|
||||||
|
# Betriebstoken besitzt.
|
||||||
|
# "agent-token" bedeutet: Betriebstoken eines Agenten statt Benutzersitzung.
|
||||||
|
# "sitzung" bedeutet: angemeldet, aber ohne besondere Berechtigung — die
|
||||||
|
# Endpunkte, die jeder ueber sich selbst aufruft.
|
||||||
|
|
||||||
|
DELETE /api/v1/backups/{id} backups.delete
|
||||||
|
DELETE /api/v1/backups/{id}/legal-hold immutability.manage
|
||||||
|
DELETE /api/v1/jobs/{id} jobs.write
|
||||||
|
DELETE /api/v1/notification-channels/{id} settings.write
|
||||||
|
DELETE /api/v1/proxmox/clusters/{id} providers.write
|
||||||
|
DELETE /api/v1/retention-policies/{id} retention.write
|
||||||
|
DELETE /api/v1/roles/{id} roles.write
|
||||||
|
DELETE /api/v1/users/{id} users.write
|
||||||
|
GET /api/v1/agents agents.read
|
||||||
|
GET /api/v1/agents/{id} agents.read
|
||||||
|
GET /api/v1/agents/{id}/health agents.read
|
||||||
|
GET /api/v1/alerts alerts.read
|
||||||
|
GET /api/v1/alerts/summary alerts.read
|
||||||
|
GET /api/v1/alerts/{id} alerts.read
|
||||||
|
GET /api/v1/audit-events audit.read
|
||||||
|
GET /api/v1/backups backups.read
|
||||||
|
GET /api/v1/backups/{id}/assurance backups.read
|
||||||
|
GET /api/v1/backups/{id}/protection backups.read
|
||||||
|
GET /api/v1/backups/{id}/ransomware-assessment backups.read
|
||||||
|
GET /api/v1/dashboard backups.read
|
||||||
|
GET /api/v1/health -
|
||||||
|
GET /api/v1/jobs jobs.read
|
||||||
|
GET /api/v1/jobs/{id} jobs.read
|
||||||
|
GET /api/v1/jobs/{id}/runs jobs.read
|
||||||
|
GET /api/v1/me sitzung
|
||||||
|
GET /api/v1/metrics monitoring.read
|
||||||
|
GET /api/v1/metrics/{metric} monitoring.read
|
||||||
|
GET /api/v1/notification-channels settings.read
|
||||||
|
GET /api/v1/permissions roles.read
|
||||||
|
GET /api/v1/proxmox/clusters providers.read
|
||||||
|
GET /api/v1/proxmox/clusters/{id} providers.read
|
||||||
|
GET /api/v1/proxmox/clusters/{id}/hosts providers.read
|
||||||
|
GET /api/v1/proxmox/clusters/{id}/vms providers.read
|
||||||
|
GET /api/v1/proxmox/vms/{id} providers.read
|
||||||
|
GET /api/v1/reports reports.read
|
||||||
|
GET /api/v1/repositories repositories.read
|
||||||
|
GET /api/v1/repositories/{id} repositories.read
|
||||||
|
GET /api/v1/restores restores.read
|
||||||
|
GET /api/v1/restores/{id} restores.read
|
||||||
|
GET /api/v1/retention-policies repositories.read
|
||||||
|
GET /api/v1/roles roles.read
|
||||||
|
GET /api/v1/roles/{id} roles.read
|
||||||
|
GET /api/v1/security security.read
|
||||||
|
GET /api/v1/security/findings security.read
|
||||||
|
GET /api/v1/users users.read
|
||||||
|
GET /api/v1/users/{id} users.read
|
||||||
|
GET /api/v1/verification verification.read
|
||||||
|
GET /api/v1/verification/{id} verification.read
|
||||||
|
GET /api/v1/verification/{id}/results verification.read
|
||||||
|
GET /api/v1/virtual-machines providers.read
|
||||||
|
GET /health/live -
|
||||||
|
GET /health/ready -
|
||||||
|
PATCH /api/v1/repositories/{id} repositories.write
|
||||||
|
PATCH /api/v1/retention-policies/{id} retention.write
|
||||||
|
PATCH /api/v1/roles/{id} roles.write
|
||||||
|
PATCH /api/v1/users/{id} users.write
|
||||||
|
POST /api/v1/agents/enrollment-tokens agents.enroll
|
||||||
|
POST /api/v1/agents/heartbeat agent-token
|
||||||
|
POST /api/v1/agents/register -
|
||||||
|
POST /api/v1/agents/tasks/claim agent-token
|
||||||
|
POST /api/v1/agents/tasks/{id}/progress agent-token
|
||||||
|
POST /api/v1/agents/tasks/{id}/result agent-token
|
||||||
|
POST /api/v1/agents/{id}/revoke agents.write
|
||||||
|
POST /api/v1/agents/{id}/rotate-credentials agents.write
|
||||||
|
POST /api/v1/alerts/{id}/acknowledge alerts.write
|
||||||
|
POST /api/v1/alerts/{id}/resolve alerts.write
|
||||||
|
POST /api/v1/auth/login -
|
||||||
|
POST /api/v1/auth/logout sitzung
|
||||||
|
POST /api/v1/auth/mfa/verify -
|
||||||
|
POST /api/v1/auth/refresh -
|
||||||
|
POST /api/v1/backup-runs/{id}/cancel jobs.run
|
||||||
|
POST /api/v1/backups/{id}/legal-hold immutability.manage
|
||||||
|
POST /api/v1/backups/{id}/retention/extend immutability.manage
|
||||||
|
POST /api/v1/jobs jobs.write
|
||||||
|
POST /api/v1/jobs/{id}/pause jobs.run
|
||||||
|
POST /api/v1/jobs/{id}/resume jobs.run
|
||||||
|
POST /api/v1/jobs/{id}/run jobs.run
|
||||||
|
POST /api/v1/me/mfa/confirm sitzung
|
||||||
|
POST /api/v1/me/mfa/enroll sitzung
|
||||||
|
POST /api/v1/notification-channels settings.write
|
||||||
|
POST /api/v1/proxmox/clusters providers.write
|
||||||
|
POST /api/v1/proxmox/clusters/{id}/discover providers.write
|
||||||
|
POST /api/v1/proxmox/clusters/{id}/test providers.write
|
||||||
|
POST /api/v1/reports/generate reports.read
|
||||||
|
POST /api/v1/repositories repositories.write
|
||||||
|
POST /api/v1/repositories/{id}/enforcement/measure repositories.write
|
||||||
|
POST /api/v1/repositories/{id}/health-check repositories.read
|
||||||
|
POST /api/v1/repositories/{id}/integrity-scan repositories.write
|
||||||
|
POST /api/v1/repositories/{id}/rebuild-catalog repositories.write
|
||||||
|
POST /api/v1/repositories/{id}/retention/apply retention.write
|
||||||
|
POST /api/v1/repositories/{id}/retention/preview repositories.read
|
||||||
|
POST /api/v1/repositories/{id}/test repositories.read
|
||||||
|
POST /api/v1/restores restores.execute
|
||||||
|
POST /api/v1/restores/validate restores.read
|
||||||
|
POST /api/v1/restores/{id}/cancel restores.execute
|
||||||
|
POST /api/v1/restores/{id}/resume restores.execute
|
||||||
|
POST /api/v1/retention-policies retention.write
|
||||||
|
POST /api/v1/roles roles.write
|
||||||
|
POST /api/v1/users users.write
|
||||||
|
POST /api/v1/users/{id}/mfa/disable users.write
|
||||||
|
POST /api/v1/verification verification.write
|
||||||
|
POST /api/v1/verification/{id}/cancel verification.write
|
||||||
261
apps/api/internal/httpapi/contract_test.go
Normal file
261
apps/api/internal/httpapi/contract_test.go
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// contractGoldenFile ist die eingefrorene Endpunktliste.
|
||||||
|
const contractGoldenFile = "contract_routes.txt"
|
||||||
|
|
||||||
|
// TestAPIContractIsFrozen haelt den API-Vertrag fest (Phase 22).
|
||||||
|
//
|
||||||
|
// Der Vertrag ist eingefroren: `/api/v1` ist ausgeliefert, und jede Aenderung
|
||||||
|
// daran bricht bestehende Aufrufer. Ein Endpunkt, der still verschwindet oder
|
||||||
|
// eine andere Berechtigung bekommt, faellt niemandem auf, bis ein Kunde anruft
|
||||||
|
// — genau deshalb steht er hier in einer Datei, die man **absichtlich** aendern
|
||||||
|
// muss.
|
||||||
|
//
|
||||||
|
// Geprueft wird der Quelltext des Routers, nicht der gebaute Multiplexer:
|
||||||
|
// http.ServeMux gibt seine Routen nicht heraus, und ein Test, der nur die
|
||||||
|
// bekannten Routen anfragt, bemerkt eine **hinzugefuegte** nicht. Eine neue
|
||||||
|
// Route ist aber genau die haeufigste Vertragsaenderung.
|
||||||
|
//
|
||||||
|
// Bricht dieser Test, ist das keine Panne, sondern die Frage: Ist die Aenderung
|
||||||
|
// abwaertskompatibel? Wenn nein, gehoert sie hinter `/api/v2`.
|
||||||
|
func TestAPIContractIsFrozen(testInstance *testing.T) {
|
||||||
|
registeredRoutes := extractRegisteredRoutes(testInstance, "router.go")
|
||||||
|
|
||||||
|
goldenContent, readError := os.ReadFile(contractGoldenFile)
|
||||||
|
if readError != nil {
|
||||||
|
testInstance.Fatalf("die eingefrorene Endpunktliste ließ sich nicht lesen: %v", readError)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedRoutes := parseGoldenRoutes(string(goldenContent))
|
||||||
|
|
||||||
|
reportRouteDifferences(testInstance, expectedRoutes, registeredRoutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportRouteDifferences nennt jede Abweichung einzeln.
|
||||||
|
//
|
||||||
|
// Eine reine "stimmt nicht ueberein"-Meldung zwaenge zum Vergleichen von Hand;
|
||||||
|
// bei knapp hundert Endpunkten sucht das niemand freiwillig.
|
||||||
|
func reportRouteDifferences(testInstance *testing.T, expectedRoutes []string, actualRoutes []string) {
|
||||||
|
testInstance.Helper()
|
||||||
|
|
||||||
|
expectedSet := make(map[string]bool, len(expectedRoutes))
|
||||||
|
for _, singleRoute := range expectedRoutes {
|
||||||
|
expectedSet[singleRoute] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
actualSet := make(map[string]bool, len(actualRoutes))
|
||||||
|
for _, singleRoute := range actualRoutes {
|
||||||
|
actualSet[singleRoute] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, singleRoute := range actualRoutes {
|
||||||
|
if !expectedSet[singleRoute] {
|
||||||
|
testInstance.Errorf("neuer oder geänderter Endpunkt: %s\n"+
|
||||||
|
" Ist er abwärtskompatibel? Dann in %s eintragen. Wenn nicht, gehört er hinter /api/v2.",
|
||||||
|
singleRoute, contractGoldenFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, singleRoute := range expectedRoutes {
|
||||||
|
if !actualSet[singleRoute] {
|
||||||
|
testInstance.Errorf("entfallener Endpunkt: %s\n"+
|
||||||
|
" Ein ausgelieferter Endpunkt darf nicht verschwinden — bestehende Aufrufer brechen.",
|
||||||
|
singleRoute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractRegisteredRoutes liest die Routen aus dem Quelltext des Routers.
|
||||||
|
//
|
||||||
|
// Jede Zeile hat die Form "<METHODE> <pfad> <berechtigung>". Die Berechtigung
|
||||||
|
// gehoert dazu, weil eine stillschweigend gelockerte Pruefung die gefaehrlichste
|
||||||
|
// Vertragsaenderung ueberhaupt waere: Der Endpunkt funktioniert weiter, nur
|
||||||
|
// duerfen ihn ploetzlich mehr Leute aufrufen.
|
||||||
|
func extractRegisteredRoutes(testInstance *testing.T, sourceFileName string) []string {
|
||||||
|
testInstance.Helper()
|
||||||
|
|
||||||
|
fileSet := token.NewFileSet()
|
||||||
|
|
||||||
|
parsedFile, parseError := parser.ParseFile(fileSet, sourceFileName, nil, parser.SkipObjectResolution)
|
||||||
|
if parseError != nil {
|
||||||
|
testInstance.Fatalf("der Router ließ sich nicht auswerten: %v", parseError)
|
||||||
|
}
|
||||||
|
|
||||||
|
collectedRoutes := make([]string, 0, 128)
|
||||||
|
|
||||||
|
ast.Inspect(parsedFile, func(currentNode ast.Node) bool {
|
||||||
|
callExpression, isCall := currentNode.(*ast.CallExpr)
|
||||||
|
if !isCall {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
methodName, isMultiplexerCall := multiplexerMethodName(callExpression)
|
||||||
|
if !isMultiplexerCall || len(callExpression.Args) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
routePattern, patternResolved := resolveRoutePattern(callExpression.Args[0])
|
||||||
|
if !patternResolved {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Auffangroute "/" ist kein fachlicher Endpunkt, sondern die
|
||||||
|
// Fehlerhülle für alles Unbekannte.
|
||||||
|
if routePattern == "/" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
requiredPermission := "-"
|
||||||
|
|
||||||
|
if methodName == "Handle" && len(callExpression.Args) > 1 {
|
||||||
|
requiredPermission = resolveRequiredPermission(callExpression.Args[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
collectedRoutes = append(collectedRoutes, routePattern+" "+requiredPermission)
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
sort.Strings(collectedRoutes)
|
||||||
|
|
||||||
|
return collectedRoutes
|
||||||
|
}
|
||||||
|
|
||||||
|
// multiplexerMethodName erkennt einen Aufruf auf dem Router.
|
||||||
|
func multiplexerMethodName(callExpression *ast.CallExpr) (string, bool) {
|
||||||
|
selectorExpression, isSelector := callExpression.Fun.(*ast.SelectorExpr)
|
||||||
|
if !isSelector {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
receiverIdentifier, isIdentifier := selectorExpression.X.(*ast.Ident)
|
||||||
|
if !isIdentifier || receiverIdentifier.Name != "requestMultiplexer" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectorExpression.Sel.Name != "Handle" && selectorExpression.Sel.Name != "HandleFunc" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectorExpression.Sel.Name, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRoutePattern setzt das Routenmuster aus dem Ausdruck zusammen.
|
||||||
|
//
|
||||||
|
// Die Routen stehen als `"GET " + apiBasePath + "/agents"` im Quelltext; der
|
||||||
|
// Basispfad wird dabei aufgeloest, damit die eingefrorene Liste die
|
||||||
|
// tatsaechlichen Adressen enthaelt und nicht eine Konstante.
|
||||||
|
func resolveRoutePattern(patternExpression ast.Expr) (string, bool) {
|
||||||
|
switch typedExpression := patternExpression.(type) {
|
||||||
|
case *ast.BasicLit:
|
||||||
|
if typedExpression.Kind != token.STRING {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
unquotedValue, unquoteError := strconv.Unquote(typedExpression.Value)
|
||||||
|
if unquoteError != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return unquotedValue, true
|
||||||
|
|
||||||
|
case *ast.Ident:
|
||||||
|
if typedExpression.Name == "apiBasePath" {
|
||||||
|
return apiBasePath, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
|
||||||
|
case *ast.BinaryExpr:
|
||||||
|
if typedExpression.Op != token.ADD {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
leftValue, leftResolved := resolveRoutePattern(typedExpression.X)
|
||||||
|
rightValue, rightResolved := resolveRoutePattern(typedExpression.Y)
|
||||||
|
|
||||||
|
if !leftResolved || !rightResolved {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return leftValue + rightValue, true
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRequiredPermission liest die geforderte Berechtigung aus dem Aufruf.
|
||||||
|
//
|
||||||
|
// Der übliche Fall ist `protected("agents.read", handler)`. Alles andere —
|
||||||
|
// etwa die Middleware der Agentenanmeldung — wird als solches benannt statt
|
||||||
|
// stillschweigend als "keine Berechtigung" geführt.
|
||||||
|
func resolveRequiredPermission(handlerExpression ast.Expr) string {
|
||||||
|
callExpression, isCall := handlerExpression.(*ast.CallExpr)
|
||||||
|
if !isCall || len(callExpression.Args) == 0 {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
functionIdentifier, isIdentifier := callExpression.Fun.(*ast.Ident)
|
||||||
|
if !isIdentifier {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
if functionIdentifier.Name == "agentAuthenticated" {
|
||||||
|
return "agent-token"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Angemeldet, aber ohne besondere Berechtigung: die Endpunkte, die jeder
|
||||||
|
// über sich selbst aufruft. Sie als "?" zu führen wäre die schlechteste
|
||||||
|
// Auskunft — eine Unklarheit in einem eingefrorenen Vertrag verdeckt genau
|
||||||
|
// die Änderung, die er aufdecken soll.
|
||||||
|
if functionIdentifier.Name == "authenticated" {
|
||||||
|
return "sitzung"
|
||||||
|
}
|
||||||
|
|
||||||
|
if functionIdentifier.Name != "protected" {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
permissionLiteral, isLiteral := callExpression.Args[0].(*ast.BasicLit)
|
||||||
|
if !isLiteral || permissionLiteral.Kind != token.STRING {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
unquotedPermission, unquoteError := strconv.Unquote(permissionLiteral.Value)
|
||||||
|
if unquoteError != nil {
|
||||||
|
return "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
return unquotedPermission
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseGoldenRoutes liest die eingefrorene Liste.
|
||||||
|
func parseGoldenRoutes(fileContent string) []string {
|
||||||
|
parsedRoutes := make([]string, 0, 128)
|
||||||
|
|
||||||
|
for _, currentLine := range strings.Split(fileContent, "\n") {
|
||||||
|
trimmedLine := strings.TrimSpace(currentLine)
|
||||||
|
|
||||||
|
if trimmedLine == "" || strings.HasPrefix(trimmedLine, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedRoutes = append(parsedRoutes, trimmedLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(parsedRoutes)
|
||||||
|
|
||||||
|
return parsedRoutes
|
||||||
|
}
|
||||||
133
apps/api/internal/httpapi/errors.go
Normal file
133
apps/api/internal/httpapi/errors.go
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
// Package httpapi implementiert die öffentliche REST-Schnittstelle laut SYNCOVA_API.md.
|
||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorCode ist ein stabiler, maschinenlesbarer Fehlercode der API.
|
||||||
|
//
|
||||||
|
// Codes sind Teil des API-Vertrags: Clients dürfen auf sie reagieren, während
|
||||||
|
// die Fehlermeldung sich ändern darf. Sie beschreiben die Ursache verständlich,
|
||||||
|
// ohne interne Details preiszugeben (SYNCOVA_API.md §26).
|
||||||
|
type ErrorCode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ErrorCodeBadRequest meldet einen syntaktisch fehlerhaften Request.
|
||||||
|
ErrorCodeBadRequest ErrorCode = "BAD_REQUEST"
|
||||||
|
// ErrorCodeValidationFailed meldet fachlich ungültige Eingabewerte.
|
||||||
|
ErrorCodeValidationFailed ErrorCode = "VALIDATION_FAILED"
|
||||||
|
// ErrorCodeUnauthenticated meldet einen fehlenden oder ungültigen Nachweis.
|
||||||
|
ErrorCodeUnauthenticated ErrorCode = "UNAUTHENTICATED"
|
||||||
|
// ErrorCodePermissionDenied meldet eine fehlende Berechtigung.
|
||||||
|
ErrorCodePermissionDenied ErrorCode = "PERMISSION_DENIED"
|
||||||
|
// ErrorCodeNotFound meldet eine nicht vorhandene Ressource.
|
||||||
|
ErrorCodeNotFound ErrorCode = "NOT_FOUND"
|
||||||
|
// ErrorCodeMethodNotAllowed meldet eine für diese Route unzulässige HTTP-Methode.
|
||||||
|
ErrorCodeMethodNotAllowed ErrorCode = "METHOD_NOT_ALLOWED"
|
||||||
|
// ErrorCodeConflict meldet einen Konflikt mit dem aktuellen Zustand.
|
||||||
|
ErrorCodeConflict ErrorCode = "CONFLICT"
|
||||||
|
// ErrorCodePayloadTooLarge meldet eine Überschreitung des Body-Limits.
|
||||||
|
ErrorCodePayloadTooLarge ErrorCode = "PAYLOAD_TOO_LARGE"
|
||||||
|
// ErrorCodeRateLimited meldet zu viele Anfragen.
|
||||||
|
ErrorCodeRateLimited ErrorCode = "RATE_LIMITED"
|
||||||
|
// ErrorCodeInternal meldet einen unerwarteten Serverfehler.
|
||||||
|
ErrorCodeInternal ErrorCode = "INTERNAL_ERROR"
|
||||||
|
// ErrorCodeServiceUnavailable meldet einen vorübergehend nicht verfügbaren Dienst.
|
||||||
|
ErrorCodeServiceUnavailable ErrorCode = "SERVICE_UNAVAILABLE"
|
||||||
|
// ErrorCodeNotImplemented meldet eine bewusst noch nicht implementierte Funktion.
|
||||||
|
//
|
||||||
|
// PROMPT.md §138 verlangt diese ehrliche Antwort statt einer vorgetäuschten
|
||||||
|
// Funktion oder erfundener Daten.
|
||||||
|
ErrorCodeNotImplemented ErrorCode = "NOT_IMPLEMENTED"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIError ist ein Fehler, der sich unmittelbar in eine API-Antwort übersetzen lässt.
|
||||||
|
//
|
||||||
|
// Er trennt die für den Aufrufer bestimmte Darstellung von der internen Ursache:
|
||||||
|
// Letztere wird geloggt, aber niemals ausgeliefert (SYNCOVA_API.md §26).
|
||||||
|
type APIError struct {
|
||||||
|
// StatusCode ist der auszuliefernde HTTP-Statuscode.
|
||||||
|
StatusCode int
|
||||||
|
// Code ist der stabile maschinenlesbare Fehlercode.
|
||||||
|
Code ErrorCode
|
||||||
|
// Message ist die für Menschen bestimmte Erklärung ohne interne Details.
|
||||||
|
Message string
|
||||||
|
// Details trägt optionale, unbedenkliche Zusatzinformationen (z. B. Feldnamen).
|
||||||
|
Details map[string]any
|
||||||
|
// cause ist die interne Ursache. Sie wird ausschließlich geloggt.
|
||||||
|
cause error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error erfüllt das error-Interface.
|
||||||
|
func (apiError *APIError) Error() string {
|
||||||
|
if apiError.cause != nil {
|
||||||
|
return fmt.Sprintf("%s: %s: %v", apiError.Code, apiError.Message, apiError.cause)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s: %s", apiError.Code, apiError.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap gibt die interne Ursache für errors.Is/errors.As frei.
|
||||||
|
func (apiError *APIError) Unwrap() error {
|
||||||
|
return apiError.cause
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithCause hinterlegt die interne Ursache eines Fehlers.
|
||||||
|
// Die Ursache erscheint im Log, niemals in der Antwort an den Aufrufer.
|
||||||
|
func (apiError *APIError) WithCause(causeError error) *APIError {
|
||||||
|
apiError.cause = causeError
|
||||||
|
return apiError
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDetails ergänzt unbedenkliche Zusatzinformationen für den Aufrufer.
|
||||||
|
func (apiError *APIError) WithDetails(errorDetails map[string]any) *APIError {
|
||||||
|
apiError.Details = errorDetails
|
||||||
|
return apiError
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBadRequestError meldet einen syntaktisch fehlerhaften Request.
|
||||||
|
func NewBadRequestError(errorMessage string) *APIError {
|
||||||
|
return &APIError{StatusCode: http.StatusBadRequest, Code: ErrorCodeBadRequest, Message: errorMessage}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewValidationError meldet fachlich ungültige Eingabewerte.
|
||||||
|
func NewValidationError(errorMessage string) *APIError {
|
||||||
|
return &APIError{StatusCode: http.StatusUnprocessableEntity, Code: ErrorCodeValidationFailed, Message: errorMessage}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNotFoundError meldet eine nicht vorhandene Ressource.
|
||||||
|
func NewNotFoundError(errorMessage string) *APIError {
|
||||||
|
return &APIError{StatusCode: http.StatusNotFound, Code: ErrorCodeNotFound, Message: errorMessage}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInternalError meldet einen unerwarteten Serverfehler.
|
||||||
|
//
|
||||||
|
// Die Nachricht ist bewusst generisch: interne Details könnten Angreifern die
|
||||||
|
// Struktur des Systems verraten.
|
||||||
|
func NewInternalError(causeError error) *APIError {
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusInternalServerError,
|
||||||
|
Code: ErrorCodeInternal,
|
||||||
|
Message: "Bei der Verarbeitung der Anfrage ist ein interner Fehler aufgetreten.",
|
||||||
|
cause: causeError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServiceUnavailableError meldet einen vorübergehend nicht verfügbaren Dienst.
|
||||||
|
func NewServiceUnavailableError(errorMessage string) *APIError {
|
||||||
|
return &APIError{StatusCode: http.StatusServiceUnavailable, Code: ErrorCodeServiceUnavailable, Message: errorMessage}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNotImplementedError meldet eine noch nicht implementierte Funktion.
|
||||||
|
//
|
||||||
|
// Sie ist der vorgeschriebene Weg für geplante, aber unfertige Endpunkte
|
||||||
|
// (PROMPT.md §138).
|
||||||
|
func NewNotImplementedError(featureName string) *APIError {
|
||||||
|
return &APIError{
|
||||||
|
StatusCode: http.StatusNotImplemented,
|
||||||
|
Code: ErrorCodeNotImplemented,
|
||||||
|
Message: fmt.Sprintf("%s ist in dieser Version noch nicht implementiert.", featureName),
|
||||||
|
}
|
||||||
|
}
|
||||||
94
apps/api/internal/httpapi/health_handler.go
Normal file
94
apps/api/internal/httpapi/health_handler.go
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/platform/config"
|
||||||
|
"github.com/syncova/syncova/packages/platform/health"
|
||||||
|
)
|
||||||
|
|
||||||
|
// healthHandler bedient die Betriebs- und Systemzustands-Endpunkte
|
||||||
|
// (SYNCOVA_API.md §23, PROMPT.md §93/§94).
|
||||||
|
type healthHandler struct {
|
||||||
|
// healthRegistry liefert den Zustand aller überwachten Komponenten.
|
||||||
|
healthRegistry *health.Registry
|
||||||
|
// logger protokolliert Zustandsabfragen und Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
// buildVersion ist die ausgelieferte Programmversion.
|
||||||
|
buildVersion string
|
||||||
|
// environment ist die Betriebsumgebung des Dienstes.
|
||||||
|
environment config.Environment
|
||||||
|
}
|
||||||
|
|
||||||
|
// livenessResponse ist die Antwort auf eine Liveness-Prüfung.
|
||||||
|
type livenessResponse struct {
|
||||||
|
// Status ist "alive", sobald der Prozess Requests bedienen kann.
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// readinessResponse ist die Antwort auf eine Readiness-Prüfung.
|
||||||
|
type readinessResponse struct {
|
||||||
|
// Ready meldet, ob der Dienst Verkehr annehmen darf.
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
// Status ist der Gesamtzustand über alle Komponenten.
|
||||||
|
Status health.Status `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemHealthResponse ist der ausführliche Systemzustand.
|
||||||
|
type systemHealthResponse struct {
|
||||||
|
// Status ist der schlechteste Zustand aller Komponenten.
|
||||||
|
Status health.Status `json:"status"`
|
||||||
|
// Components enthält das Ergebnis je Komponente.
|
||||||
|
Components map[string]health.CheckResult `json:"components"`
|
||||||
|
// Version ist die laufende Programmversion.
|
||||||
|
Version string `json:"version"`
|
||||||
|
// Environment ist die Betriebsumgebung.
|
||||||
|
Environment string `json:"environment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLiveness beantwortet GET /health/live.
|
||||||
|
//
|
||||||
|
// Liveness beantwortet ausschließlich die Frage, ob der Prozess selbst arbeitet.
|
||||||
|
// Sie prüft bewusst keine Abhängigkeiten: sonst würde eine kurzzeitig nicht
|
||||||
|
// erreichbare Datenbank einen Neustart des Dienstes auslösen.
|
||||||
|
func (handler *healthHandler) handleLiveness(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, livenessResponse{Status: "alive"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReadiness beantwortet GET /health/ready.
|
||||||
|
//
|
||||||
|
// Readiness prüft alle als kritisch markierten Komponenten. Ist eine davon
|
||||||
|
// gestört, liefert der Endpunkt 503, damit kein Verkehr zugestellt wird.
|
||||||
|
func (handler *healthHandler) handleReadiness(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
isReady, systemReport := handler.healthRegistry.IsReady(request.Context())
|
||||||
|
|
||||||
|
responseStatusCode := http.StatusOK
|
||||||
|
if !isReady {
|
||||||
|
responseStatusCode = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, responseStatusCode, readinessResponse{
|
||||||
|
Ready: isReady,
|
||||||
|
Status: systemReport.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSystemHealth beantwortet GET /api/v1/health mit dem Komponentenbericht.
|
||||||
|
func (handler *healthHandler) handleSystemHealth(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
systemReport := handler.healthRegistry.Check(request.Context())
|
||||||
|
|
||||||
|
// Der HTTP-Status folgt dem Gesamtzustand: ein kritischer Bericht darf nicht
|
||||||
|
// mit 200 quittiert werden, sonst übersieht ihn jedes externe Monitoring.
|
||||||
|
responseStatusCode := http.StatusOK
|
||||||
|
if systemReport.Status == health.StatusCritical || systemReport.Status == health.StatusOffline {
|
||||||
|
responseStatusCode = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, responseStatusCode, systemHealthResponse{
|
||||||
|
Status: systemReport.Status,
|
||||||
|
Components: systemReport.Components,
|
||||||
|
Version: handler.buildVersion,
|
||||||
|
Environment: string(handler.environment),
|
||||||
|
})
|
||||||
|
}
|
||||||
297
apps/api/internal/httpapi/httpapi_test.go
Normal file
297
apps/api/internal/httpapi/httpapi_test.go
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/syncova/syncova/packages/platform/config"
|
||||||
|
"github.com/syncova/syncova/packages/platform/health"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestLogger liefert einen Logger, der ins Nichts schreibt.
|
||||||
|
// Tests sollen keine Logausgabe erzeugen, das Verhalten aber unverändert lassen.
|
||||||
|
func newTestLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewJSONHandler(io.Discard, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestRouter baut einen Router mit den übergebenen Health-Checks.
|
||||||
|
func newTestRouter(testInstance *testing.T, healthRegistry *health.Registry) http.Handler {
|
||||||
|
testInstance.Helper()
|
||||||
|
|
||||||
|
return NewRouter(RouterDependencies{
|
||||||
|
Config: config.Config{
|
||||||
|
Environment: config.EnvironmentTest,
|
||||||
|
HTTP: config.HTTPConfig{
|
||||||
|
MaxRequestBodyBytes: 1 << 20,
|
||||||
|
AllowedOrigins: []string{"https://ui.example.local"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Logger: newTestLogger(),
|
||||||
|
HealthRegistry: healthRegistry,
|
||||||
|
BuildVersion: "0.1.0-test",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthyTestCheck meldet eine uneingeschränkt funktionsfähige Komponente.
|
||||||
|
func healthyTestCheck(context.Context) health.CheckResult {
|
||||||
|
return health.CheckResult{Status: health.StatusHealthy}
|
||||||
|
}
|
||||||
|
|
||||||
|
// offlineTestCheck meldet eine nicht erreichbare Komponente.
|
||||||
|
func offlineTestCheck(context.Context) health.CheckResult {
|
||||||
|
return health.CheckResult{Status: health.StatusOffline, Message: "Datenbank nicht erreichbar."}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeSuccessResponse liest eine Erfolgsantwort in der Standard-Hülle.
|
||||||
|
func decodeSuccessResponse(testInstance *testing.T, responseRecorder *httptest.ResponseRecorder) SuccessResponse {
|
||||||
|
testInstance.Helper()
|
||||||
|
|
||||||
|
var successResponse SuccessResponse
|
||||||
|
if decodeError := json.Unmarshal(responseRecorder.Body.Bytes(), &successResponse); decodeError != nil {
|
||||||
|
testInstance.Fatalf("Antwort ist kein gültiges JSON (%v): %s", decodeError, responseRecorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return successResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLivenessIgnoresBrokenDependencies(testInstance *testing.T) {
|
||||||
|
healthRegistry := health.NewRegistry(time.Second)
|
||||||
|
// Die Datenbank ist ausgefallen — die Liveness darf davon unberührt bleiben,
|
||||||
|
// sonst würde der Prozess grundlos neu gestartet.
|
||||||
|
healthRegistry.Register("database", true, offlineTestCheck)
|
||||||
|
|
||||||
|
responseRecorder := httptest.NewRecorder()
|
||||||
|
newTestRouter(testInstance, healthRegistry).ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/health/live", nil))
|
||||||
|
|
||||||
|
if responseRecorder.Code != http.StatusOK {
|
||||||
|
testInstance.Fatalf("Liveness soll 200 liefern, war %d", responseRecorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadinessFailsWhenCriticalComponentIsDown(testInstance *testing.T) {
|
||||||
|
healthRegistry := health.NewRegistry(time.Second)
|
||||||
|
healthRegistry.Register("database", true, offlineTestCheck)
|
||||||
|
|
||||||
|
responseRecorder := httptest.NewRecorder()
|
||||||
|
newTestRouter(testInstance, healthRegistry).ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/health/ready", nil))
|
||||||
|
|
||||||
|
// Ein nicht betriebsbereiter Dienst muss 503 melden, damit ihm kein Verkehr zugestellt wird.
|
||||||
|
if responseRecorder.Code != http.StatusServiceUnavailable {
|
||||||
|
testInstance.Fatalf("Readiness soll 503 liefern, war %d", responseRecorder.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSystemHealthReturnsComponentReport(testInstance *testing.T) {
|
||||||
|
healthRegistry := health.NewRegistry(time.Second)
|
||||||
|
healthRegistry.Register("database", true, healthyTestCheck)
|
||||||
|
|
||||||
|
responseRecorder := httptest.NewRecorder()
|
||||||
|
newTestRouter(testInstance, healthRegistry).ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/health", nil))
|
||||||
|
|
||||||
|
if responseRecorder.Code != http.StatusOK {
|
||||||
|
testInstance.Fatalf("Systemzustand soll 200 liefern, war %d", responseRecorder.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse := decodeSuccessResponse(testInstance, responseRecorder)
|
||||||
|
|
||||||
|
// Jede Antwort trägt eine Request-ID, damit ein Vorfall im Log auffindbar ist.
|
||||||
|
if successResponse.Meta.RequestID == "" {
|
||||||
|
testInstance.Error("die Antwort enthält keine request_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, parseError := uuid.Parse(successResponse.Meta.RequestID); parseError != nil {
|
||||||
|
testInstance.Errorf("request_id soll eine UUID sein, war %q", successResponse.Meta.RequestID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownRouteUsesStandardErrorEnvelope(testInstance *testing.T) {
|
||||||
|
responseRecorder := httptest.NewRecorder()
|
||||||
|
newTestRouter(testInstance, health.NewRegistry(time.Second)).ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/gibt-es-nicht", nil))
|
||||||
|
|
||||||
|
if responseRecorder.Code != http.StatusNotFound {
|
||||||
|
testInstance.Fatalf("unbekannte Route soll 404 liefern, war %d", responseRecorder.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var errorResponse ErrorResponse
|
||||||
|
if decodeError := json.Unmarshal(responseRecorder.Body.Bytes(), &errorResponse); decodeError != nil {
|
||||||
|
testInstance.Fatalf("Fehlerantwort ist kein gültiges JSON: %s", responseRecorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if errorResponse.Error.Code != ErrorCodeNotFound {
|
||||||
|
testInstance.Errorf("Fehlercode soll %q sein, war %q", ErrorCodeNotFound, errorResponse.Error.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errorResponse.Error.RequestID == "" {
|
||||||
|
testInstance.Error("die Fehlerantwort enthält keine request_id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientCorrelationIDIsAdopted(testInstance *testing.T) {
|
||||||
|
const clientCorrelationID = "3f1c2b4a-5d6e-4f70-8192-a3b4c5d6e7f8"
|
||||||
|
|
||||||
|
testRequest := httptest.NewRequest(http.MethodGet, "/health/live", nil)
|
||||||
|
testRequest.Header.Set(headerCorrelationID, clientCorrelationID)
|
||||||
|
|
||||||
|
responseRecorder := httptest.NewRecorder()
|
||||||
|
newTestRouter(testInstance, health.NewRegistry(time.Second)).ServeHTTP(responseRecorder, testRequest)
|
||||||
|
|
||||||
|
if responseRecorder.Header().Get(headerCorrelationID) != clientCorrelationID {
|
||||||
|
testInstance.Fatalf("die vorgegebene Correlation ID soll übernommen werden, war %q", responseRecorder.Header().Get(headerCorrelationID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidCorrelationIDIsReplaced(testInstance *testing.T) {
|
||||||
|
testRequest := httptest.NewRequest(http.MethodGet, "/health/live", nil)
|
||||||
|
// Ein frei gewählter Wert darf nicht ungeprüft in die Logs gelangen.
|
||||||
|
testRequest.Header.Set(headerCorrelationID, "<script>alert(1)</script>")
|
||||||
|
|
||||||
|
responseRecorder := httptest.NewRecorder()
|
||||||
|
newTestRouter(testInstance, health.NewRegistry(time.Second)).ServeHTTP(responseRecorder, testRequest)
|
||||||
|
|
||||||
|
returnedCorrelationID := responseRecorder.Header().Get(headerCorrelationID)
|
||||||
|
if returnedCorrelationID == "<script>alert(1)</script>" {
|
||||||
|
testInstance.Fatal("eine ungültige Correlation ID darf nicht übernommen werden")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, parseError := uuid.Parse(returnedCorrelationID); parseError != nil {
|
||||||
|
testInstance.Fatalf("die ersetzte Correlation ID soll eine UUID sein, war %q", returnedCorrelationID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecurityHeadersArePresent(testInstance *testing.T) {
|
||||||
|
responseRecorder := httptest.NewRecorder()
|
||||||
|
newTestRouter(testInstance, health.NewRegistry(time.Second)).ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/health/live", nil))
|
||||||
|
|
||||||
|
expectedHeaders := map[string]string{
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
"Referrer-Policy": "no-referrer",
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
}
|
||||||
|
|
||||||
|
for headerName, expectedValue := range expectedHeaders {
|
||||||
|
if responseRecorder.Header().Get(headerName) != expectedValue {
|
||||||
|
testInstance.Errorf("Header %s soll %q sein, war %q", headerName, expectedValue, responseRecorder.Header().Get(headerName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORSAllowsOnlyConfiguredOrigin(testInstance *testing.T) {
|
||||||
|
testRouter := newTestRouter(testInstance, health.NewRegistry(time.Second))
|
||||||
|
|
||||||
|
// Die konfigurierte Herkunft wird zugelassen.
|
||||||
|
allowedRequest := httptest.NewRequest(http.MethodGet, "/health/live", nil)
|
||||||
|
allowedRequest.Header.Set("Origin", "https://ui.example.local")
|
||||||
|
|
||||||
|
allowedRecorder := httptest.NewRecorder()
|
||||||
|
testRouter.ServeHTTP(allowedRecorder, allowedRequest)
|
||||||
|
|
||||||
|
if allowedRecorder.Header().Get("Access-Control-Allow-Origin") != "https://ui.example.local" {
|
||||||
|
testInstance.Error("die konfigurierte Herkunft soll zugelassen werden")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine fremde Herkunft erhält keinen CORS-Freibrief.
|
||||||
|
foreignRequest := httptest.NewRequest(http.MethodGet, "/health/live", nil)
|
||||||
|
foreignRequest.Header.Set("Origin", "https://angreifer.example.com")
|
||||||
|
|
||||||
|
foreignRecorder := httptest.NewRecorder()
|
||||||
|
testRouter.ServeHTTP(foreignRecorder, foreignRequest)
|
||||||
|
|
||||||
|
if foreignRecorder.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||||
|
testInstance.Errorf("eine fremde Herkunft darf keinen CORS-Header erhalten, war %q", foreignRecorder.Header().Get("Access-Control-Allow-Origin"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPanicIsTurnedIntoInternalError(testInstance *testing.T) {
|
||||||
|
panickingHandler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||||
|
panic("unerwarteter programmierfehler")
|
||||||
|
})
|
||||||
|
|
||||||
|
handlerChain := Chain(panickingHandler,
|
||||||
|
RecoveryMiddleware(newTestLogger()),
|
||||||
|
CorrelationMiddleware(),
|
||||||
|
)
|
||||||
|
|
||||||
|
responseRecorder := httptest.NewRecorder()
|
||||||
|
handlerChain.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/health", nil))
|
||||||
|
|
||||||
|
if responseRecorder.Code != http.StatusInternalServerError {
|
||||||
|
testInstance.Fatalf("ein Panic soll 500 liefern, war %d", responseRecorder.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Panic-Text ist ein internes Detail und darf den Aufrufer nicht erreichen.
|
||||||
|
if strings.Contains(responseRecorder.Body.String(), "unerwarteter programmierfehler") {
|
||||||
|
testInstance.Fatalf("interne Details wurden ausgeliefert: %s", responseRecorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteErrorHidesInternalCause(testInstance *testing.T) {
|
||||||
|
const internalDetail = "pq: relation \"backup_jobs\" does not exist"
|
||||||
|
|
||||||
|
apiError := NewInternalError(errors.New(internalDetail))
|
||||||
|
|
||||||
|
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/jobs", nil)
|
||||||
|
responseRecorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
WriteError(responseRecorder, testRequest, newTestLogger(), apiError)
|
||||||
|
|
||||||
|
if strings.Contains(responseRecorder.Body.String(), internalDetail) {
|
||||||
|
testInstance.Fatalf("die interne Ursache darf nicht ausgeliefert werden: %s", responseRecorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Im Fehlerobjekt selbst bleibt die Ursache für das Log erhalten.
|
||||||
|
if !strings.Contains(apiError.Error(), internalDetail) {
|
||||||
|
testInstance.Error("die Ursache soll für das Log erhalten bleiben")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotImplementedErrorIsExplicit(testInstance *testing.T) {
|
||||||
|
// PROMPT.md §138: unfertige Funktionen melden das ehrlich.
|
||||||
|
notImplementedError := NewNotImplementedError("Der Proxmox-Restore")
|
||||||
|
|
||||||
|
if notImplementedError.StatusCode != http.StatusNotImplemented {
|
||||||
|
testInstance.Errorf("Status soll 501 sein, war %d", notImplementedError.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if notImplementedError.Code != ErrorCodeNotImplemented {
|
||||||
|
testInstance.Errorf("Code soll %q sein, war %q", ErrorCodeNotImplemented, notImplementedError.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPaginatedResponseCarriesMeta(testInstance *testing.T) {
|
||||||
|
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/jobs", nil)
|
||||||
|
responseRecorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
WritePaginatedSuccess(responseRecorder, testRequest, []string{}, PaginationMeta{Page: 2, PageSize: 50, Total: 1000})
|
||||||
|
|
||||||
|
successResponse := decodeSuccessResponse(testInstance, responseRecorder)
|
||||||
|
|
||||||
|
if successResponse.Meta.Page == nil || *successResponse.Meta.Page != 2 {
|
||||||
|
testInstance.Error("meta.page fehlt oder ist falsch")
|
||||||
|
}
|
||||||
|
|
||||||
|
if successResponse.Meta.Total == nil || *successResponse.Meta.Total != 1000 {
|
||||||
|
testInstance.Error("meta.total fehlt oder ist falsch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonPaginatedResponseOmitsPaginationMeta(testInstance *testing.T) {
|
||||||
|
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
|
||||||
|
responseRecorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
WriteSuccess(responseRecorder, testRequest, http.StatusOK, map[string]string{"status": "healthy"})
|
||||||
|
|
||||||
|
// Pagination-Felder dürfen bei Einzelressourcen gar nicht erst erscheinen.
|
||||||
|
if strings.Contains(responseRecorder.Body.String(), "page_size") {
|
||||||
|
testInstance.Fatalf("die Antwort enthält unerwartete Pagination-Felder: %s", responseRecorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
354
apps/api/internal/httpapi/hypervisor_handler.go
Normal file
354
apps/api/internal/httpapi/hypervisor_handler.go
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/audit"
|
||||||
|
"github.com/syncova/syncova/packages/hypervisor"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hypervisorHandler bedient die Endpunkte der Virtualisierungsumgebungen.
|
||||||
|
type hypervisorHandler struct {
|
||||||
|
// clusterStore ist die Datenzugriffsschicht der Verbünde.
|
||||||
|
clusterStore *hypervisor.Store
|
||||||
|
// auditRecorder protokolliert die verändernden Zugriffe.
|
||||||
|
auditRecorder audit.Recorder
|
||||||
|
// logger protokolliert technische Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListClusters bedient GET /proxmox/clusters.
|
||||||
|
func (handler *hypervisorHandler) handleListClusters(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
clusterList, listError := handler.clusterStore.ListClusters(request.Context())
|
||||||
|
if listError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, clusterList)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateCluster bedient POST /proxmox/clusters.
|
||||||
|
func (handler *hypervisorHandler) handleCreateCluster(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
var clusterInput hypervisor.ClusterInput
|
||||||
|
if decodeError := decodeJSONBody(request, &clusterInput); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createdCluster, createError := handler.clusterStore.CreateCluster(request.Context(), clusterInput)
|
||||||
|
if createError != nil {
|
||||||
|
// Eine ungültige Eingabe ist kein Serverfehler. Die Meldung wird
|
||||||
|
// wörtlich weitergereicht, weil sie erklärt, was fehlt — eine
|
||||||
|
// allgemeine „Eingabe ungültig" ließe den Betreiber raten.
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewValidationError(createError.Error()))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein neuer Verbund bedeutet neue Zugangsdaten im System — das gehört ins
|
||||||
|
// Protokoll, unabhängig davon, ob es später jemanden interessiert.
|
||||||
|
handler.recordAudit(request, actingUser.ID, actingUser.Username, audit.ActionHypervisorClusterCreated,
|
||||||
|
createdCluster.ID, map[string]any{
|
||||||
|
"name": createdCluster.Name,
|
||||||
|
"api_endpoint": createdCluster.APIEndpoint,
|
||||||
|
"archive_transport": string(createdCluster.ArchiveTransport),
|
||||||
|
})
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusCreated, createdCluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetCluster bedient GET /proxmox/clusters/{id}.
|
||||||
|
func (handler *hypervisorHandler) handleGetCluster(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
clusterIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
foundCluster, readError := handler.clusterStore.GetCluster(request.Context(), clusterIdentifier)
|
||||||
|
if errors.Is(readError, hypervisor.ErrClusterNotFound) {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der Verbund wurde nicht gefunden."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(readError))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, foundCluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTestConnection bedient POST /proxmox/clusters/{id}/test.
|
||||||
|
//
|
||||||
|
// Die Prüfung verändert nichts am Verbund und ist der erste Schritt nach dem
|
||||||
|
// Einrichten: Sie sagt, ob Anmeldung und Zertifikatsbindung stimmen, bevor um
|
||||||
|
// zwei Uhr nachts eine Sicherung daran scheitert.
|
||||||
|
func (handler *hypervisorHandler) handleTestConnection(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
clusterIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionError := handler.clusterStore.TestConnection(request.Context(), clusterIdentifier, handler.logger)
|
||||||
|
|
||||||
|
if errors.Is(connectionError, hypervisor.ErrClusterNotFound) {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der Verbund wurde nicht gefunden."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Zustand nach der Prüfung ist die eigentliche Antwort — auch im
|
||||||
|
// Fehlerfall. Ein 500 verschwiege, dass die Prüfung ordnungsgemäß gelaufen
|
||||||
|
// ist und ein Ergebnis hat.
|
||||||
|
updatedCluster, readError := handler.clusterStore.GetCluster(request.Context(), clusterIdentifier)
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(readError))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responsePayload := map[string]any{
|
||||||
|
"cluster_id": clusterIdentifier,
|
||||||
|
"status": updatedCluster.Status,
|
||||||
|
"reachable": connectionError == nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
if connectionError != nil {
|
||||||
|
responsePayload["error"] = connectionError.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, responsePayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDiscover bedient POST /proxmox/clusters/{id}/discover.
|
||||||
|
func (handler *hypervisorHandler) handleDiscover(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
clusterIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveryResult, discoveryError := handler.clusterStore.Discover(request.Context(),
|
||||||
|
clusterIdentifier, handler.logger)
|
||||||
|
|
||||||
|
if errors.Is(discoveryError, hypervisor.ErrClusterNotFound) {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der Verbund wurde nicht gefunden."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if discoveryError != nil {
|
||||||
|
// Ein nicht erreichbarer Verbund ist kein Serverfehler, sondern eine
|
||||||
|
// Auskunft über die Anlage. 503 statt 500, damit die Oberfläche den
|
||||||
|
// Unterschied anzeigen kann.
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewServiceUnavailableError("Die Bestandsaufnahme schlug fehl: "+discoveryError.Error()))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, discoveryResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteCluster bedient DELETE /proxmox/clusters/{id}.
|
||||||
|
func (handler *hypervisorHandler) handleDeleteCluster(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
clusterIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteError := handler.clusterStore.DeleteCluster(request.Context(), clusterIdentifier)
|
||||||
|
|
||||||
|
if errors.Is(deleteError, hypervisor.ErrClusterNotFound) {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der Verbund wurde nicht gefunden."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Verbund, auf den noch Sicherungsquellen verweisen, wird nicht
|
||||||
|
// entfernt: Die Aufträge verlören ihre Grundlage, und zwar stillschweigend
|
||||||
|
// bis zum nächsten Lauf.
|
||||||
|
if errors.Is(deleteError, hypervisor.ErrClusterInUse) {
|
||||||
|
WriteError(responseWriter, request, requestLogger, &APIError{
|
||||||
|
StatusCode: http.StatusConflict,
|
||||||
|
Code: ErrorCodeConflict,
|
||||||
|
Message: deleteError.Error(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if deleteError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(deleteError))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser.ID, actingUser.Username, audit.ActionHypervisorClusterDeleted,
|
||||||
|
clusterIdentifier, nil)
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListVirtualMachines bedient GET /virtual-machines.
|
||||||
|
func (handler *hypervisorHandler) handleListVirtualMachines(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
var clusterFilter *uuid.UUID
|
||||||
|
|
||||||
|
if rawFilter := request.URL.Query().Get("cluster_id"); rawFilter != "" {
|
||||||
|
parsedFilter, parseError := uuid.Parse(rawFilter)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError("Die Kennung des Verbunds ist ungültig."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterFilter = &parsedFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
machineList, listError := handler.clusterStore.ListVirtualMachines(request.Context(), clusterFilter)
|
||||||
|
if listError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, machineList)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordAudit schreibt einen Eintrag ins Auditprotokoll.
|
||||||
|
//
|
||||||
|
// Ein fehlgeschlagener Eintrag wird protokolliert, aber nicht geworfen: Die
|
||||||
|
// Handlung ist geschehen, und ein Fehler an dieser Stelle machte sie nicht
|
||||||
|
// ungeschehen — er verschwiege sie nur zusätzlich.
|
||||||
|
func (handler *hypervisorHandler) recordAudit(request *http.Request, actingUserID uuid.UUID,
|
||||||
|
actingUsername string, auditAction audit.Action, entityIdentifier uuid.UUID,
|
||||||
|
auditDetails map[string]any) {
|
||||||
|
if handler.auditRecorder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
correlationIdentifier, _ := logging.CorrelationIDFromContext(request.Context())
|
||||||
|
|
||||||
|
recordError := handler.auditRecorder.Record(request.Context(), audit.Event{
|
||||||
|
UserID: &actingUserID,
|
||||||
|
ActorUsername: actingUsername,
|
||||||
|
Action: auditAction,
|
||||||
|
EntityType: "proxmox_cluster",
|
||||||
|
EntityID: &entityIdentifier,
|
||||||
|
Result: audit.ResultSuccess,
|
||||||
|
IPAddress: clientIPAddress(request),
|
||||||
|
UserAgent: request.UserAgent(),
|
||||||
|
CorrelationID: correlationIdentifier,
|
||||||
|
Details: auditDetails,
|
||||||
|
})
|
||||||
|
|
||||||
|
if recordError != nil {
|
||||||
|
logging.WithContext(request.Context(), handler.logger).Error(
|
||||||
|
"der audit-eintrag liess sich nicht schreiben",
|
||||||
|
slog.String("aktion", string(auditAction)),
|
||||||
|
slog.String("grund", recordError.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListHosts bedient GET /proxmox/clusters/{id}/hosts.
|
||||||
|
func (handler *hypervisorHandler) handleListHosts(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
clusterIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hostList, listError := handler.clusterStore.ListHosts(request.Context(), clusterIdentifier)
|
||||||
|
if listError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, hostList)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListClusterMachines bedient GET /proxmox/clusters/{id}/vms.
|
||||||
|
func (handler *hypervisorHandler) handleListClusterMachines(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
clusterIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
machineList, listError := handler.clusterStore.ListVirtualMachines(request.Context(), &clusterIdentifier)
|
||||||
|
if listError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, machineList)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetVirtualMachine bedient GET /proxmox/vms/{id}.
|
||||||
|
func (handler *hypervisorHandler) handleGetVirtualMachine(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
machineIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Gasts ist ungültig."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
foundMachine, readError := handler.clusterStore.GetVirtualMachine(request.Context(), machineIdentifier)
|
||||||
|
if errors.Is(readError, hypervisor.ErrVirtualMachineNotFound) {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der Gast wurde im Bestand nicht gefunden."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(readError))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, foundMachine)
|
||||||
|
}
|
||||||
782
apps/api/internal/httpapi/job_handler.go
Normal file
782
apps/api/internal/httpapi/job_handler.go
Normal file
@ -0,0 +1,782 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/syncova/syncova/packages/audit"
|
||||||
|
"github.com/syncova/syncova/packages/auth"
|
||||||
|
"github.com/syncova/syncova/packages/jobs"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// jobHandler bedient die Sicherungsaufträge (SYNCOVA_API.md §9).
|
||||||
|
type jobHandler struct {
|
||||||
|
// store ist die Datenzugriffsschicht der Aufträge.
|
||||||
|
store *jobs.PostgresStore
|
||||||
|
// auditRecorder protokolliert Änderungen an Aufträgen.
|
||||||
|
auditRecorder audit.Recorder
|
||||||
|
// logger protokolliert technische Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduleRequest beschreibt einen Zeitplan im Anfragerumpf.
|
||||||
|
//
|
||||||
|
// Bewusst eine eigene Struktur statt scheduler.Schedule: Die API-Gestalt darf
|
||||||
|
// sich nicht mitverändern, wenn ein internes Feld umbenannt wird. Der Vertrag
|
||||||
|
// nach außen ist stabiler als der Code dahinter.
|
||||||
|
type scheduleRequest struct {
|
||||||
|
// Type ist die Art des Zeitplans.
|
||||||
|
Type string `json:"type"`
|
||||||
|
// Interval ist der Abstand in Sekunden bei type=interval.
|
||||||
|
IntervalSeconds int64 `json:"interval_seconds,omitempty"`
|
||||||
|
// Time ist die Uhrzeit im Format "HH:MM".
|
||||||
|
//
|
||||||
|
// Eine Zeichenkette statt zweier Zahlen, weil SYNCOVA_API.md §9 sie so
|
||||||
|
// vorgibt und weil "02:00" für einen Anwender lesbar ist.
|
||||||
|
Time string `json:"time,omitempty"`
|
||||||
|
// Weekdays sind die Wochentage (0 = Sonntag).
|
||||||
|
Weekdays []int `json:"weekdays,omitempty"`
|
||||||
|
// MonthDays sind die Tage des Monats; -1 bedeutet Monatsletzter.
|
||||||
|
MonthDays []int `json:"month_days,omitempty"`
|
||||||
|
// CronExpression ist der Ausdruck bei type=cron.
|
||||||
|
CronExpression string `json:"cron_expression,omitempty"`
|
||||||
|
// TimeZone ist die Zeitzone der Uhrzeiten.
|
||||||
|
TimeZone string `json:"time_zone,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceRequest beschreibt eine Quelle im Anfragerumpf.
|
||||||
|
type sourceRequest struct {
|
||||||
|
// Type ist die Art der Quelle.
|
||||||
|
Type string `json:"type"`
|
||||||
|
// ID ist die Kennung innerhalb ihrer Art.
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Name ist die sprechende Bezeichnung.
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
// AgentID ist der ausführende Agent.
|
||||||
|
AgentID *uuid.UUID `json:"agent_id,omitempty"`
|
||||||
|
// IncludePatterns beschränken die Erfassung.
|
||||||
|
IncludePatterns []string `json:"include_patterns,omitempty"`
|
||||||
|
// ExcludePatterns nehmen Pfade aus.
|
||||||
|
ExcludePatterns []string `json:"exclude_patterns,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobRequest ist der Rumpf von POST und PATCH auf /jobs.
|
||||||
|
type jobRequest struct {
|
||||||
|
// Name ist die eindeutige Bezeichnung.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Description erläutert den Zweck.
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
// Priority ist die Dringlichkeit.
|
||||||
|
Priority string `json:"priority,omitempty"`
|
||||||
|
// Schedule ist der Zeitplan.
|
||||||
|
Schedule scheduleRequest `json:"schedule"`
|
||||||
|
// Sources sind die zu sichernden Quellen.
|
||||||
|
Sources []sourceRequest `json:"sources"`
|
||||||
|
// RepositoryID ist das Ziel-Repository.
|
||||||
|
RepositoryID uuid.UUID `json:"repository_id"`
|
||||||
|
// RetentionPolicyID ist die Aufbewahrungsregel.
|
||||||
|
RetentionPolicyID *uuid.UUID `json:"retention_policy_id,omitempty"`
|
||||||
|
// DependsOnJobIDs sind vorausgesetzte Aufträge.
|
||||||
|
DependsOnJobIDs []uuid.UUID `json:"depends_on_job_ids,omitempty"`
|
||||||
|
// RecoveryPointSeconds ist der zulässige Datenverlust in Sekunden.
|
||||||
|
RecoveryPointSeconds int64 `json:"rpo_seconds,omitempty"`
|
||||||
|
// RecoveryTimeSeconds ist die zulässige Wiederherstellungsdauer in Sekunden.
|
||||||
|
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
|
||||||
|
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
|
||||||
|
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
|
||||||
|
// MaximumConcurrency begrenzt gleichzeitige Läufe.
|
||||||
|
MaximumConcurrency int `json:"max_concurrency,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobResponse ist die Darstellung eines Auftrags nach außen.
|
||||||
|
type jobResponse struct {
|
||||||
|
// ID ist der öffentliche Bezeichner.
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
// Name ist die Bezeichnung.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Description erläutert den Zweck.
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
// Status ist der Zustand.
|
||||||
|
Status string `json:"status"`
|
||||||
|
// Priority ist die Dringlichkeit.
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
// Schedule ist der Zeitplan.
|
||||||
|
Schedule scheduleRequest `json:"schedule"`
|
||||||
|
// ScheduleDescription erklärt den Zeitplan in einem Satz.
|
||||||
|
//
|
||||||
|
// Ein Cron-Ausdruck sagt einem Anwender wenig; „täglich um 02:00 Uhr
|
||||||
|
// (Europe/Berlin)" dagegen alles. Die Erklärung entsteht auf dem Server,
|
||||||
|
// damit sie in Oberfläche und Benachrichtigung gleich lautet.
|
||||||
|
ScheduleDescription string `json:"schedule_description"`
|
||||||
|
// Sources sind die Quellen.
|
||||||
|
Sources []sourceRequest `json:"sources"`
|
||||||
|
// RepositoryID ist das Ziel-Repository.
|
||||||
|
RepositoryID uuid.UUID `json:"repository_id"`
|
||||||
|
// RetentionPolicyID ist die Aufbewahrungsregel.
|
||||||
|
RetentionPolicyID *uuid.UUID `json:"retention_policy_id,omitempty"`
|
||||||
|
// DependsOnJobIDs sind vorausgesetzte Aufträge.
|
||||||
|
DependsOnJobIDs []uuid.UUID `json:"depends_on_job_ids,omitempty"`
|
||||||
|
// RecoveryPointSeconds ist der zulässige Datenverlust.
|
||||||
|
RecoveryPointSeconds int64 `json:"rpo_seconds,omitempty"`
|
||||||
|
// RecoveryTimeSeconds ist die zulässige Wiederherstellungsdauer.
|
||||||
|
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
|
||||||
|
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
|
||||||
|
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
|
||||||
|
// MaximumConcurrency begrenzt gleichzeitige Läufe.
|
||||||
|
MaximumConcurrency int `json:"max_concurrency"`
|
||||||
|
// NextRunAt ist der nächste Zeitpunkt in UTC.
|
||||||
|
NextRunAt *time.Time `json:"next_run_at,omitempty"`
|
||||||
|
// LastRunAt ist der Beginn des letzten Laufs in UTC.
|
||||||
|
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||||
|
// LastOutcome ist der Ausgang des letzten Laufs.
|
||||||
|
LastOutcome string `json:"last_outcome,omitempty"`
|
||||||
|
// PausedAt ist der Zeitpunkt einer Aussetzung in UTC.
|
||||||
|
PausedAt *time.Time `json:"paused_at,omitempty"`
|
||||||
|
// CreatedAt ist der Anlagezeitpunkt in UTC.
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
// UpdatedAt ist der Zeitpunkt der letzten Änderung in UTC.
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListJobs bedient GET /jobs.
|
||||||
|
func (handler *jobHandler) handleListJobs(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
listFilter := jobs.ListFilter{
|
||||||
|
Status: jobs.JobStatus(request.URL.Query().Get("status")),
|
||||||
|
SearchTerm: request.URL.Query().Get("search"),
|
||||||
|
Page: parsePositiveInteger(request.URL.Query().Get("page"), 1),
|
||||||
|
PageSize: parsePositiveInteger(request.URL.Query().Get("page_size"), 50),
|
||||||
|
}
|
||||||
|
|
||||||
|
if repositoryParameter := request.URL.Query().Get("repository"); repositoryParameter != "" {
|
||||||
|
repositoryID, parseError := uuid.Parse(repositoryParameter)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError("Der Filter 'repository' ist keine gültige Kennung."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listFilter.RepositoryID = &repositoryID
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedJobs, totalCount, listError := handler.store.ListJobs(request.Context(), listFilter)
|
||||||
|
if listError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobResponses := make([]jobResponse, 0, len(loadedJobs))
|
||||||
|
for jobIndex := range loadedJobs {
|
||||||
|
jobResponses = append(jobResponses, buildJobResponse(&loadedJobs[jobIndex]))
|
||||||
|
}
|
||||||
|
|
||||||
|
WritePaginatedSuccess(responseWriter, request, jobResponses, PaginationMeta{
|
||||||
|
Page: listFilter.Page,
|
||||||
|
PageSize: listFilter.PageSize,
|
||||||
|
Total: int64(totalCount),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateJob bedient POST /jobs.
|
||||||
|
func (handler *jobHandler) handleCreateJob(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
var jobPayload jobRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &jobPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newJob, conversionError := buildJobFromRequest(jobPayload, actingUser.ID)
|
||||||
|
if conversionError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, conversionError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der nächste Zeitpunkt wird sofort berechnet und mitgespeichert. Ohne ihn
|
||||||
|
// stünde der Auftrag als aktiv da, ohne je zu laufen — bis jemand ihn von
|
||||||
|
// Hand anstößt.
|
||||||
|
if nextRun, nextError := newJob.Schedule.NextRun(time.Now().UTC()); nextError == nil {
|
||||||
|
newJob.NextRunAt = &nextRun
|
||||||
|
}
|
||||||
|
|
||||||
|
createdJobID, createError := handler.store.CreateJob(request.Context(), newJob)
|
||||||
|
if createError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateJobError(createError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionBackupJobCreated, createdJobID, map[string]any{
|
||||||
|
"name": newJob.Name,
|
||||||
|
"schedule": newJob.Schedule.Describe(),
|
||||||
|
"source_count": len(newJob.Sources),
|
||||||
|
"repository_id": newJob.RepositoryID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
createdJob, readError := handler.store.GetJob(request.Context(), createdJobID)
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(readError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusCreated, buildJobResponse(createdJob))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetJob bedient GET /jobs/{id}.
|
||||||
|
func (handler *jobHandler) handleGetJob(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
jobIdentifier, parseError := parseJobIdentifier(request)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedJob, readError := handler.store.GetJob(request.Context(), jobIdentifier)
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateJobError(readError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, buildJobResponse(loadedJob))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteJob bedient DELETE /jobs/{id}.
|
||||||
|
//
|
||||||
|
// Die Löschung ist weich: Die Läufe eines gelöschten Auftrags bleiben als
|
||||||
|
// Nachweis erhalten. Sie wird immer auditiert (PROMPT.md §140: destruktive
|
||||||
|
// Aktionen niemals still).
|
||||||
|
func (handler *jobHandler) handleDeleteJob(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
jobIdentifier, parseError := parseJobIdentifier(request)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Auftrag wird vor der Löschung gelesen, damit das Auditprotokoll
|
||||||
|
// festhält, was verschwunden ist. Danach wäre es nicht mehr feststellbar.
|
||||||
|
existingJob, readError := handler.store.GetJob(request.Context(), jobIdentifier)
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateJobError(readError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if deleteError := handler.store.SoftDeleteJob(request.Context(), jobIdentifier); deleteError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateJobError(deleteError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionBackupJobDeleted, jobIdentifier, map[string]any{
|
||||||
|
"name": existingJob.Name,
|
||||||
|
"schedule": existingJob.Schedule.Describe(),
|
||||||
|
"source_count": len(existingJob.Sources),
|
||||||
|
})
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]string{
|
||||||
|
"status": "deleted",
|
||||||
|
"message": "Der Auftrag wurde gelöscht. Seine bisherigen Läufe bleiben als Nachweis erhalten.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePauseJob bedient POST /jobs/{id}/pause.
|
||||||
|
func (handler *jobHandler) handlePauseJob(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
handler.changeJobStatus(responseWriter, request, jobs.JobStatusPaused, audit.ActionBackupJobPaused)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResumeJob bedient POST /jobs/{id}/resume.
|
||||||
|
func (handler *jobHandler) handleResumeJob(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
handler.changeJobStatus(responseWriter, request, jobs.JobStatusActive, audit.ActionBackupJobResumed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// changeJobStatus setzt den Zustand eines Auftrags und auditiert die Änderung.
|
||||||
|
//
|
||||||
|
// Das Aussetzen einer Sicherung ist sicherheitsrelevant: Es lässt den Schutz
|
||||||
|
// still auslaufen, ohne dass etwas kaputtgeht. Deshalb wird es protokolliert
|
||||||
|
// wie eine Löschung.
|
||||||
|
func (handler *jobHandler) changeJobStatus(responseWriter http.ResponseWriter, request *http.Request, newStatus jobs.JobStatus, auditAction audit.Action) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
jobIdentifier, parseError := parseJobIdentifier(request)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existingJob, readError := handler.store.GetJob(request.Context(), jobIdentifier)
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateJobError(readError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if statusError := handler.store.SetJobStatus(request.Context(), jobIdentifier, newStatus, &actingUser.ID); statusError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateJobError(statusError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beim Fortsetzen wird der nächste Zeitpunkt neu berechnet. Ohne diesen
|
||||||
|
// Schritt bliebe der alte stehen: Ein Auftrag, der eine Woche ausgesetzt
|
||||||
|
// war, liefe sofort los und danach zur falschen Zeit weiter.
|
||||||
|
if newStatus == jobs.JobStatusActive {
|
||||||
|
if nextRun, nextError := existingJob.Schedule.NextRun(time.Now().UTC()); nextError == nil {
|
||||||
|
if updateError := handler.store.SetNextRun(request.Context(), jobIdentifier, &nextRun); updateError != nil {
|
||||||
|
requestLogger.Warn("der nächste zeitpunkt konnte nicht gesetzt werden",
|
||||||
|
slog.String("job_id", jobIdentifier.String()),
|
||||||
|
slog.String("grund", updateError.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, auditAction, jobIdentifier, map[string]any{
|
||||||
|
"name": existingJob.Name,
|
||||||
|
"von_status": string(existingJob.Status),
|
||||||
|
"nach_status": string(newStatus),
|
||||||
|
})
|
||||||
|
|
||||||
|
updatedJob, updateReadError := handler.store.GetJob(request.Context(), jobIdentifier)
|
||||||
|
if updateReadError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(updateReadError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, buildJobResponse(updatedJob))
|
||||||
|
}
|
||||||
|
|
||||||
|
// runResponse ist die Darstellung eines Laufs nach außen.
|
||||||
|
type runResponse struct {
|
||||||
|
// ID ist der öffentliche Bezeichner.
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
// JobID ist der ausgeführte Auftrag.
|
||||||
|
JobID uuid.UUID `json:"job_id"`
|
||||||
|
// Status ist der Zustand.
|
||||||
|
Status string `json:"status"`
|
||||||
|
// Trigger benennt den Auslöser.
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
// AttemptNumber ist die Nummer des Versuchs.
|
||||||
|
AttemptNumber int `json:"attempt_number"`
|
||||||
|
// ScheduledFor ist der geplante Zeitpunkt in UTC.
|
||||||
|
ScheduledFor *time.Time `json:"scheduled_for,omitempty"`
|
||||||
|
// StartedAt ist der Beginn in UTC.
|
||||||
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||||
|
// CompletedAt ist das Ende in UTC.
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
// DurationSeconds ist die Dauer in Sekunden.
|
||||||
|
DurationSeconds float64 `json:"duration_seconds,omitempty"`
|
||||||
|
// DelaySeconds ist die Verspätung gegenüber dem geplanten Zeitpunkt.
|
||||||
|
//
|
||||||
|
// Die Abweichung ist die eigentliche Auskunft: Ein Lauf, der regelmäßig
|
||||||
|
// eine Stunde zu spät beginnt, hat ein Problem, das man ohne diesen
|
||||||
|
// Vergleich nicht sieht.
|
||||||
|
DelaySeconds float64 `json:"delay_seconds,omitempty"`
|
||||||
|
// BytesProcessed ist die gelesene Datenmenge.
|
||||||
|
BytesProcessed int64 `json:"bytes_processed"`
|
||||||
|
// BytesWritten ist die abgelegte Datenmenge.
|
||||||
|
BytesWritten int64 `json:"bytes_written"`
|
||||||
|
// FilesProcessed ist die Zahl bearbeiteter Objekte.
|
||||||
|
FilesProcessed int64 `json:"files_processed"`
|
||||||
|
// FilesSkipped ist die Zahl übergangener Objekte.
|
||||||
|
FilesSkipped int64 `json:"files_skipped"`
|
||||||
|
// ErrorCode ist die Fehlerkennung.
|
||||||
|
ErrorCode string `json:"error_code,omitempty"`
|
||||||
|
// ErrorMessage ist die verständliche Fehlermeldung.
|
||||||
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
|
// FailureClass ordnet den Fehler ein.
|
||||||
|
FailureClass string `json:"failure_class,omitempty"`
|
||||||
|
// CorrelationID verbindet den Lauf mit seinen Protokollzeilen.
|
||||||
|
CorrelationID uuid.UUID `json:"correlation_id"`
|
||||||
|
// CreatedAt ist der Anlagezeitpunkt in UTC.
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRunResponse wandelt einen Lauf in seine Darstellung.
|
||||||
|
func buildRunResponse(sourceRun *jobs.Run) runResponse {
|
||||||
|
builtResponse := runResponse{
|
||||||
|
ID: sourceRun.ID,
|
||||||
|
JobID: sourceRun.JobID,
|
||||||
|
Status: string(sourceRun.Status),
|
||||||
|
Trigger: string(sourceRun.Trigger),
|
||||||
|
AttemptNumber: sourceRun.AttemptNumber,
|
||||||
|
ScheduledFor: sourceRun.ScheduledFor,
|
||||||
|
StartedAt: sourceRun.StartedAt,
|
||||||
|
CompletedAt: sourceRun.CompletedAt,
|
||||||
|
BytesProcessed: sourceRun.BytesProcessed,
|
||||||
|
BytesWritten: sourceRun.BytesWritten,
|
||||||
|
FilesProcessed: sourceRun.FilesProcessed,
|
||||||
|
FilesSkipped: sourceRun.FilesSkipped,
|
||||||
|
ErrorCode: sourceRun.ErrorCode,
|
||||||
|
ErrorMessage: sourceRun.ErrorMessage,
|
||||||
|
FailureClass: string(sourceRun.FailureClass),
|
||||||
|
CorrelationID: sourceRun.CorrelationID,
|
||||||
|
CreatedAt: sourceRun.CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
builtResponse.DurationSeconds = sourceRun.Duration().Seconds()
|
||||||
|
|
||||||
|
if sourceRun.ScheduledFor != nil && sourceRun.StartedAt != nil {
|
||||||
|
builtResponse.DelaySeconds = sourceRun.StartedAt.Sub(*sourceRun.ScheduledFor).Seconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
return builtResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRunJob bedient POST /jobs/{id}/run.
|
||||||
|
//
|
||||||
|
// Der Lauf wird eingereiht, nicht ausgeführt: Die Ausführungsschleife holt ihn
|
||||||
|
// im nächsten Durchgang. Der Endpunkt antwortet deshalb mit 202 statt 201 —
|
||||||
|
// die Sicherung hat noch nicht begonnen.
|
||||||
|
func (handler *jobHandler) handleRunJob(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
jobIdentifier, parseError := parseJobIdentifier(request)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existingJob, readError := handler.store.GetJob(request.Context(), jobIdentifier)
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateJobError(readError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein ausgesetzter Auftrag wird nicht heimlich reaktiviert. Wer ihn
|
||||||
|
// ausführen will, setzt ihn zuerst fort — sonst liefe er einmal und
|
||||||
|
// schwiege danach wieder, ohne dass es jemandem auffiele.
|
||||||
|
if existingJob.Status == jobs.JobStatusPaused {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewValidationError(
|
||||||
|
"Der Auftrag ist ausgesetzt. Setzen Sie ihn zuerst fort, bevor Sie ihn ausführen."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createdRun, createError := handler.store.CreateManualRun(request.Context(), jobIdentifier, &actingUser.ID)
|
||||||
|
if createError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateJobError(createError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionBackupJobRunRequested, jobIdentifier, map[string]any{
|
||||||
|
"name": existingJob.Name,
|
||||||
|
"run_id": createdRun.ID.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusAccepted, buildRunResponse(createdRun))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListJobRuns bedient GET /jobs/{id}/runs.
|
||||||
|
func (handler *jobHandler) handleListJobRuns(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
jobIdentifier, parseError := parseJobIdentifier(request)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, readError := handler.store.GetJob(request.Context(), jobIdentifier); readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateJobError(readError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedPage := parsePositiveInteger(request.URL.Query().Get("page"), 1)
|
||||||
|
requestedPageSize := parsePositiveInteger(request.URL.Query().Get("page_size"), 50)
|
||||||
|
|
||||||
|
loadedRuns, totalCount, listError := handler.store.ListRuns(request.Context(),
|
||||||
|
jobIdentifier, requestedPage, requestedPageSize)
|
||||||
|
if listError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runResponses := make([]runResponse, 0, len(loadedRuns))
|
||||||
|
for runIndex := range loadedRuns {
|
||||||
|
runResponses = append(runResponses, buildRunResponse(&loadedRuns[runIndex]))
|
||||||
|
}
|
||||||
|
|
||||||
|
WritePaginatedSuccess(responseWriter, request, runResponses, PaginationMeta{
|
||||||
|
Page: requestedPage,
|
||||||
|
PageSize: requestedPageSize,
|
||||||
|
Total: int64(totalCount),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCancelRun bedient POST /backup-runs/{id}/cancel.
|
||||||
|
func (handler *jobHandler) handleCancelRun(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
runIdentifier, uuidError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if uuidError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError("Die Laufkennung ist keine gültige UUID."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existingRun, readError := handler.store.GetRun(request.Context(), runIdentifier)
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateJobError(readError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cancelError := handler.store.CancelRun(request.Context(), runIdentifier,
|
||||||
|
"Der Lauf wurde von "+actingUser.Username+" abgebrochen."); cancelError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateJobError(cancelError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionBackupRunCancelled, existingRun.JobID, map[string]any{
|
||||||
|
"run_id": runIdentifier.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
cancelledRun, refreshError := handler.store.GetRun(request.Context(), runIdentifier)
|
||||||
|
if refreshError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(refreshError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Abbruch wirkt nicht sofort: Die Ausführungsschleife beendet den
|
||||||
|
// laufenden Vorgang beim nächsten Durchgang. Das wird gesagt, statt einen
|
||||||
|
// bereits beendeten Lauf vorzutäuschen.
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"run": buildRunResponse(cancelledRun),
|
||||||
|
"message": "Der Abbruch wurde vermerkt. Ein bereits laufender Vorgang wird in Kürze beendet.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordAudit schreibt ein Auditereignis.
|
||||||
|
//
|
||||||
|
// Ein Fehler beim Protokollieren darf die Antwort nicht verändern — die
|
||||||
|
// Handlung ist bereits geschehen. Er wird aber deutlich protokolliert: Ein
|
||||||
|
// stiller Verlust von Auditereignissen wäre ein Sicherheitsmangel.
|
||||||
|
func (handler *jobHandler) recordAudit(request *http.Request, actingUser auth.User, auditAction audit.Action, jobIdentifier uuid.UUID, auditDetails map[string]any) {
|
||||||
|
if handler.auditRecorder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
correlationID, _ := logging.CorrelationIDFromContext(request.Context())
|
||||||
|
|
||||||
|
recordError := handler.auditRecorder.Record(request.Context(), audit.Event{
|
||||||
|
UserID: &actingUser.ID,
|
||||||
|
ActorUsername: actingUser.Username,
|
||||||
|
Action: auditAction,
|
||||||
|
EntityType: "backup_job",
|
||||||
|
EntityID: &jobIdentifier,
|
||||||
|
Result: audit.ResultSuccess,
|
||||||
|
IPAddress: clientIPAddress(request),
|
||||||
|
UserAgent: request.UserAgent(),
|
||||||
|
CorrelationID: correlationID,
|
||||||
|
Details: auditDetails,
|
||||||
|
})
|
||||||
|
|
||||||
|
if recordError != nil {
|
||||||
|
logging.WithContext(request.Context(), handler.logger).Error("das auditereignis konnte nicht geschrieben werden",
|
||||||
|
slog.String("aktion", string(auditAction)),
|
||||||
|
slog.String("job_id", jobIdentifier.String()),
|
||||||
|
slog.String("grund", recordError.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJobIdentifier liest die Auftragskennung aus dem Pfad.
|
||||||
|
func parseJobIdentifier(request *http.Request) (uuid.UUID, *APIError) {
|
||||||
|
jobIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
return uuid.Nil, NewBadRequestError("Die Auftragskennung ist keine gültige UUID.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobIdentifier, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePositiveInteger liest eine positive Zahl mit Standardwert.
|
||||||
|
func parsePositiveInteger(parameterValue string, defaultValue int) int {
|
||||||
|
parsedValue, parseError := strconv.Atoi(parameterValue)
|
||||||
|
if parseError != nil || parsedValue < 1 {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateJobError bildet Fehler der Fachschicht auf API-Fehler ab.
|
||||||
|
//
|
||||||
|
// Ein durchgereichter interner Fehler verriete Aufbau und Tabellennamen der
|
||||||
|
// Datenbank. Bekannte Fälle bekommen deshalb eine eigene, verständliche
|
||||||
|
// Meldung; alles Übrige wird zu einem allgemeinen Serverfehler.
|
||||||
|
func translateJobError(occurredError error) *APIError {
|
||||||
|
switch {
|
||||||
|
case errors.Is(occurredError, jobs.ErrJobNotFound):
|
||||||
|
return NewNotFoundError("Der Sicherungsauftrag wurde nicht gefunden.")
|
||||||
|
|
||||||
|
case errors.Is(occurredError, jobs.ErrRunNotFound):
|
||||||
|
return NewNotFoundError("Der Sicherungslauf wurde nicht gefunden oder ist bereits beendet.")
|
||||||
|
|
||||||
|
case errors.Is(occurredError, jobs.ErrRunAlreadyActive):
|
||||||
|
// 409 und nicht 500: Der Aufrufer hat nichts falsch gemacht, der
|
||||||
|
// Auftrag läuft nur bereits. Ein Serverfehler schickte ihn auf die
|
||||||
|
// Suche nach einem Defekt, den es nicht gibt.
|
||||||
|
activeError := NewValidationError(
|
||||||
|
"Für diesen Auftrag läuft bereits ein Sicherungslauf. Warten Sie dessen Ende ab.")
|
||||||
|
activeError.Code = ErrorCodeConflict
|
||||||
|
activeError.StatusCode = http.StatusConflict
|
||||||
|
|
||||||
|
return activeError
|
||||||
|
|
||||||
|
case errors.Is(occurredError, jobs.ErrJobNameTaken):
|
||||||
|
conflictError := NewValidationError("Ein Sicherungsauftrag dieses Namens besteht bereits.")
|
||||||
|
conflictError.Code = ErrorCodeConflict
|
||||||
|
conflictError.StatusCode = http.StatusConflict
|
||||||
|
|
||||||
|
return conflictError
|
||||||
|
|
||||||
|
case errors.Is(occurredError, jobs.ErrInvalidJob):
|
||||||
|
// Die Meldung der Fachschicht ist bereits verständlich formuliert und
|
||||||
|
// enthält keine Interna — sie wird deshalb weitergereicht.
|
||||||
|
return NewValidationError(occurredError.Error())
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NewInternalError(occurredError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// repositoryResponse ist die Darstellung eines Repositorys nach außen.
|
||||||
|
type repositoryResponse struct {
|
||||||
|
// ID ist der öffentliche Bezeichner.
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
// Name ist die sprechende Bezeichnung.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// RepositoryType benennt die Ablageart.
|
||||||
|
RepositoryType string `json:"repository_type"`
|
||||||
|
// Location ist der Pfad oder die Adresse der Ablage.
|
||||||
|
//
|
||||||
|
// Der Pfad ist keine Zugangsinformation und kein Geheimnis; ohne ihn liesse
|
||||||
|
// sich in der Oberfläche nicht unterscheiden, welches von zwei gleich
|
||||||
|
// benannten Zielen gemeint ist.
|
||||||
|
Location string `json:"location"`
|
||||||
|
// Status ist der Betriebszustand.
|
||||||
|
Status string `json:"status"`
|
||||||
|
// AcceptsBackups meldet, ob dieses Ziel Sicherungen annimmt.
|
||||||
|
//
|
||||||
|
// Der Zustand allein genügt der Oberfläche nicht: Sie müsste sonst wissen,
|
||||||
|
// welche Zustände schreibend sind. Diese Regel gehört auf den Server.
|
||||||
|
AcceptsBackups bool `json:"accepts_backups"`
|
||||||
|
// Hardened meldet den gehärteten Modus.
|
||||||
|
Hardened bool `json:"hardened"`
|
||||||
|
// CreatedAt ist der Anlagezeitpunkt in UTC.
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListRepositories bedient GET /repositories.
|
||||||
|
//
|
||||||
|
// Rein lesend und ohne Pagination: Ein Betrieb hat eine Handvoll Repositories,
|
||||||
|
// nicht tausende. Das Anlegen geschieht weiterhin über syncova-repo — ein
|
||||||
|
// Repository entsteht auf einem Datenträger, nicht in einer Datenbankzeile.
|
||||||
|
func (handler *jobHandler) handleListRepositories(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
loadedRepositories, listError := handler.store.ListRepositories(request.Context())
|
||||||
|
if listError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repositoryResponses := make([]repositoryResponse, 0, len(loadedRepositories))
|
||||||
|
for _, loadedRepository := range loadedRepositories {
|
||||||
|
repositoryResponses = append(repositoryResponses, repositoryResponse{
|
||||||
|
ID: loadedRepository.ID,
|
||||||
|
Name: loadedRepository.Name,
|
||||||
|
RepositoryType: loadedRepository.RepositoryType,
|
||||||
|
Location: loadedRepository.Location,
|
||||||
|
Status: string(loadedRepository.Status),
|
||||||
|
AcceptsBackups: loadedRepository.Status.AcceptsWrites(),
|
||||||
|
Hardened: loadedRepository.Hardened,
|
||||||
|
CreatedAt: loadedRepository.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, repositoryResponses)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListBackups bedient GET /backups (Wiederherstellungspunkte).
|
||||||
|
//
|
||||||
|
// Die Seite „Recovery Points" ist die zentrale Auskunft der Anlage: Welche
|
||||||
|
// Punkte gibt es, und kann man sich auf sie verlassen? Die Antwort trägt
|
||||||
|
// deshalb Einstufung, Bewertung und Schutzlage je Zeile — sonst müsste die
|
||||||
|
// Oberfläche je Punkt drei weitere Anfragen stellen.
|
||||||
|
func (handler *jobHandler) handleListBackups(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
listFilter := jobs.BackupListFilter{
|
||||||
|
Status: request.URL.Query().Get("status"),
|
||||||
|
Classification: request.URL.Query().Get("classification"),
|
||||||
|
IncludeDeleted: request.URL.Query().Get("include_deleted") == "true",
|
||||||
|
OnlyProtected: request.URL.Query().Get("only_protected") == "true",
|
||||||
|
Page: parsePositiveInteger(request.URL.Query().Get("page"), 1),
|
||||||
|
PageSize: parsePositiveInteger(request.URL.Query().Get("page_size"), 50),
|
||||||
|
}
|
||||||
|
|
||||||
|
if repositoryText := request.URL.Query().Get("repository_id"); repositoryText != "" {
|
||||||
|
repositoryIdentifier, parseError := uuid.Parse(repositoryText)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError("Die Repositorykennung ist keine gültige UUID."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listFilter.RepositoryID = &repositoryIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
if jobText := request.URL.Query().Get("job_id"); jobText != "" {
|
||||||
|
jobIdentifier, parseError := uuid.Parse(jobText)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError("Die Auftragskennung ist keine gültige UUID."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
listFilter.JobID = &jobIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedBackups, totalCount, listError := handler.store.ListBackups(request.Context(), listFilter)
|
||||||
|
if listError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WritePaginatedSuccess(responseWriter, request, loadedBackups, PaginationMeta{
|
||||||
|
Page: listFilter.Page,
|
||||||
|
PageSize: listFilter.PageSize,
|
||||||
|
Total: int64(totalCount),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDashboard bedient GET /dashboard.
|
||||||
|
//
|
||||||
|
// Jedes Widget meldet, ob es eine Datengrundlage hat. Drei der zehn im Plan
|
||||||
|
// genannten haben sie in dieser Ausbaustufe nicht; sie erscheinen trotzdem —
|
||||||
|
// mit der Angabe, was fehlt. Ein weggelassenes Widget sieht aus wie ein
|
||||||
|
// vergessenes, ein gefülltes wäre eine erfundene Statistik (PROMPT.md §139).
|
||||||
|
func (handler *jobHandler) handleDashboard(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
dashboardData, dashboardError := handler.store.Dashboard(request.Context())
|
||||||
|
if dashboardError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(dashboardError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, dashboardData)
|
||||||
|
}
|
||||||
192
apps/api/internal/httpapi/job_mapping.go
Normal file
192
apps/api/internal/httpapi/job_mapping.go
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/syncova/syncova/packages/jobs"
|
||||||
|
"github.com/syncova/syncova/packages/scheduler"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildJobFromRequest wandelt einen Anfragerumpf in einen Auftrag.
|
||||||
|
//
|
||||||
|
// Die Prüfung der Werte bleibt der Fachschicht überlassen; hier wird nur
|
||||||
|
// übersetzt. Zwei Prüfstellen mit eigenen Regeln liefen unweigerlich
|
||||||
|
// auseinander.
|
||||||
|
func buildJobFromRequest(jobPayload jobRequest, creatorID uuid.UUID) (*jobs.Job, *APIError) {
|
||||||
|
parsedSchedule, scheduleError := buildScheduleFromRequest(jobPayload.Schedule)
|
||||||
|
if scheduleError != nil {
|
||||||
|
return nil, scheduleError
|
||||||
|
}
|
||||||
|
|
||||||
|
jobPriority := scheduler.Priority(jobPayload.Priority)
|
||||||
|
if jobPayload.Priority == "" {
|
||||||
|
jobPriority = scheduler.PriorityNormal
|
||||||
|
}
|
||||||
|
|
||||||
|
maximumConcurrency := jobPayload.MaximumConcurrency
|
||||||
|
if maximumConcurrency < 1 {
|
||||||
|
maximumConcurrency = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
jobSources := make([]jobs.JobSource, 0, len(jobPayload.Sources))
|
||||||
|
for _, requestedSource := range jobPayload.Sources {
|
||||||
|
jobSources = append(jobSources, jobs.JobSource{
|
||||||
|
SourceType: jobs.SourceType(requestedSource.Type),
|
||||||
|
SourceID: requestedSource.ID,
|
||||||
|
SourceName: requestedSource.Name,
|
||||||
|
AgentID: requestedSource.AgentID,
|
||||||
|
IncludePatterns: requestedSource.IncludePatterns,
|
||||||
|
ExcludePatterns: requestedSource.ExcludePatterns,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
builtJob := &jobs.Job{
|
||||||
|
Name: strings.TrimSpace(jobPayload.Name),
|
||||||
|
Description: strings.TrimSpace(jobPayload.Description),
|
||||||
|
Status: jobs.JobStatusActive,
|
||||||
|
Priority: jobPriority,
|
||||||
|
Schedule: parsedSchedule,
|
||||||
|
Sources: jobSources,
|
||||||
|
RepositoryID: jobPayload.RepositoryID,
|
||||||
|
RetentionPolicyID: jobPayload.RetentionPolicyID,
|
||||||
|
DependsOnJobIDs: jobPayload.DependsOnJobIDs,
|
||||||
|
RecoveryPointObjective: time.Duration(jobPayload.RecoveryPointSeconds) * time.Second,
|
||||||
|
RecoveryTimeObjective: time.Duration(jobPayload.RecoveryTimeSeconds) * time.Second,
|
||||||
|
BandwidthLimitBytesPerSecond: jobPayload.BandwidthLimitBytesPerSecond,
|
||||||
|
MaximumConcurrency: maximumConcurrency,
|
||||||
|
RetryPolicy: scheduler.DefaultRetryPolicy(),
|
||||||
|
CreatedBy: &creatorID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die fachliche Prüfung läuft hier, damit ein fehlerhafter Auftrag mit 422
|
||||||
|
// beantwortet wird statt mit einem Datenbankfehler als 500.
|
||||||
|
if validationError := builtJob.Validate(); validationError != nil {
|
||||||
|
return nil, NewValidationError(validationError.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return builtJob, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildScheduleFromRequest wandelt eine Zeitplanangabe.
|
||||||
|
func buildScheduleFromRequest(scheduleData scheduleRequest) (scheduler.Schedule, *APIError) {
|
||||||
|
builtSchedule := scheduler.Schedule{
|
||||||
|
ScheduleType: scheduler.ScheduleType(scheduleData.Type),
|
||||||
|
CronExpression: scheduleData.CronExpression,
|
||||||
|
TimeZone: scheduleData.TimeZone,
|
||||||
|
MonthDays: scheduleData.MonthDays,
|
||||||
|
Interval: time.Duration(scheduleData.IntervalSeconds) * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
if scheduleData.Time != "" {
|
||||||
|
parsedHour, parsedMinute, timeError := parseClockTime(scheduleData.Time)
|
||||||
|
if timeError != nil {
|
||||||
|
return scheduler.Schedule{}, NewValidationError(timeError.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
builtSchedule.Hour = parsedHour
|
||||||
|
builtSchedule.Minute = parsedMinute
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, weekdayNumber := range scheduleData.Weekdays {
|
||||||
|
if weekdayNumber < 0 || weekdayNumber > 6 {
|
||||||
|
return scheduler.Schedule{}, NewValidationError(
|
||||||
|
fmt.Sprintf("Der Wochentag %d ist unbekannt; zulässig sind 0 (Sonntag) bis 6 (Samstag).", weekdayNumber))
|
||||||
|
}
|
||||||
|
|
||||||
|
builtSchedule.Weekdays = append(builtSchedule.Weekdays, time.Weekday(weekdayNumber))
|
||||||
|
}
|
||||||
|
|
||||||
|
return builtSchedule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseClockTime liest eine Uhrzeit im Format "HH:MM".
|
||||||
|
func parseClockTime(clockText string) (int, int, error) {
|
||||||
|
hourText, minuteText, hasSeparator := strings.Cut(strings.TrimSpace(clockText), ":")
|
||||||
|
if !hasSeparator {
|
||||||
|
return 0, 0, fmt.Errorf("Die Uhrzeit %q muss im Format HH:MM angegeben werden.", clockText)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedHour, hourError := strconv.Atoi(hourText)
|
||||||
|
if hourError != nil || parsedHour < 0 || parsedHour > 23 {
|
||||||
|
return 0, 0, fmt.Errorf("Die Stunde in %q liegt nicht zwischen 00 und 23.", clockText)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedMinute, minuteError := strconv.Atoi(minuteText)
|
||||||
|
if minuteError != nil || parsedMinute < 0 || parsedMinute > 59 {
|
||||||
|
return 0, 0, fmt.Errorf("Die Minute in %q liegt nicht zwischen 00 und 59.", clockText)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedHour, parsedMinute, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildJobResponse wandelt einen Auftrag in seine Darstellung nach außen.
|
||||||
|
func buildJobResponse(sourceJob *jobs.Job) jobResponse {
|
||||||
|
sourceResponses := make([]sourceRequest, 0, len(sourceJob.Sources))
|
||||||
|
for _, jobSource := range sourceJob.Sources {
|
||||||
|
sourceResponses = append(sourceResponses, sourceRequest{
|
||||||
|
Type: string(jobSource.SourceType),
|
||||||
|
ID: jobSource.SourceID,
|
||||||
|
Name: jobSource.SourceName,
|
||||||
|
AgentID: jobSource.AgentID,
|
||||||
|
IncludePatterns: jobSource.IncludePatterns,
|
||||||
|
ExcludePatterns: jobSource.ExcludePatterns,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobResponse{
|
||||||
|
ID: sourceJob.ID,
|
||||||
|
Name: sourceJob.Name,
|
||||||
|
Description: sourceJob.Description,
|
||||||
|
Status: string(sourceJob.Status),
|
||||||
|
Priority: string(sourceJob.Priority),
|
||||||
|
Schedule: buildScheduleResponse(sourceJob.Schedule),
|
||||||
|
ScheduleDescription: sourceJob.Schedule.Describe(),
|
||||||
|
Sources: sourceResponses,
|
||||||
|
RepositoryID: sourceJob.RepositoryID,
|
||||||
|
RetentionPolicyID: sourceJob.RetentionPolicyID,
|
||||||
|
DependsOnJobIDs: sourceJob.DependsOnJobIDs,
|
||||||
|
RecoveryPointSeconds: int64(sourceJob.RecoveryPointObjective.Seconds()),
|
||||||
|
RecoveryTimeSeconds: int64(sourceJob.RecoveryTimeObjective.Seconds()),
|
||||||
|
BandwidthLimitBytesPerSecond: sourceJob.BandwidthLimitBytesPerSecond,
|
||||||
|
MaximumConcurrency: sourceJob.MaximumConcurrency,
|
||||||
|
NextRunAt: sourceJob.NextRunAt,
|
||||||
|
LastRunAt: sourceJob.LastRunAt,
|
||||||
|
LastOutcome: string(sourceJob.LastOutcome),
|
||||||
|
PausedAt: sourceJob.PausedAt,
|
||||||
|
CreatedAt: sourceJob.CreatedAt,
|
||||||
|
UpdatedAt: sourceJob.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildScheduleResponse wandelt einen Zeitplan in seine Darstellung.
|
||||||
|
func buildScheduleResponse(sourceSchedule scheduler.Schedule) scheduleRequest {
|
||||||
|
scheduleData := scheduleRequest{
|
||||||
|
Type: string(sourceSchedule.ScheduleType),
|
||||||
|
CronExpression: sourceSchedule.CronExpression,
|
||||||
|
TimeZone: sourceSchedule.TimeZone,
|
||||||
|
MonthDays: sourceSchedule.MonthDays,
|
||||||
|
}
|
||||||
|
|
||||||
|
if sourceSchedule.Interval > 0 {
|
||||||
|
scheduleData.IntervalSeconds = int64(sourceSchedule.Interval.Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Uhrzeit erscheint nur bei den Zeitplänen, die eine haben. Bei einem
|
||||||
|
// Intervallplan wäre "00:00" eine Angabe, die es nicht gibt.
|
||||||
|
switch sourceSchedule.ScheduleType {
|
||||||
|
case scheduler.ScheduleTypeDaily, scheduler.ScheduleTypeWeekly, scheduler.ScheduleTypeMonthly:
|
||||||
|
scheduleData.Time = fmt.Sprintf("%02d:%02d", sourceSchedule.Hour, sourceSchedule.Minute)
|
||||||
|
case scheduler.ScheduleTypeHourly:
|
||||||
|
scheduleData.Time = fmt.Sprintf(":%02d", sourceSchedule.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scheduleWeekday := range sourceSchedule.Weekdays {
|
||||||
|
scheduleData.Weekdays = append(scheduleData.Weekdays, int(scheduleWeekday))
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheduleData
|
||||||
|
}
|
||||||
144
apps/api/internal/httpapi/metrics_handler.go
Normal file
144
apps/api/internal/httpapi/metrics_handler.go
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/syncova/syncova/packages/metrics"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// metricsHandler bedient die Kennzahlen und Diagramme (Phase 13).
|
||||||
|
type metricsHandler struct {
|
||||||
|
// metricsStore bildet die Zeitreihen.
|
||||||
|
metricsStore *metrics.Store
|
||||||
|
// logger protokolliert technische Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListCharts bedient GET /metrics.
|
||||||
|
//
|
||||||
|
// Der Katalog nennt **alle** zwölf Diagramme aus dem Plan, auch das ohne
|
||||||
|
// Datengrundlage. Ein weggelassenes sähe aus wie ein vergessenes; die
|
||||||
|
// Oberfläche kann so anzeigen, was es gibt und was noch fehlt.
|
||||||
|
func (handler *metricsHandler) handleListCharts(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
chartCatalog := metrics.Catalog()
|
||||||
|
|
||||||
|
availableCount := 0
|
||||||
|
|
||||||
|
for _, chartDefinition := range chartCatalog {
|
||||||
|
if chartDefinition.Available {
|
||||||
|
availableCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"charts": chartCatalog,
|
||||||
|
"available_count": availableCount,
|
||||||
|
"ranges": []string{"1h", "24h", "7d", "30d", "90d", "1y", "custom"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetChart bedient GET /metrics/{metric}.
|
||||||
|
func (handler *metricsHandler) handleGetChart(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
metricName := request.PathValue("metric")
|
||||||
|
|
||||||
|
requestedRange := metrics.TimeRange(request.URL.Query().Get("range"))
|
||||||
|
if requestedRange == "" {
|
||||||
|
// Sieben Tage als Vorgabe: kurz genug, um einen Ausfall von gestern zu
|
||||||
|
// zeigen, lang genug, um nicht bei jeder Nacht ohne Lauf leer zu sein.
|
||||||
|
requestedRange = metrics.RangeLastWeek
|
||||||
|
}
|
||||||
|
|
||||||
|
customFrom, customTo, parseError := parseCustomRange(request)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedWindow, windowError := metrics.ResolveWindow(requestedRange, customFrom, customTo, time.Now())
|
||||||
|
if windowError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewValidationError(windowError.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var repositoryFilter *uuid.UUID
|
||||||
|
|
||||||
|
if repositoryText := request.URL.Query().Get("repository_id"); repositoryText != "" {
|
||||||
|
repositoryIdentifier, uuidError := uuid.Parse(repositoryText)
|
||||||
|
if uuidError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError("Die Repositorykennung ist keine gültige UUID."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repositoryFilter = &repositoryIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
builtChart, buildError := handler.metricsStore.BuildChart(request.Context(),
|
||||||
|
metricName, resolvedWindow, repositoryFilter)
|
||||||
|
if buildError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateMetricsError(buildError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, builtChart)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCustomRange liest Anfang und Ende eines eigenen Zeitraums.
|
||||||
|
func parseCustomRange(request *http.Request) (time.Time, time.Time, *APIError) {
|
||||||
|
var customFrom, customTo time.Time
|
||||||
|
|
||||||
|
if fromText := request.URL.Query().Get("from"); fromText != "" {
|
||||||
|
parsedFrom, parseError := time.Parse(time.RFC3339, fromText)
|
||||||
|
if parseError != nil {
|
||||||
|
return customFrom, customTo, NewBadRequestError(
|
||||||
|
"Der Anfangszeitpunkt ist keine gültige Angabe nach RFC 3339.")
|
||||||
|
}
|
||||||
|
|
||||||
|
customFrom = parsedFrom
|
||||||
|
}
|
||||||
|
|
||||||
|
if toText := request.URL.Query().Get("to"); toText != "" {
|
||||||
|
parsedTo, parseError := time.Parse(time.RFC3339, toText)
|
||||||
|
if parseError != nil {
|
||||||
|
return customFrom, customTo, NewBadRequestError(
|
||||||
|
"Der Endzeitpunkt ist keine gültige Angabe nach RFC 3339.")
|
||||||
|
}
|
||||||
|
|
||||||
|
customTo = parsedTo
|
||||||
|
}
|
||||||
|
|
||||||
|
return customFrom, customTo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateMetricsError bildet Fehler der Fachschicht auf API-Fehler ab.
|
||||||
|
func translateMetricsError(occurredError error) *APIError {
|
||||||
|
switch {
|
||||||
|
case errors.Is(occurredError, metrics.ErrChartNotFound):
|
||||||
|
return NewNotFoundError("Dieses Diagramm gibt es nicht. GET /metrics nennt alle verfügbaren.")
|
||||||
|
|
||||||
|
case errors.Is(occurredError, metrics.ErrChartUnavailable):
|
||||||
|
// 501 und nicht 404: Das Diagramm ist vorgesehen, es fehlt nur die
|
||||||
|
// Datengrundlage. Der Unterschied entscheidet, was der Aufrufer tut —
|
||||||
|
// warten oder den Namen korrigieren.
|
||||||
|
unavailableError := NewValidationError(occurredError.Error())
|
||||||
|
unavailableError.Code = "NOT_IMPLEMENTED"
|
||||||
|
unavailableError.StatusCode = http.StatusNotImplemented
|
||||||
|
|
||||||
|
return unavailableError
|
||||||
|
|
||||||
|
case errors.Is(occurredError, metrics.ErrInvalidRange):
|
||||||
|
return NewValidationError(occurredError.Error())
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NewInternalError(occurredError)
|
||||||
|
}
|
||||||
|
}
|
||||||
245
apps/api/internal/httpapi/middleware.go
Normal file
245
apps/api/internal/httpapi/middleware.go
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTP-Header, die Syncova für Nachvollziehbarkeit auswertet bzw. setzt.
|
||||||
|
const (
|
||||||
|
// headerCorrelationID trägt die vom Aufrufer vorgegebene Correlation ID (SYNCOVA_API.md).
|
||||||
|
headerCorrelationID = "X-Correlation-ID"
|
||||||
|
// headerRequestID trägt die serverseitig vergebene Request-ID.
|
||||||
|
headerRequestID = "X-Request-ID"
|
||||||
|
)
|
||||||
|
|
||||||
|
// requestIDContextKeyType ist ein privater Typ für den Context-Schlüssel der Request-ID.
|
||||||
|
type requestIDContextKeyType struct{}
|
||||||
|
|
||||||
|
// requestIDContextKey speichert die Request-ID im Request-Context.
|
||||||
|
var requestIDContextKey = requestIDContextKeyType{}
|
||||||
|
|
||||||
|
// Middleware ist ein Dekorator für einen HTTP-Handler.
|
||||||
|
type Middleware func(http.Handler) http.Handler
|
||||||
|
|
||||||
|
// Chain verkettet Middlewares so, dass die zuerst genannte außen liegt.
|
||||||
|
//
|
||||||
|
// Die Reihenfolge ist sicherheitsrelevant: Recovery und Correlation ID müssen
|
||||||
|
// außen liegen, damit auch Fehler innerer Schichten erfasst werden.
|
||||||
|
func Chain(finalHandler http.Handler, middlewares ...Middleware) http.Handler {
|
||||||
|
// Rückwärts anwenden, damit middlewares[0] die äußerste Schicht bildet.
|
||||||
|
for middlewareIndex := len(middlewares) - 1; middlewareIndex >= 0; middlewareIndex-- {
|
||||||
|
finalHandler = middlewares[middlewareIndex](finalHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestIDFromContext liest die Request-ID aus dem Context.
|
||||||
|
// Ohne gesetzte ID liefert die Funktion eine leere Zeichenkette.
|
||||||
|
func RequestIDFromContext(currentContext context.Context) string {
|
||||||
|
requestID, isPresent := currentContext.Value(requestIDContextKey).(string)
|
||||||
|
if !isPresent {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestID
|
||||||
|
}
|
||||||
|
|
||||||
|
// CorrelationMiddleware vergibt Request-ID und Correlation ID für jeden Request.
|
||||||
|
//
|
||||||
|
// Die Request-ID wird immer serverseitig erzeugt; die Correlation ID darf der
|
||||||
|
// Aufrufer vorgeben, um eine Operation über Systemgrenzen hinweg zu verfolgen
|
||||||
|
// (PROMPT.md §50). Ein ungültiger Vorgabewert wird verworfen statt übernommen,
|
||||||
|
// damit keine fremden Zeichenketten in die Logs gelangen.
|
||||||
|
func CorrelationMiddleware() Middleware {
|
||||||
|
return func(nextHandler http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestID := uuid.NewString()
|
||||||
|
|
||||||
|
// Nur eine syntaktisch gültige UUID wird als Correlation ID übernommen.
|
||||||
|
correlationID := requestID
|
||||||
|
if providedCorrelationID := request.Header.Get(headerCorrelationID); providedCorrelationID != "" {
|
||||||
|
if _, parseError := uuid.Parse(providedCorrelationID); parseError == nil {
|
||||||
|
correlationID = providedCorrelationID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichedContext := context.WithValue(request.Context(), requestIDContextKey, requestID)
|
||||||
|
enrichedContext = logging.ContextWithCorrelationID(enrichedContext, correlationID)
|
||||||
|
|
||||||
|
// Beide IDs gehen an den Aufrufer zurück, damit er einen Vorfall
|
||||||
|
// gegenüber dem Betreiber eindeutig benennen kann.
|
||||||
|
responseWriter.Header().Set(headerRequestID, requestID)
|
||||||
|
responseWriter.Header().Set(headerCorrelationID, correlationID)
|
||||||
|
|
||||||
|
nextHandler.ServeHTTP(responseWriter, request.WithContext(enrichedContext))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecurityHeadersMiddleware setzt defensive Antwort-Header (PROMPT.md §45).
|
||||||
|
func SecurityHeadersMiddleware() Middleware {
|
||||||
|
return func(nextHandler http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
responseHeaders := responseWriter.Header()
|
||||||
|
|
||||||
|
// Verhindert, dass Browser den Inhaltstyp erraten.
|
||||||
|
responseHeaders.Set("X-Content-Type-Options", "nosniff")
|
||||||
|
// Die API liefert ausschließlich JSON und wird nie eingebettet.
|
||||||
|
responseHeaders.Set("X-Frame-Options", "DENY")
|
||||||
|
// Keine Referrer-Weitergabe an fremde Ziele.
|
||||||
|
responseHeaders.Set("Referrer-Policy", "no-referrer")
|
||||||
|
// Eine restriktive CSP, da API-Antworten kein aktives Material enthalten.
|
||||||
|
responseHeaders.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
|
||||||
|
// Antworten der Control Plane dürfen nicht zwischengespeichert werden.
|
||||||
|
responseHeaders.Set("Cache-Control", "no-store")
|
||||||
|
|
||||||
|
nextHandler.ServeHTTP(responseWriter, request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORSMiddleware erlaubt Cross-Origin-Zugriff ausschließlich für benannte Herkünfte.
|
||||||
|
//
|
||||||
|
// Eine leere Liste bedeutet: kein Cross-Origin-Zugriff. Es gibt bewusst keine
|
||||||
|
// Wildcard-Unterstützung (PROMPT.md §45).
|
||||||
|
func CORSMiddleware(allowedOrigins []string) Middleware {
|
||||||
|
return func(nextHandler http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestOrigin := request.Header.Get("Origin")
|
||||||
|
isAllowedOrigin := requestOrigin != "" && slices.Contains(allowedOrigins, requestOrigin)
|
||||||
|
|
||||||
|
if isAllowedOrigin {
|
||||||
|
responseHeaders := responseWriter.Header()
|
||||||
|
responseHeaders.Set("Access-Control-Allow-Origin", requestOrigin)
|
||||||
|
responseHeaders.Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
responseHeaders.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, "+headerCorrelationID+", Idempotency-Key")
|
||||||
|
responseHeaders.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||||
|
responseHeaders.Set("Access-Control-Max-Age", "600")
|
||||||
|
// Antwort hängt von der Herkunft ab; ohne Vary wären Caches unsicher.
|
||||||
|
responseHeaders.Add("Vary", "Origin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preflight-Anfragen werden hier abschließend beantwortet.
|
||||||
|
if request.Method == http.MethodOptions {
|
||||||
|
if isAllowedOrigin {
|
||||||
|
responseWriter.WriteHeader(http.StatusNoContent)
|
||||||
|
} else {
|
||||||
|
responseWriter.WriteHeader(http.StatusForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nextHandler.ServeHTTP(responseWriter, request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BodyLimitMiddleware begrenzt die Größe eines Request-Bodys (PROMPT.md §45).
|
||||||
|
func BodyLimitMiddleware(maxRequestBodyBytes int64) Middleware {
|
||||||
|
return func(nextHandler http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
// MaxBytesReader bricht das Lesen ab, sobald das Limit überschritten wird,
|
||||||
|
// statt den gesamten Body erst zu puffern.
|
||||||
|
request.Body = http.MaxBytesReader(responseWriter, request.Body, maxRequestBodyBytes)
|
||||||
|
nextHandler.ServeHTTP(responseWriter, request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecoveryMiddleware fängt Panics und verwandelt sie in eine kontrollierte Antwort.
|
||||||
|
//
|
||||||
|
// PROMPT.md §51 verbietet unkontrollierte Ausnahmen: ein Programmierfehler darf
|
||||||
|
// den Dienst nicht beenden und keine internen Details ausliefern.
|
||||||
|
func RecoveryMiddleware(baseLogger *slog.Logger) Middleware {
|
||||||
|
return func(nextHandler http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
panicValue := recover()
|
||||||
|
if panicValue == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// http.ErrAbortHandler ist der dokumentierte Weg, eine Antwort
|
||||||
|
// bewusst abzubrechen, und kein Fehlerfall.
|
||||||
|
if panicValue == http.ErrAbortHandler {
|
||||||
|
panic(panicValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestLogger := logging.WithContext(request.Context(), baseLogger)
|
||||||
|
requestLogger.Error("panic im request-handler abgefangen",
|
||||||
|
slog.Any("panic", panicValue),
|
||||||
|
slog.String("path", request.URL.Path),
|
||||||
|
)
|
||||||
|
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(nil))
|
||||||
|
}()
|
||||||
|
|
||||||
|
nextHandler.ServeHTTP(responseWriter, request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusCapturingResponseWriter merkt sich Statuscode und Antwortgröße für das Zugriffslog.
|
||||||
|
type statusCapturingResponseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
// statusCode ist der tatsächlich gesendete HTTP-Status.
|
||||||
|
statusCode int
|
||||||
|
// writtenBytes zählt die Größe des Antwortkörpers.
|
||||||
|
writtenBytes int
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteHeader merkt sich den Statuscode und reicht ihn weiter.
|
||||||
|
func (capturingWriter *statusCapturingResponseWriter) WriteHeader(statusCode int) {
|
||||||
|
capturingWriter.statusCode = statusCode
|
||||||
|
capturingWriter.ResponseWriter.WriteHeader(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write zählt die geschriebenen Bytes und reicht sie weiter.
|
||||||
|
func (capturingWriter *statusCapturingResponseWriter) Write(responseBytes []byte) (int, error) {
|
||||||
|
// Ohne vorherigen WriteHeader gilt implizit 200.
|
||||||
|
if capturingWriter.statusCode == 0 {
|
||||||
|
capturingWriter.statusCode = http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
bytesWritten, writeError := capturingWriter.ResponseWriter.Write(responseBytes)
|
||||||
|
capturingWriter.writtenBytes += bytesWritten
|
||||||
|
|
||||||
|
return bytesWritten, writeError
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessLogMiddleware protokolliert jeden Request strukturiert (PROMPT.md §49).
|
||||||
|
func AccessLogMiddleware(baseLogger *slog.Logger) Middleware {
|
||||||
|
return func(nextHandler http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestStartTime := time.Now()
|
||||||
|
capturingWriter := &statusCapturingResponseWriter{ResponseWriter: responseWriter}
|
||||||
|
|
||||||
|
nextHandler.ServeHTTP(capturingWriter, request)
|
||||||
|
|
||||||
|
// Ein Handler, der nichts schreibt, hat faktisch 200 geliefert.
|
||||||
|
if capturingWriter.statusCode == 0 {
|
||||||
|
capturingWriter.statusCode = http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Query-Zeichenkette wird bewusst nicht geloggt: sie könnte
|
||||||
|
// versehentlich sensible Werte enthalten (PROMPT.md §45).
|
||||||
|
logging.WithContext(request.Context(), baseLogger).Info("http request",
|
||||||
|
slog.String("method", request.Method),
|
||||||
|
slog.String("path", request.URL.Path),
|
||||||
|
slog.Int("status_code", capturingWriter.statusCode),
|
||||||
|
slog.Int("response_bytes", capturingWriter.writtenBytes),
|
||||||
|
slog.String("duration_ms", strconv.FormatFloat(time.Since(requestStartTime).Seconds()*1000, 'f', 2, 64)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apps/api/internal/httpapi/ransomware_handler.go
Normal file
50
apps/api/internal/httpapi/ransomware_handler.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
"github.com/syncova/syncova/packages/ransomware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ransomwareHandler bedient die Auffaelligkeitsbewertung eines Laufs.
|
||||||
|
//
|
||||||
|
// Ohne diesen Endpunkt bliebe die Meldung „4 von 6 Signalen sind auffaellig"
|
||||||
|
// eine Behauptung: Ihre Empfehlung lautet „Pruefen Sie die Quelle" — wer aber
|
||||||
|
// nicht sehen kann, **welche** vier Signale gemeint sind, hat keinen Anfang.
|
||||||
|
type ransomwareHandler struct {
|
||||||
|
// detector bewertet einen Lauf gegen den Basiswert seiner Kette.
|
||||||
|
detector *ransomware.Detector
|
||||||
|
// logger protokolliert technische Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetAssessment bedient GET /backups/{id}/ransomware-assessment.
|
||||||
|
//
|
||||||
|
// Die Bewertung wird bei jedem Aufruf neu berechnet, nicht gespeichert. Der
|
||||||
|
// Basiswert wandert mit den letzten Laeufen; eine eingefrorene Bewertung
|
||||||
|
// beschriebe einen Vergleich, den es so nicht mehr gibt. Dieselbe Entscheidung
|
||||||
|
// wie bei der Recovery Assurance (Phase 10).
|
||||||
|
func (handler *ransomwareHandler) handleGetAssessment(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
backupIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError("Die Backupkennung ist keine gueltige UUID."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assessment, assessError := handler.detector.AssessBackup(request.Context(), backupIdentifier)
|
||||||
|
if assessError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewNotFoundError("Das Backup wurde nicht gefunden."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, assessment)
|
||||||
|
}
|
||||||
222
apps/api/internal/httpapi/report_handler.go
Normal file
222
apps/api/internal/httpapi/report_handler.go
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/audit"
|
||||||
|
"github.com/syncova/syncova/packages/auth"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
"github.com/syncova/syncova/packages/reports"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reportHandler bedient die Berichte (SYNCOVA_API.md §21).
|
||||||
|
type reportHandler struct {
|
||||||
|
// reportGenerator erzeugt die Berichte.
|
||||||
|
reportGenerator *reports.Generator
|
||||||
|
// auditRecorder protokolliert die Ausgabe eines Berichts.
|
||||||
|
auditRecorder audit.Recorder
|
||||||
|
// logger protokolliert technische Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportCatalogEntry ist ein Eintrag des Berichtskatalogs.
|
||||||
|
type reportCatalogEntry struct {
|
||||||
|
// Type ist der maschinenlesbare Bezeichner.
|
||||||
|
Type reports.ReportType `json:"type"`
|
||||||
|
// Title ist die Bezeichnung.
|
||||||
|
Title string `json:"title"`
|
||||||
|
// Description erklaert, welche Frage der Bericht beantwortet.
|
||||||
|
Description string `json:"description"`
|
||||||
|
// PeriodKind beschreibt die Art des Zeitbezugs.
|
||||||
|
PeriodKind reports.PeriodKind `json:"period_kind"`
|
||||||
|
// DefaultPeriod beschreibt den Standardzeitraum.
|
||||||
|
DefaultPeriod string `json:"default_period,omitempty"`
|
||||||
|
// Formats sind die verfuegbaren Ausgabeformate.
|
||||||
|
Formats []reports.Format `json:"formats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListReports bedient GET /reports.
|
||||||
|
func (handler *reportHandler) handleListReports(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
catalogEntries := make([]reportCatalogEntry, 0, 9)
|
||||||
|
|
||||||
|
for _, definition := range reports.Definitions() {
|
||||||
|
catalogEntries = append(catalogEntries, reportCatalogEntry{
|
||||||
|
Type: definition.Type,
|
||||||
|
Title: definition.Title,
|
||||||
|
Description: definition.Description,
|
||||||
|
PeriodKind: definition.PeriodKind,
|
||||||
|
DefaultPeriod: definition.DefaultPeriodLabel,
|
||||||
|
Formats: definition.Formats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, catalogEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateReportRequest ist der Rumpf von POST /reports/generate.
|
||||||
|
type generateReportRequest struct {
|
||||||
|
// Type ist die gewuenschte Reportart.
|
||||||
|
Type string `json:"type"`
|
||||||
|
// From ist der Beginn des Zeitraums; ohne Angabe gilt der Standardzeitraum.
|
||||||
|
From *time.Time `json:"from"`
|
||||||
|
// To ist das Ende des Zeitraums; ohne Angabe gilt jetzt.
|
||||||
|
To *time.Time `json:"to"`
|
||||||
|
// Format ist das Ausgabeformat; ohne Angabe JSON.
|
||||||
|
Format string `json:"format"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGenerateReport bedient POST /reports/generate.
|
||||||
|
//
|
||||||
|
// Der Bericht wird erzeugt und unmittelbar ausgeliefert; er wird **nicht**
|
||||||
|
// gespeichert. Ein abgelegter Bericht veraltet mit jedem Tag, ohne dass sich an
|
||||||
|
// ihm etwas aendert — dieselbe Ueberlegung wie bei der Recovery Assurance
|
||||||
|
// (Phase 10) und der Sicherheitsbewertung (Phase 15). Wer ihn aufbewahren will,
|
||||||
|
// laedt ihn herunter; die Datei traegt ihren Erzeugungszeitpunkt bei sich.
|
||||||
|
func (handler *reportHandler) handleGenerateReport(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
var reportRequest generateReportRequest
|
||||||
|
|
||||||
|
if decodeError := decodeJSONBody(request, &reportRequest); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reportType, typeError := reports.ParseType(reportRequest.Type)
|
||||||
|
if typeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError(typeError.Error()))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
outputFormat, formatError := reports.ParseFormat(reportRequest.Format)
|
||||||
|
if formatError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError(formatError.Error()))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
definition, _ := reports.FindDefinition(reportType)
|
||||||
|
|
||||||
|
periodFrom, periodTo, periodError := reports.ResolvePeriod(definition,
|
||||||
|
reportRequest.From, reportRequest.To, time.Now().UTC())
|
||||||
|
if periodError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError(periodError.Error()))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUser, isAuthenticated := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
requestedBy := ""
|
||||||
|
if isAuthenticated {
|
||||||
|
requestedBy = currentUser.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
generatedReport, generateError := handler.reportGenerator.Generate(request.Context(),
|
||||||
|
reportType, periodFrom, periodTo, requestedBy)
|
||||||
|
if generateError != nil {
|
||||||
|
if errors.Is(generateError, reports.ErrSecurityInspectorMissing) {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewServiceUnavailableError("Für diesen Bericht ist keine Sicherheitsprüfung "+
|
||||||
|
"eingerichtet. Ein Bericht mit leeren Abschnitten sähe aus wie eine Anlage "+
|
||||||
|
"ohne Befunde."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestLogger.Error("der bericht konnte nicht erzeugt werden",
|
||||||
|
slog.String("art", string(reportType)),
|
||||||
|
slog.String("grund", generateError.Error()))
|
||||||
|
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(generateError))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erst vollstaendig in den Puffer, dann ausliefern. Schriebe der Erzeuger
|
||||||
|
// direkt in die Antwort, stuenden bei einem Fehler auf halber Strecke schon
|
||||||
|
// Kopfzeilen und ein halber Bericht beim Empfaenger — eine abgeschnittene
|
||||||
|
// CSV-Datei sieht aus wie eine vollstaendige mit weniger Zeilen.
|
||||||
|
outputBuffer := &bytes.Buffer{}
|
||||||
|
|
||||||
|
if writeError := reports.Write(outputBuffer, generatedReport, outputFormat); writeError != nil {
|
||||||
|
requestLogger.Error("der bericht konnte nicht ausgegeben werden",
|
||||||
|
slog.String("format", string(outputFormat)),
|
||||||
|
slog.String("grund", writeError.Error()))
|
||||||
|
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(writeError))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isAuthenticated {
|
||||||
|
handler.recordGeneration(request, currentUser, generatedReport, outputFormat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON geht durch die uebliche Antworthuelle, damit der Frontend-Client
|
||||||
|
// nicht zwei Arten von Antworten unterscheiden muss. CSV und PDF sind
|
||||||
|
// Dateien und werden roh ausgeliefert.
|
||||||
|
if outputFormat == reports.FormatJSON {
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, generatedReport)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := reports.SanitizeFileName(reports.FileName(generatedReport, outputFormat))
|
||||||
|
|
||||||
|
responseWriter.Header().Set("Content-Type", outputFormat.ContentType())
|
||||||
|
responseWriter.Header().Set("Content-Disposition", `attachment; filename="`+fileName+`"`)
|
||||||
|
responseWriter.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
if _, writeError := responseWriter.Write(outputBuffer.Bytes()); writeError != nil {
|
||||||
|
requestLogger.Warn("der bericht konnte nicht vollstaendig gesendet werden",
|
||||||
|
slog.String("grund", writeError.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordGeneration protokolliert die Ausgabe eines Berichts.
|
||||||
|
//
|
||||||
|
// Scheitert das Protokollieren, wird der Bericht trotzdem ausgeliefert und der
|
||||||
|
// Fehler vermerkt: Ein Leserecht wegen eines Protokollfehlers zu verweigern
|
||||||
|
// waere die falsche Abwaegung — anders als bei einer destruktiven Handlung.
|
||||||
|
func (handler *reportHandler) recordGeneration(request *http.Request, currentUser auth.User,
|
||||||
|
generatedReport *reports.Report, outputFormat reports.Format) {
|
||||||
|
if handler.auditRecorder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actingUserIdentifier := currentUser.ID
|
||||||
|
correlationID, _ := logging.CorrelationIDFromContext(request.Context())
|
||||||
|
|
||||||
|
auditEvent := audit.Event{
|
||||||
|
UserID: &actingUserIdentifier,
|
||||||
|
ActorUsername: currentUser.Username,
|
||||||
|
Action: audit.ActionReportGenerated,
|
||||||
|
EntityType: "report",
|
||||||
|
Result: audit.ResultSuccess,
|
||||||
|
IPAddress: clientIPAddress(request),
|
||||||
|
UserAgent: request.UserAgent(),
|
||||||
|
CorrelationID: correlationID,
|
||||||
|
Details: map[string]any{
|
||||||
|
"report_type": string(generatedReport.Type),
|
||||||
|
"format": string(outputFormat),
|
||||||
|
"period_from": generatedReport.PeriodFrom,
|
||||||
|
"period_to": generatedReport.PeriodTo,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if recordError := handler.auditRecorder.Record(request.Context(), auditEvent); recordError != nil {
|
||||||
|
logging.WithContext(request.Context(), handler.logger).Warn(
|
||||||
|
"die berichtsausgabe konnte nicht protokolliert werden",
|
||||||
|
slog.String("grund", recordError.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
481
apps/api/internal/httpapi/repository_handler.go
Normal file
481
apps/api/internal/httpapi/repository_handler.go
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/audit"
|
||||||
|
"github.com/syncova/syncova/packages/auth"
|
||||||
|
"github.com/syncova/syncova/packages/jobs"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
"github.com/syncova/syncova/packages/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// repositoryHandler bedient die Repository-Endpunkte (SYNCOVA_API.md §8).
|
||||||
|
//
|
||||||
|
// Bis Phase 22 gab es hier nur eine Liste: Repositories entstanden über
|
||||||
|
// `syncova-repo create` und wurden **von Hand in die Datenbank eingetragen**.
|
||||||
|
// Das fiel erst beim vollständigen Durchlauf auf — die Anlage ließ sich über
|
||||||
|
// ihre eigene API nicht in Betrieb nehmen.
|
||||||
|
type repositoryHandler struct {
|
||||||
|
// store ist die Datenzugriffsschicht.
|
||||||
|
store *jobs.PostgresStore
|
||||||
|
// auditRecorder protokolliert die verändernden Zugriffe.
|
||||||
|
auditRecorder audit.Recorder
|
||||||
|
// logger protokolliert technische Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerRepositoryRequest ist der Rumpf von POST /repositories.
|
||||||
|
type registerRepositoryRequest struct {
|
||||||
|
// Name ist die sprechende Bezeichnung.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Location ist der Pfad der Ablage.
|
||||||
|
Location string `json:"location"`
|
||||||
|
// RepositoryType benennt die Ablageart; leer bedeutet "local".
|
||||||
|
RepositoryType string `json:"repository_type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateRepositoryRequest ist der Rumpf von PATCH /repositories/{id}.
|
||||||
|
type updateRepositoryRequest struct {
|
||||||
|
// Status ist der neue Betriebszustand.
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// repositoryCheckResponse ist die Antwort der Prüfendpunkte.
|
||||||
|
type repositoryCheckResponse struct {
|
||||||
|
// RepositoryID ist das geprüfte Repository.
|
||||||
|
RepositoryID uuid.UUID `json:"repository_id"`
|
||||||
|
// Reachable meldet, ob das Repository geöffnet werden konnte.
|
||||||
|
Reachable bool `json:"reachable"`
|
||||||
|
// RepositoryUUID ist die im Repository hinterlegte Kennung.
|
||||||
|
RepositoryUUID string `json:"repository_uuid,omitempty"`
|
||||||
|
// Error ist der Grund eines Fehlschlags.
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
// Details tragen das Ergebnis der jeweiligen Prüfung.
|
||||||
|
Details map[string]any `json:"details,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRegisterRepository bedient POST /repositories.
|
||||||
|
//
|
||||||
|
// **Es wird nichts angelegt, sondern übernommen.** Ein Repository entsteht auf
|
||||||
|
// einem Datenträger — mit `syncova-repo create`, das Descriptor und
|
||||||
|
// Verzeichnisse schreibt und den gehärteten Modus setzt. Dieser Endpunkt öffnet
|
||||||
|
// das vorhandene Repository, liest seine Kennung aus dem Descriptor und trägt
|
||||||
|
// es ein.
|
||||||
|
//
|
||||||
|
// Ein Eintrag ohne Repository dahinter wäre ein Ziel, das erst um zwei Uhr
|
||||||
|
// nachts als nicht vorhanden auffällt.
|
||||||
|
func (handler *repositoryHandler) handleRegisterRepository(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
var registerPayload registerRepositoryRequest
|
||||||
|
if decodeError := decodeJSONBody(request, ®isterPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if registerPayload.Name == "" || registerPayload.Location == "" {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewValidationError("Name und Ort des Repositorys sind erforderlich."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repositoryType := registerPayload.RepositoryType
|
||||||
|
if repositoryType == "" {
|
||||||
|
repositoryType = "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erst nachsehen, dann eintragen. Der Descriptor sagt, ob dort überhaupt
|
||||||
|
// ein Repository liegt und welche Kennung es trägt.
|
||||||
|
inspectedRepository, inspectError := openRepositoryForInspection(request.Context(),
|
||||||
|
registerPayload.Location, handler.logger)
|
||||||
|
if inspectError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewValidationError(
|
||||||
|
"Unter "+registerPayload.Location+" liegt kein lesbares Repository: "+inspectError.Error()+
|
||||||
|
". Legen Sie es zuerst mit 'syncova-repo create' an."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repositoryDescriptor := inspectedRepository.Descriptor()
|
||||||
|
|
||||||
|
if closeError := inspectedRepository.Close(); closeError != nil {
|
||||||
|
requestLogger.Warn("das geprüfte repository liess sich nicht schliessen",
|
||||||
|
slog.String("grund", closeError.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
registeredRepository, registerError := handler.store.RegisterRepository(request.Context(),
|
||||||
|
jobs.RepositoryRegistration{
|
||||||
|
Name: registerPayload.Name,
|
||||||
|
RepositoryType: repositoryType,
|
||||||
|
Location: registerPayload.Location,
|
||||||
|
Status: jobs.RepositoryStatusActive,
|
||||||
|
Hardened: repositoryDescriptor.Immutable,
|
||||||
|
RepositoryUUID: repositoryDescriptor.RepositoryID,
|
||||||
|
})
|
||||||
|
|
||||||
|
if errors.Is(registerError, jobs.ErrRepositoryNameTaken) ||
|
||||||
|
errors.Is(registerError, jobs.ErrRepositoryLocationTaken) {
|
||||||
|
WriteError(responseWriter, request, requestLogger, &APIError{
|
||||||
|
StatusCode: http.StatusConflict,
|
||||||
|
Code: ErrorCodeConflict,
|
||||||
|
Message: registerError.Error(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if registerError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(registerError))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionRepositoryRegistered, registeredRepository.ID,
|
||||||
|
map[string]any{
|
||||||
|
"name": registeredRepository.Name,
|
||||||
|
"location": registeredRepository.Location,
|
||||||
|
"hardened": registeredRepository.Hardened,
|
||||||
|
})
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusCreated, buildRepositoryResponse(*registeredRepository))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetRepository bedient GET /repositories/{id}.
|
||||||
|
func (handler *repositoryHandler) handleGetRepository(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
repositoryIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError("Die Kennung des Repositorys ist ungültig."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
foundRepository, readError := handler.store.GetRepository(request.Context(), repositoryIdentifier)
|
||||||
|
if errors.Is(readError, jobs.ErrRepositoryNotFound) {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Repository wurde nicht gefunden."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(readError))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, buildRepositoryResponse(*foundRepository))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateRepository bedient PATCH /repositories/{id}.
|
||||||
|
//
|
||||||
|
// Änderbar ist ausschließlich der Betriebszustand. Ort und Name gehören zum
|
||||||
|
// Repository selbst; sie hier zu ändern hieße, den Eintrag von der Ablage zu
|
||||||
|
// lösen, auf die er zeigt.
|
||||||
|
func (handler *repositoryHandler) handleUpdateRepository(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
repositoryIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError("Die Kennung des Repositorys ist ungültig."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatePayload updateRepositoryRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &updatePayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newStatus := jobs.RepositoryStatus(updatePayload.Status)
|
||||||
|
|
||||||
|
if !isKnownRepositoryStatus(newStatus) {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewValidationError(
|
||||||
|
"Zulässige Zustände sind active, read_only, unavailable und maintenance."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedRepository, updateError := handler.store.UpdateRepositoryStatus(request.Context(),
|
||||||
|
repositoryIdentifier, newStatus)
|
||||||
|
if errors.Is(updateError, jobs.ErrRepositoryNotFound) {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Repository wurde nicht gefunden."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if updateError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(updateError))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Ziel aus dem Betrieb zu nehmen, hält Sicherungen an. Das gehört ins
|
||||||
|
// Protokoll: Sonst sucht später jemand den Grund für ausbleibende Backups.
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionRepositoryStatusChanged, updatedRepository.ID,
|
||||||
|
map[string]any{"status": string(newStatus)})
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, buildRepositoryResponse(*updatedRepository))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTestRepository bedient POST /repositories/{id}/test.
|
||||||
|
//
|
||||||
|
// Die schnelle Prüfung: Lässt sich das Repository öffnen und ist es dasselbe
|
||||||
|
// wie beim Eintragen? Ein Ziel, dessen Kennung sich geändert hat, ist ein
|
||||||
|
// anderes Repository am selben Pfad — und die dort vermerkten Backups sind
|
||||||
|
// nicht die, die man sucht.
|
||||||
|
func (handler *repositoryHandler) handleTestRepository(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
handler.runRepositoryCheck(responseWriter, request, func(checkContext context.Context,
|
||||||
|
openedRepository *repository.LocalRepository, storedRecord *jobs.Repository) (map[string]any, error) {
|
||||||
|
repositoryDescriptor := openedRepository.Descriptor()
|
||||||
|
|
||||||
|
checkDetails := map[string]any{
|
||||||
|
"repository_uuid": repositoryDescriptor.RepositoryID,
|
||||||
|
"format_version": repositoryDescriptor.FormatVersion,
|
||||||
|
"hardened": repositoryDescriptor.Immutable,
|
||||||
|
}
|
||||||
|
|
||||||
|
if storedRecord.RepositoryUUID != "" && storedRecord.RepositoryUUID != repositoryDescriptor.RepositoryID {
|
||||||
|
checkDetails["identity_mismatch"] = true
|
||||||
|
|
||||||
|
return checkDetails, errors.New("das repository unter diesem pfad ist nicht mehr dasselbe " +
|
||||||
|
"(erwartet " + storedRecord.RepositoryUUID + ", vorgefunden " + repositoryDescriptor.RepositoryID + ")")
|
||||||
|
}
|
||||||
|
|
||||||
|
return checkDetails, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRepositoryHealth bedient POST /repositories/{id}/health-check.
|
||||||
|
func (handler *repositoryHandler) handleRepositoryHealth(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
handler.runRepositoryCheck(responseWriter, request, func(checkContext context.Context,
|
||||||
|
openedRepository *repository.LocalRepository, _ *jobs.Repository) (map[string]any, error) {
|
||||||
|
healthReport, healthError := openedRepository.Health(checkContext)
|
||||||
|
if healthError != nil {
|
||||||
|
return nil, healthError
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]any{
|
||||||
|
"status": string(healthReport.Status),
|
||||||
|
"message": healthReport.Message,
|
||||||
|
"recommended_action": healthReport.RecommendedAction,
|
||||||
|
"backup_count": healthReport.BackupCount,
|
||||||
|
"capacity_bytes": healthReport.CapacityBytes,
|
||||||
|
"used_bytes": healthReport.UsedBytes,
|
||||||
|
"free_bytes": healthReport.FreeBytes,
|
||||||
|
"used_percentage": healthReport.UsedPercentage(),
|
||||||
|
"latency_ms": healthReport.LatencyMilliseconds,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRepositoryIntegrityScan bedient POST /repositories/{id}/integrity-scan.
|
||||||
|
//
|
||||||
|
// Der vollständige Lauf liest jeden Block und prüft ihn gegen seine Prüfsumme.
|
||||||
|
// Er läuft synchron: Ein Endpunkt, der sofort „gestartet" meldet und das
|
||||||
|
// Ergebnis nirgends hinterlegt, wäre ein Prüfwerkzeug ohne Prüfergebnis.
|
||||||
|
func (handler *repositoryHandler) handleRepositoryIntegrityScan(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
deepScan := request.URL.Query().Get("deep") != "false"
|
||||||
|
|
||||||
|
handler.runRepositoryCheck(responseWriter, request, func(checkContext context.Context,
|
||||||
|
openedRepository *repository.LocalRepository, _ *jobs.Repository) (map[string]any, error) {
|
||||||
|
scanReport, scanError := openedRepository.Scan(checkContext, repository.ScanOptions{
|
||||||
|
VerifyChunkContents: deepScan,
|
||||||
|
})
|
||||||
|
if scanError != nil {
|
||||||
|
return nil, scanError
|
||||||
|
}
|
||||||
|
|
||||||
|
scanDetails := map[string]any{
|
||||||
|
"verified_chunk_contents": scanReport.VerifiedChunkContents,
|
||||||
|
"backups_checked": scanReport.BackupsChecked,
|
||||||
|
"backups_healthy": scanReport.BackupsHealthy,
|
||||||
|
"chunks_checked": scanReport.ChunksChecked,
|
||||||
|
"missing_chunks": scanReport.MissingChunks,
|
||||||
|
"corrupted_chunks": scanReport.CorruptedChunks,
|
||||||
|
"orphaned_chunks": scanReport.OrphanedChunks,
|
||||||
|
"healthy": scanReport.IsHealthy(),
|
||||||
|
"summary": scanReport.Summary(),
|
||||||
|
"affected_backup_ids": scanReport.AffectedBackupIDs,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Befund ist **kein** Fehler des Endpunkts: Die Prüfung ist
|
||||||
|
// ordnungsgemäß gelaufen und hat ein Ergebnis. Sie als Fehler zu melden
|
||||||
|
// verwechselte „die Prüfung schlug fehl" mit „das Repository ist
|
||||||
|
// beschädigt" — zwei völlig verschiedene Lagen.
|
||||||
|
return scanDetails, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRebuildCatalog bedient POST /repositories/{id}/rebuild-catalog.
|
||||||
|
//
|
||||||
|
// Der Katalog ist nur ein Beschleuniger; verbindlich sind die Manifeste. Genau
|
||||||
|
// deshalb lässt er sich jederzeit neu bauen — und genau deshalb ist der
|
||||||
|
// Wiederaufbau harmlos.
|
||||||
|
func (handler *repositoryHandler) handleRebuildCatalog(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
handler.runRepositoryCheck(responseWriter, request, func(checkContext context.Context,
|
||||||
|
openedRepository *repository.LocalRepository, _ *jobs.Repository) (map[string]any, error) {
|
||||||
|
rebuiltCatalog, rebuildError := openedRepository.RebuildCatalog(checkContext)
|
||||||
|
if rebuildError != nil {
|
||||||
|
return nil, rebuildError
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]any{"backups_in_catalog": len(rebuiltCatalog.Entries)}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// repositoryCheckFunction führt eine Prüfung auf einem geöffneten Repository aus.
|
||||||
|
type repositoryCheckFunction func(checkContext context.Context,
|
||||||
|
openedRepository *repository.LocalRepository, storedRecord *jobs.Repository) (map[string]any, error)
|
||||||
|
|
||||||
|
// runRepositoryCheck öffnet das Repository und führt eine Prüfung aus.
|
||||||
|
//
|
||||||
|
// Der gemeinsame Rahmen aller vier Prüfendpunkte: Kennung lesen, Datensatz
|
||||||
|
// holen, Repository öffnen, prüfen, schließen. Ohne ihn stünde derselbe Ablauf
|
||||||
|
// viermal da, und beim vierten vergisst jemand das Schließen.
|
||||||
|
func (handler *repositoryHandler) runRepositoryCheck(responseWriter http.ResponseWriter,
|
||||||
|
request *http.Request, checkFunction repositoryCheckFunction) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
repositoryIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError("Die Kennung des Repositorys ist ungültig."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storedRecord, readError := handler.store.GetRepository(request.Context(), repositoryIdentifier)
|
||||||
|
if errors.Is(readError, jobs.ErrRepositoryNotFound) {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Repository wurde nicht gefunden."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(readError))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openedRepository, openError := openRepositoryForInspection(request.Context(),
|
||||||
|
storedRecord.Location, handler.logger)
|
||||||
|
if openError != nil {
|
||||||
|
// Ein nicht erreichbares Repository ist eine Auskunft über die Anlage,
|
||||||
|
// kein Serverfehler. Die Antwort trägt sie in der Hülle, damit die
|
||||||
|
// Oberfläche sie anzeigen kann.
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, repositoryCheckResponse{
|
||||||
|
RepositoryID: repositoryIdentifier,
|
||||||
|
Reachable: false,
|
||||||
|
Error: openError.Error(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if closeError := openedRepository.Close(); closeError != nil {
|
||||||
|
requestLogger.Warn("das repository liess sich nicht schliessen",
|
||||||
|
slog.String("grund", closeError.Error()))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
checkDetails, checkError := checkFunction(request.Context(), openedRepository, storedRecord)
|
||||||
|
|
||||||
|
checkResponse := repositoryCheckResponse{
|
||||||
|
RepositoryID: repositoryIdentifier,
|
||||||
|
Reachable: checkError == nil,
|
||||||
|
RepositoryUUID: openedRepository.Descriptor().RepositoryID,
|
||||||
|
Details: checkDetails,
|
||||||
|
}
|
||||||
|
|
||||||
|
if checkError != nil {
|
||||||
|
checkResponse.Error = checkError.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, checkResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openRepositoryForInspection öffnet ein Repository schreibgeschützt.
|
||||||
|
//
|
||||||
|
// Schreibgeschützt, weil jede Prüfung nur liest — und weil ein Prüfaufruf sonst
|
||||||
|
// die Schreibsperre einer laufenden Sicherung bräuchte und daran wartete
|
||||||
|
// (dieselbe Entscheidung wie bei der Wiederherstellung in Phase 9).
|
||||||
|
func openRepositoryForInspection(openContext context.Context, repositoryLocation string,
|
||||||
|
baseLogger *slog.Logger) (*repository.LocalRepository, error) {
|
||||||
|
inspectionContext, cancelInspection := context.WithTimeout(openContext, 30*time.Second)
|
||||||
|
defer cancelInspection()
|
||||||
|
|
||||||
|
return repository.Open(inspectionContext, repositoryLocation,
|
||||||
|
repository.OpenOptions{ReadOnly: true}, baseLogger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isKnownRepositoryStatus prüft einen Zustandswert.
|
||||||
|
func isKnownRepositoryStatus(candidateStatus jobs.RepositoryStatus) bool {
|
||||||
|
switch candidateStatus {
|
||||||
|
case jobs.RepositoryStatusActive, jobs.RepositoryStatusReadOnly,
|
||||||
|
jobs.RepositoryStatusUnavailable, jobs.RepositoryStatusMaintenance:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRepositoryResponse formt die Antwort eines Repositorys.
|
||||||
|
func buildRepositoryResponse(storedRepository jobs.Repository) repositoryResponse {
|
||||||
|
return repositoryResponse{
|
||||||
|
ID: storedRepository.ID,
|
||||||
|
Name: storedRepository.Name,
|
||||||
|
RepositoryType: storedRepository.RepositoryType,
|
||||||
|
Location: storedRepository.Location,
|
||||||
|
Status: string(storedRepository.Status),
|
||||||
|
AcceptsBackups: storedRepository.Status.AcceptsWrites(),
|
||||||
|
Hardened: storedRepository.Hardened,
|
||||||
|
CreatedAt: storedRepository.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordAudit schreibt einen Eintrag ins Auditprotokoll.
|
||||||
|
func (handler *repositoryHandler) recordAudit(request *http.Request, actingUser auth.User,
|
||||||
|
auditAction audit.Action, entityIdentifier uuid.UUID, auditDetails map[string]any) {
|
||||||
|
if handler.auditRecorder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
correlationIdentifier, _ := logging.CorrelationIDFromContext(request.Context())
|
||||||
|
|
||||||
|
recordError := handler.auditRecorder.Record(request.Context(), audit.Event{
|
||||||
|
UserID: &actingUser.ID,
|
||||||
|
ActorUsername: actingUser.Username,
|
||||||
|
Action: auditAction,
|
||||||
|
EntityType: "repository",
|
||||||
|
EntityID: &entityIdentifier,
|
||||||
|
Result: audit.ResultSuccess,
|
||||||
|
IPAddress: clientIPAddress(request),
|
||||||
|
UserAgent: request.UserAgent(),
|
||||||
|
CorrelationID: correlationIdentifier,
|
||||||
|
Details: auditDetails,
|
||||||
|
})
|
||||||
|
|
||||||
|
if recordError != nil {
|
||||||
|
logging.WithContext(request.Context(), handler.logger).Error(
|
||||||
|
"der audit-eintrag liess sich nicht schreiben",
|
||||||
|
slog.String("aktion", string(auditAction)),
|
||||||
|
slog.String("grund", recordError.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
147
apps/api/internal/httpapi/response.go
Normal file
147
apps/api/internal/httpapi/response.go
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SuccessResponse ist die einheitliche Hülle erfolgreicher Antworten
|
||||||
|
// (SYNCOVA_API.md §1).
|
||||||
|
type SuccessResponse struct {
|
||||||
|
// Data trägt die eigentliche Nutzlast.
|
||||||
|
Data any `json:"data"`
|
||||||
|
// Meta trägt Kontextinformationen wie Request-ID und Pagination.
|
||||||
|
Meta ResponseMeta `json:"meta"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponseMeta beschreibt den Kontext einer Antwort.
|
||||||
|
type ResponseMeta struct {
|
||||||
|
// RequestID identifiziert diesen Request eindeutig und taucht auch im Log auf.
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
// Page ist die aktuelle Seitennummer; nur bei paginierten Listen gesetzt.
|
||||||
|
Page *int `json:"page,omitempty"`
|
||||||
|
// PageSize ist die Seitengröße; nur bei paginierten Listen gesetzt.
|
||||||
|
PageSize *int `json:"page_size,omitempty"`
|
||||||
|
// Total ist die Gesamtzahl verfügbarer Einträge; nur bei paginierten Listen gesetzt.
|
||||||
|
Total *int64 `json:"total,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorResponse ist die einheitliche Hülle fehlerhafter Antworten
|
||||||
|
// (SYNCOVA_API.md §1).
|
||||||
|
type ErrorResponse struct {
|
||||||
|
// Error beschreibt den aufgetretenen Fehler.
|
||||||
|
Error ErrorBody `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorBody ist der Fehlerkörper einer Antwort.
|
||||||
|
type ErrorBody struct {
|
||||||
|
// Code ist der stabile maschinenlesbare Fehlercode.
|
||||||
|
Code ErrorCode `json:"code"`
|
||||||
|
// Message erklärt den Fehler verständlich (PROMPT.md §124).
|
||||||
|
Message string `json:"message"`
|
||||||
|
// Details trägt optionale unbedenkliche Zusatzinformationen.
|
||||||
|
Details map[string]any `json:"details,omitempty"`
|
||||||
|
// RequestID verknüpft die Fehlermeldung mit dem Serverlog.
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PaginationMeta beschreibt die Seiteninformationen einer Liste.
|
||||||
|
type PaginationMeta struct {
|
||||||
|
// Page ist die aktuelle Seitennummer, beginnend bei 1.
|
||||||
|
Page int
|
||||||
|
// PageSize ist die Anzahl der Einträge pro Seite.
|
||||||
|
PageSize int
|
||||||
|
// Total ist die Gesamtzahl verfügbarer Einträge.
|
||||||
|
Total int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteSuccess schreibt eine erfolgreiche Antwort in der Standard-Hülle.
|
||||||
|
func WriteSuccess(responseWriter http.ResponseWriter, request *http.Request, statusCode int, payloadData any) {
|
||||||
|
writeJSON(responseWriter, request, statusCode, SuccessResponse{
|
||||||
|
Data: payloadData,
|
||||||
|
Meta: ResponseMeta{RequestID: RequestIDFromContext(request.Context())},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePaginatedSuccess schreibt eine paginierte Liste in der Standard-Hülle
|
||||||
|
// (SYNCOVA_API.md §28).
|
||||||
|
func WritePaginatedSuccess(responseWriter http.ResponseWriter, request *http.Request, payloadData any, pagination PaginationMeta) {
|
||||||
|
// Die Werte werden als Zeiger übergeben, damit sie bei nicht paginierten
|
||||||
|
// Antworten vollständig aus dem JSON verschwinden.
|
||||||
|
currentPage := pagination.Page
|
||||||
|
currentPageSize := pagination.PageSize
|
||||||
|
totalEntries := pagination.Total
|
||||||
|
|
||||||
|
writeJSON(responseWriter, request, http.StatusOK, SuccessResponse{
|
||||||
|
Data: payloadData,
|
||||||
|
Meta: ResponseMeta{
|
||||||
|
RequestID: RequestIDFromContext(request.Context()),
|
||||||
|
Page: ¤tPage,
|
||||||
|
PageSize: ¤tPageSize,
|
||||||
|
Total: &totalEntries,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteError schreibt eine Fehlerantwort und protokolliert die interne Ursache.
|
||||||
|
//
|
||||||
|
// Der Aufrufer erhält ausschließlich die freigegebene Darstellung; die Ursache
|
||||||
|
// bleibt im Log (SYNCOVA_API.md §26).
|
||||||
|
func WriteError(responseWriter http.ResponseWriter, request *http.Request, requestLogger *slog.Logger, apiError *APIError) {
|
||||||
|
requestID := RequestIDFromContext(request.Context())
|
||||||
|
|
||||||
|
// Serverfehler sind Betriebsprobleme, Client-Fehler nur Hinweise —
|
||||||
|
// die Log-Level unterscheiden sich deshalb bewusst.
|
||||||
|
logAttributes := []any{
|
||||||
|
slog.String(logging.FieldErrorCode, string(apiError.Code)),
|
||||||
|
slog.Int("status_code", apiError.StatusCode),
|
||||||
|
slog.String("error", apiError.Error()),
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiError.StatusCode >= http.StatusInternalServerError {
|
||||||
|
requestLogger.Error("request fehlgeschlagen", logAttributes...)
|
||||||
|
} else {
|
||||||
|
requestLogger.Warn("request abgelehnt", logAttributes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(responseWriter, request, apiError.StatusCode, ErrorResponse{
|
||||||
|
Error: ErrorBody{
|
||||||
|
Code: apiError.Code,
|
||||||
|
Message: apiError.Message,
|
||||||
|
Details: apiError.Details,
|
||||||
|
RequestID: requestID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSON serialisiert einen Antwortkörper und setzt die passenden Header.
|
||||||
|
func writeJSON(responseWriter http.ResponseWriter, request *http.Request, statusCode int, responseBody any) {
|
||||||
|
responseWriter.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
|
||||||
|
// Die Request-ID gehört auch in den Header, damit sie bei leerem Body
|
||||||
|
// (etwa 204) nicht verloren geht.
|
||||||
|
if requestID := RequestIDFromContext(request.Context()); requestID != "" {
|
||||||
|
responseWriter.Header().Set(headerRequestID, requestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 darf per HTTP-Spezifikation keinen Body besitzen.
|
||||||
|
if statusCode == http.StatusNoContent {
|
||||||
|
responseWriter.WriteHeader(statusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
encodedBody, encodeError := json.Marshal(responseBody)
|
||||||
|
if encodeError != nil {
|
||||||
|
// Der Body ließ sich nicht serialisieren. Ein halb geschriebener Body
|
||||||
|
// wäre schlimmer als eine klare, minimale Fehlerantwort.
|
||||||
|
responseWriter.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = responseWriter.Write([]byte(`{"error":{"code":"INTERNAL_ERROR","message":"Die Antwort konnte nicht erzeugt werden."}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responseWriter.WriteHeader(statusCode)
|
||||||
|
_, _ = responseWriter.Write(encodedBody)
|
||||||
|
}
|
||||||
648
apps/api/internal/httpapi/restore_handler.go
Normal file
648
apps/api/internal/httpapi/restore_handler.go
Normal file
@ -0,0 +1,648 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/syncova/syncova/packages/audit"
|
||||||
|
"github.com/syncova/syncova/packages/auth"
|
||||||
|
"github.com/syncova/syncova/packages/jobs"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
"github.com/syncova/syncova/packages/recovery"
|
||||||
|
"github.com/syncova/syncova/packages/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// restoreHandler bedient die Wiederherstellung (SYNCOVA_API.md §13).
|
||||||
|
type restoreHandler struct {
|
||||||
|
// restoreStore ist die Datenzugriffsschicht der Wiederherstellungen.
|
||||||
|
restoreStore *recovery.Store
|
||||||
|
// jobStore loest Backups auf ihr Repository auf.
|
||||||
|
jobStore *jobs.PostgresStore
|
||||||
|
// auditRecorder protokolliert Wiederherstellungen.
|
||||||
|
auditRecorder audit.Recorder
|
||||||
|
// targetGuard begrenzt, wohin geschrieben werden darf (Phase 19).
|
||||||
|
targetGuard *recovery.TargetGuard
|
||||||
|
// logger protokolliert technische Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreRequest ist der Rumpf von POST /restores und /restores/validate.
|
||||||
|
type restoreRequest struct {
|
||||||
|
// BackupID ist das wiederherzustellende Backup.
|
||||||
|
BackupID uuid.UUID `json:"backup_id"`
|
||||||
|
// TargetType benennt die Art des Ziels.
|
||||||
|
TargetType string `json:"target_type"`
|
||||||
|
// TargetPath ist das Zielverzeichnis.
|
||||||
|
TargetPath string `json:"target_path"`
|
||||||
|
// PathPrefix beschraenkt auf einen Teilbaum.
|
||||||
|
PathPrefix string `json:"path_prefix,omitempty"`
|
||||||
|
// OverwriteExisting erlaubt das Ueberschreiben vorhandener Daten.
|
||||||
|
OverwriteExisting bool `json:"overwrite_existing,omitempty"`
|
||||||
|
// ConfirmOverwrite ist die ausdrueckliche Bestaetigung des Ueberschreibens.
|
||||||
|
//
|
||||||
|
// Ein zweites Feld neben OverwriteExisting ist keine Umstaendlichkeit: Ein
|
||||||
|
// versehentlich gesetztes Kennzeichen in einem Skript oder einer Vorlage
|
||||||
|
// reicht damit nicht aus, um Daten zu vernichten. Der Wert muss den
|
||||||
|
// Zielpfad wiederholen — wer ihn abtippt, hat ihn gelesen.
|
||||||
|
ConfirmOverwrite string `json:"confirm_overwrite,omitempty"`
|
||||||
|
// SkipPermissions verzichtet auf das Setzen der urspruenglichen Rechte.
|
||||||
|
SkipPermissions bool `json:"skip_permissions,omitempty"`
|
||||||
|
// SkipDeepCheck ueberspringt die Blockpruefung.
|
||||||
|
//
|
||||||
|
// Sie ist der eigentliche Nachweis der Wiederherstellbarkeit und kostet bei
|
||||||
|
// grossen Backups Zeit. Wer sie ueberspringt, bekommt das im Bericht gesagt.
|
||||||
|
SkipDeepCheck bool `json:"skip_deep_check,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreResponse ist die Darstellung eines Wiederherstellungsauftrags.
|
||||||
|
type restoreResponse struct {
|
||||||
|
// ID ist der oeffentliche Bezeichner.
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
// BackupID ist das wiederherzustellende Backup.
|
||||||
|
BackupID uuid.UUID `json:"backup_id"`
|
||||||
|
// TargetType benennt die Art des Ziels.
|
||||||
|
TargetType string `json:"target_type"`
|
||||||
|
// TargetPath ist das Zielverzeichnis.
|
||||||
|
TargetPath string `json:"target_path"`
|
||||||
|
// PathPrefix beschraenkt auf einen Teilbaum.
|
||||||
|
PathPrefix string `json:"path_prefix,omitempty"`
|
||||||
|
// Status ist der Zustand.
|
||||||
|
Status string `json:"status"`
|
||||||
|
// OverwriteExisting meldet das Ueberschreiben vorhandener Daten.
|
||||||
|
OverwriteExisting bool `json:"overwrite_existing"`
|
||||||
|
// StartedAt ist der Beginn in UTC.
|
||||||
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||||
|
// CompletedAt ist das Ende in UTC.
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
// DurationSeconds ist die Dauer in Sekunden.
|
||||||
|
DurationSeconds float64 `json:"duration_seconds,omitempty"`
|
||||||
|
// BytesRestored ist die zurueckgeschriebene Datenmenge.
|
||||||
|
BytesRestored int64 `json:"bytes_restored"`
|
||||||
|
// FilesRestored ist die Zahl zurueckgeschriebener Objekte.
|
||||||
|
FilesRestored int64 `json:"files_restored"`
|
||||||
|
// FilesSkipped ist die Zahl uebergangener Objekte.
|
||||||
|
FilesSkipped int64 `json:"files_skipped"`
|
||||||
|
// ErrorCode ist die Fehlerkennung.
|
||||||
|
ErrorCode string `json:"error_code,omitempty"`
|
||||||
|
// ErrorMessage ist die verstaendliche Fehlermeldung.
|
||||||
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
|
// ValidationReport ist das Ergebnis der Vorabpruefung.
|
||||||
|
ValidationReport *recovery.ValidationReport `json:"validation_report,omitempty"`
|
||||||
|
// Checkpoint ist der Fortschritt der offenen Sitzung.
|
||||||
|
//
|
||||||
|
// Er erscheint nur bei einem unterbrochenen Auftrag: Dort ist er die
|
||||||
|
// Auskunft, wie weit die Wiederherstellung gekommen ist.
|
||||||
|
Checkpoint *recovery.Checkpoint `json:"checkpoint,omitempty"`
|
||||||
|
// CorrelationID verbindet den Auftrag mit seinen Protokollzeilen.
|
||||||
|
CorrelationID uuid.UUID `json:"correlation_id"`
|
||||||
|
// CreatedAt ist der Anlagezeitpunkt in UTC.
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// validationResponse ist die Antwort auf POST /restores/validate.
|
||||||
|
type validationResponse struct {
|
||||||
|
// CanProceed meldet, ob die Wiederherstellung beginnen darf.
|
||||||
|
CanProceed bool `json:"can_proceed"`
|
||||||
|
// RequiresOverwriteConfirmation meldet, dass Daten ueberschrieben wuerden.
|
||||||
|
RequiresOverwriteConfirmation bool `json:"requires_overwrite_confirmation"`
|
||||||
|
// Summary fasst das Ergebnis in einem Satz zusammen.
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
// Report ist der vollstaendige Pruefbericht.
|
||||||
|
Report *recovery.ValidationReport `json:"report"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleValidateRestore bedient POST /restores/validate.
|
||||||
|
//
|
||||||
|
// Der Endpunkt schreibt **nichts**. Er ist damit gefahrlos und laesst sich
|
||||||
|
// jederzeit aufrufen — auch als regelmaessiger Nachweis, dass die Backups
|
||||||
|
// weiterhin wiederherstellbar sind, lange bevor jemand sie braucht.
|
||||||
|
func (handler *restoreHandler) handleValidateRestore(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
var restorePayload restoreRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &restorePayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
validationReport, apiError := handler.runValidation(request.Context(), restorePayload)
|
||||||
|
if apiError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, apiError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, validationResponse{
|
||||||
|
CanProceed: validationReport.CanProceed(),
|
||||||
|
RequiresOverwriteConfirmation: needsOverwriteConfirmation(validationReport),
|
||||||
|
Summary: validationReport.Summary(),
|
||||||
|
Report: validationReport,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// needsOverwriteConfirmation meldet, ob am Ziel Daten ueberschrieben wuerden.
|
||||||
|
func needsOverwriteConfirmation(validationReport *recovery.ValidationReport) bool {
|
||||||
|
for _, finding := range validationReport.Findings {
|
||||||
|
if finding.Code == "TARGET_NOT_EMPTY" || finding.Code == "TARGET_WILL_BE_OVERWRITTEN" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// runValidation fuehrt die Vorabpruefung aus.
|
||||||
|
func (handler *restoreHandler) runValidation(validationContext context.Context, restorePayload restoreRequest) (*recovery.ValidationReport, *APIError) {
|
||||||
|
// Das Ziel wird geprueft, **bevor** irgendetwas anderes geschieht: Wer nach
|
||||||
|
// /etc schreiben darf, braucht keine Luecke mehr. Die Meldung kommt schon
|
||||||
|
// aus der Vorabpruefung, damit der Betreiber sie sieht, bevor er eine
|
||||||
|
// Wiederherstellung anlegt.
|
||||||
|
if handler.targetGuard != nil {
|
||||||
|
if guardError := handler.targetGuard.Validate(restorePayload.TargetPath); guardError != nil {
|
||||||
|
return nil, NewValidationError(guardError.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositoryPath, backupIDInRepository, resolveError := handler.resolveBackup(validationContext, restorePayload.BackupID)
|
||||||
|
if resolveError != nil {
|
||||||
|
return nil, resolveError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schreibgeschuetzt: Eine Pruefung darf nichts anfassen und soll neben einer
|
||||||
|
// laufenden Sicherung stattfinden koennen.
|
||||||
|
openedRepository, openError := repository.Open(validationContext, repositoryPath,
|
||||||
|
repository.OpenOptions{ReadOnly: true}, handler.logger)
|
||||||
|
if openError != nil {
|
||||||
|
return nil, NewServiceUnavailableError(
|
||||||
|
"Das Repository des Backups ist derzeit nicht erreichbar.")
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = openedRepository.Close() }()
|
||||||
|
|
||||||
|
validator := recovery.NewValidator(openedRepository)
|
||||||
|
|
||||||
|
validationReport, validationError := validator.Validate(validationContext, recovery.ValidationRequest{
|
||||||
|
BackupID: backupIDInRepository,
|
||||||
|
TargetPath: restorePayload.TargetPath,
|
||||||
|
PathPrefix: restorePayload.PathPrefix,
|
||||||
|
OverwriteExisting: restorePayload.OverwriteExisting,
|
||||||
|
DeepChunkCheck: !restorePayload.SkipDeepCheck,
|
||||||
|
})
|
||||||
|
if validationError != nil {
|
||||||
|
return nil, NewInternalError(validationError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return validationReport, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveBackup loest ein Backup auf Repository und Kennung auf.
|
||||||
|
func (handler *restoreHandler) resolveBackup(resolveContext context.Context, backupIdentifier uuid.UUID) (string, string, *APIError) {
|
||||||
|
backupRecord, readError := handler.jobStore.GetBackup(resolveContext, backupIdentifier)
|
||||||
|
if readError != nil {
|
||||||
|
if errors.Is(readError, jobs.ErrBackupNotFound) {
|
||||||
|
return "", "", NewNotFoundError("Das Backup wurde nicht gefunden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", "", NewInternalError(readError)
|
||||||
|
}
|
||||||
|
|
||||||
|
repositoryRecord, repositoryError := handler.jobStore.GetRepository(resolveContext, backupRecord.RepositoryID)
|
||||||
|
if repositoryError != nil {
|
||||||
|
return "", "", NewInternalError(repositoryError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return repositoryRecord.Location, backupRecord.BackupIDInRepository, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateRestore bedient POST /restores.
|
||||||
|
//
|
||||||
|
// Der Auftrag wird eingereiht, nicht ausgefuehrt: Die Wiederherstellungsschleife
|
||||||
|
// holt ihn im naechsten Durchgang. Deshalb 202 statt 201 — die Daten sind noch
|
||||||
|
// nicht zurueck.
|
||||||
|
func (handler *restoreHandler) handleCreateRestore(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
var restorePayload restoreRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &restorePayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if restorePayload.TargetPath == "" {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewValidationError("Es wurde kein Zielverzeichnis angegeben."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetType := recovery.TargetType(restorePayload.TargetType)
|
||||||
|
if targetType == "" {
|
||||||
|
targetType = recovery.TargetFilesystem
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetType != recovery.TargetFilesystem && targetType != recovery.TargetOriginalLocation {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewValidationError("Die Zielart ist unbekannt. Zulaessig sind filesystem und original_location."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Pruefung laeuft **vor** dem Anlegen. Ein Auftrag, der von vornherein
|
||||||
|
// nicht gelingen kann, soll gar nicht erst in der Warteschlange stehen.
|
||||||
|
validationReport, validationAPIError := handler.runValidation(request.Context(), restorePayload)
|
||||||
|
if validationAPIError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, validationAPIError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiError := handler.checkOverwriteConfirmation(request, restorePayload, validationReport, actingUser); apiError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, apiError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validationReport.CanProceed() {
|
||||||
|
blockingFindings := validationReport.BlockingFindings()
|
||||||
|
|
||||||
|
validationError := NewValidationError(blockingFindings[0].Message)
|
||||||
|
validationError.Details = map[string]any{
|
||||||
|
"findings": blockingFindings,
|
||||||
|
"summary": validationReport.Summary(),
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteError(responseWriter, request, requestLogger, validationError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
backupRecord, _ := handler.jobStore.GetBackup(request.Context(), restorePayload.BackupID)
|
||||||
|
|
||||||
|
createdRestore, createError := handler.restoreStore.CreateRestore(request.Context(), recovery.CreateRequest{
|
||||||
|
BackupID: restorePayload.BackupID,
|
||||||
|
SourceType: "filesystem",
|
||||||
|
TargetType: targetType,
|
||||||
|
TargetRef: restorePayload.TargetPath,
|
||||||
|
PathPrefix: restorePayload.PathPrefix,
|
||||||
|
OverwriteExisting: restorePayload.OverwriteExisting,
|
||||||
|
RestorePermissions: !restorePayload.SkipPermissions,
|
||||||
|
VerifyContent: true,
|
||||||
|
ValidationReport: validationReport,
|
||||||
|
CreatedBy: &actingUser.ID,
|
||||||
|
})
|
||||||
|
if createError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateRestoreError(createError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jede Wiederherstellung wird auditiert — auch die harmlose. Sie holt Daten
|
||||||
|
// zurueck, die jemand einmal fuer schuetzenswert hielt.
|
||||||
|
auditDetails := map[string]any{
|
||||||
|
"target_path": restorePayload.TargetPath,
|
||||||
|
"target_type": string(targetType),
|
||||||
|
"overwrite": restorePayload.OverwriteExisting,
|
||||||
|
"path_prefix": restorePayload.PathPrefix,
|
||||||
|
"restore_id": createdRestore.ID.String(),
|
||||||
|
"file_count": validationReport.FileCount,
|
||||||
|
"total_bytes": validationReport.TotalBytes,
|
||||||
|
"deep_checked": !restorePayload.SkipDeepCheck,
|
||||||
|
}
|
||||||
|
|
||||||
|
if backupRecord != nil {
|
||||||
|
auditDetails["backup_in_repository"] = backupRecord.BackupIDInRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
auditAction := audit.ActionRestoreRequested
|
||||||
|
if restorePayload.OverwriteExisting {
|
||||||
|
auditAction = audit.ActionRestoreOverwriteRequested
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, auditAction, restorePayload.BackupID, auditDetails)
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusAccepted, buildRestoreResponse(createdRestore, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkOverwriteConfirmation prueft die Bestaetigung eines Ueberschreibens.
|
||||||
|
//
|
||||||
|
// Zwei Huerden statt einer: die Berechtigung restores.overwrite und die
|
||||||
|
// woertliche Wiederholung des Zielpfades. Ein versehentlich gesetztes
|
||||||
|
// Kennzeichen in einem Skript reicht damit nicht aus, um Daten zu vernichten —
|
||||||
|
// wer den Pfad abtippt, hat ihn gelesen.
|
||||||
|
func (handler *restoreHandler) checkOverwriteConfirmation(request *http.Request, restorePayload restoreRequest, validationReport *recovery.ValidationReport, actingUser auth.User) *APIError {
|
||||||
|
if !restorePayload.OverwriteExisting {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !userHasPermission(actingUser, "restores.overwrite") {
|
||||||
|
permissionError := NewValidationError(
|
||||||
|
"Fuer das Ueberschreiben vorhandener Daten fehlt die Berechtigung restores.overwrite.")
|
||||||
|
permissionError.Code = ErrorCodePermissionDenied
|
||||||
|
permissionError.StatusCode = http.StatusForbidden
|
||||||
|
|
||||||
|
return permissionError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ist am Ziel nichts zu ueberschreiben, braucht es keine Bestaetigung: Der
|
||||||
|
// Schalter laeuft dann ins Leere.
|
||||||
|
if !needsOverwriteConfirmation(validationReport) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if restorePayload.ConfirmOverwrite != restorePayload.TargetPath {
|
||||||
|
confirmationError := NewValidationError(
|
||||||
|
"Diese Wiederherstellung ueberschreibt vorhandene Daten. Wiederholen Sie zur Bestaetigung " +
|
||||||
|
"den Zielpfad im Feld confirm_overwrite.")
|
||||||
|
confirmationError.Details = map[string]any{
|
||||||
|
"target_path": restorePayload.TargetPath,
|
||||||
|
"confirm_with": restorePayload.TargetPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
return confirmationError
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.WithContext(request.Context(), handler.logger).Warn(
|
||||||
|
"eine wiederherstellung ueberschreibt vorhandene daten",
|
||||||
|
slog.String("ziel", restorePayload.TargetPath),
|
||||||
|
slog.String("benutzer", actingUser.Username))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// userHasPermission prueft eine Berechtigung des angemeldeten Benutzers.
|
||||||
|
func userHasPermission(actingUser auth.User, permissionName string) bool {
|
||||||
|
for _, grantedPermission := range actingUser.Permissions {
|
||||||
|
if grantedPermission == permissionName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListRestores bedient GET /restores.
|
||||||
|
func (handler *restoreHandler) handleListRestores(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
requestedPage := parsePositiveInteger(request.URL.Query().Get("page"), 1)
|
||||||
|
requestedPageSize := parsePositiveInteger(request.URL.Query().Get("page_size"), 50)
|
||||||
|
|
||||||
|
loadedRestores, totalCount, listError := handler.restoreStore.ListRestores(
|
||||||
|
request.Context(), requestedPage, requestedPageSize)
|
||||||
|
if listError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreResponses := make([]restoreResponse, 0, len(loadedRestores))
|
||||||
|
for restoreIndex := range loadedRestores {
|
||||||
|
restoreResponses = append(restoreResponses, buildRestoreResponse(&loadedRestores[restoreIndex], nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
WritePaginatedSuccess(responseWriter, request, restoreResponses, PaginationMeta{
|
||||||
|
Page: requestedPage,
|
||||||
|
PageSize: requestedPageSize,
|
||||||
|
Total: int64(totalCount),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetRestore bedient GET /restores/{id}.
|
||||||
|
func (handler *restoreHandler) handleGetRestore(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
restoreIdentifier, parseError := parseRestoreIdentifier(request)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedRestore, readError := handler.restoreStore.GetRestore(request.Context(), restoreIdentifier)
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateRestoreError(readError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Pruefpunkt kommt mit, wenn eine Sitzung offen ist: Bei einem
|
||||||
|
// unterbrochenen Auftrag ist er die Auskunft, wie weit es gekommen ist.
|
||||||
|
activeSession, _ := handler.restoreStore.GetActiveSession(request.Context(), restoreIdentifier)
|
||||||
|
|
||||||
|
var checkpoint *recovery.Checkpoint
|
||||||
|
if activeSession != nil && activeSession.Checkpoint.LastCompletedPath != "" {
|
||||||
|
checkpoint = &activeSession.Checkpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, buildRestoreResponse(loadedRestore, checkpoint))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCancelRestore bedient POST /restores/{id}/cancel.
|
||||||
|
func (handler *restoreHandler) handleCancelRestore(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
restoreIdentifier, parseError := parseRestoreIdentifier(request)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existingRestore, readError := handler.restoreStore.GetRestore(request.Context(), restoreIdentifier)
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateRestoreError(readError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cancelError := handler.restoreStore.CancelRestore(request.Context(), restoreIdentifier,
|
||||||
|
"Die Wiederherstellung wurde von "+actingUser.Username+" abgebrochen."); cancelError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateRestoreError(cancelError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionRestoreCancelled, existingRestore.BackupID,
|
||||||
|
map[string]any{
|
||||||
|
"restore_id": restoreIdentifier.String(),
|
||||||
|
"target_path": existingRestore.TargetRef,
|
||||||
|
})
|
||||||
|
|
||||||
|
cancelledRestore, _ := handler.restoreStore.GetRestore(request.Context(), restoreIdentifier)
|
||||||
|
|
||||||
|
// Ein abgebrochener Lauf hinterlaesst einen Pruefpunkt. Das wird gesagt:
|
||||||
|
// Sonst haelt man das Ziel fuer unberuehrt, obwohl dort bereits Daten liegen.
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"restore": buildRestoreResponse(cancelledRestore, nil),
|
||||||
|
"message": "Der Abbruch wurde vermerkt. Bereits zurueckgeschriebene Daten bleiben am Ziel liegen; " +
|
||||||
|
"der Pruefpunkt erlaubt eine Fortsetzung.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResumeRestore bedient POST /restores/{id}/resume.
|
||||||
|
//
|
||||||
|
// Der Auftrag wird erneut eingereiht und setzt am Pruefpunkt seiner Sitzung
|
||||||
|
// fort. Ohne diesen Endpunkt fuehrte der Weg nur ueber die Datenbank — und ein
|
||||||
|
// unterbrochener Grossrestore begaenne von vorn.
|
||||||
|
func (handler *restoreHandler) handleResumeRestore(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
restoreIdentifier, parseError := parseRestoreIdentifier(request)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existingRestore, readError := handler.restoreStore.GetRestore(request.Context(), restoreIdentifier)
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateRestoreError(readError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein ueberschreibender Auftrag bleibt ueberschreibend: Die Fortsetzung
|
||||||
|
// erbt sein Kennzeichen. Sie verlangt deshalb dieselbe Berechtigung wie das
|
||||||
|
// erstmalige Anlegen — sonst waere sie ein Weg daran vorbei.
|
||||||
|
if existingRestore.OverwriteExisting && !userHasPermission(actingUser, "restores.overwrite") {
|
||||||
|
permissionError := NewValidationError(
|
||||||
|
"Dieser Auftrag ueberschreibt vorhandene Daten. Fuer die Fortsetzung fehlt die " +
|
||||||
|
"Berechtigung restores.overwrite.")
|
||||||
|
permissionError.Code = ErrorCodePermissionDenied
|
||||||
|
permissionError.StatusCode = http.StatusForbidden
|
||||||
|
|
||||||
|
WriteError(responseWriter, request, requestLogger, permissionError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resumedRestore, resumeError := handler.restoreStore.ResumeRestore(request.Context(), restoreIdentifier)
|
||||||
|
if resumeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateRestoreError(resumeError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activeSession, _ := handler.restoreStore.GetActiveSession(request.Context(), restoreIdentifier)
|
||||||
|
|
||||||
|
resumePath := ""
|
||||||
|
if activeSession != nil {
|
||||||
|
resumePath = activeSession.Checkpoint.LastCompletedPath
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionRestoreResumed, existingRestore.BackupID,
|
||||||
|
map[string]any{
|
||||||
|
"restore_id": restoreIdentifier.String(),
|
||||||
|
"target_path": existingRestore.TargetRef,
|
||||||
|
"resume_from": resumePath,
|
||||||
|
})
|
||||||
|
|
||||||
|
var checkpoint *recovery.Checkpoint
|
||||||
|
if activeSession != nil && activeSession.Checkpoint.LastCompletedPath != "" {
|
||||||
|
checkpoint = &activeSession.Checkpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
responseMessage := "Der Auftrag wurde erneut eingereiht und beginnt von vorn."
|
||||||
|
if resumePath != "" {
|
||||||
|
responseMessage = "Der Auftrag wurde erneut eingereiht und setzt nach " + resumePath + " fort."
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusAccepted, map[string]any{
|
||||||
|
"restore": buildRestoreResponse(resumedRestore, checkpoint),
|
||||||
|
"message": responseMessage,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordAudit schreibt ein Auditereignis.
|
||||||
|
func (handler *restoreHandler) recordAudit(request *http.Request, actingUser auth.User, auditAction audit.Action, backupIdentifier uuid.UUID, auditDetails map[string]any) {
|
||||||
|
if handler.auditRecorder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
correlationID, _ := logging.CorrelationIDFromContext(request.Context())
|
||||||
|
|
||||||
|
recordError := handler.auditRecorder.Record(request.Context(), audit.Event{
|
||||||
|
UserID: &actingUser.ID,
|
||||||
|
ActorUsername: actingUser.Username,
|
||||||
|
Action: auditAction,
|
||||||
|
EntityType: "backup",
|
||||||
|
EntityID: &backupIdentifier,
|
||||||
|
Result: audit.ResultSuccess,
|
||||||
|
IPAddress: clientIPAddress(request),
|
||||||
|
UserAgent: request.UserAgent(),
|
||||||
|
CorrelationID: correlationID,
|
||||||
|
Details: auditDetails,
|
||||||
|
})
|
||||||
|
|
||||||
|
if recordError != nil {
|
||||||
|
logging.WithContext(request.Context(), handler.logger).Error(
|
||||||
|
"das auditereignis konnte nicht geschrieben werden",
|
||||||
|
slog.String("aktion", string(auditAction)),
|
||||||
|
slog.String("grund", recordError.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRestoreResponse wandelt einen Auftrag in seine Darstellung.
|
||||||
|
func buildRestoreResponse(sourceRestore *recovery.RestoreJob, checkpoint *recovery.Checkpoint) restoreResponse {
|
||||||
|
builtResponse := restoreResponse{
|
||||||
|
ID: sourceRestore.ID,
|
||||||
|
BackupID: sourceRestore.BackupID,
|
||||||
|
TargetType: string(sourceRestore.TargetType),
|
||||||
|
TargetPath: sourceRestore.TargetRef,
|
||||||
|
PathPrefix: sourceRestore.PathPrefix,
|
||||||
|
Status: string(sourceRestore.Status),
|
||||||
|
OverwriteExisting: sourceRestore.OverwriteExisting,
|
||||||
|
StartedAt: sourceRestore.StartedAt,
|
||||||
|
CompletedAt: sourceRestore.CompletedAt,
|
||||||
|
BytesRestored: sourceRestore.BytesRestored,
|
||||||
|
FilesRestored: sourceRestore.FilesRestored,
|
||||||
|
FilesSkipped: sourceRestore.FilesSkipped,
|
||||||
|
ErrorCode: sourceRestore.ErrorCode,
|
||||||
|
ErrorMessage: sourceRestore.ErrorMessage,
|
||||||
|
ValidationReport: sourceRestore.ValidationReport,
|
||||||
|
Checkpoint: checkpoint,
|
||||||
|
CorrelationID: sourceRestore.CorrelationID,
|
||||||
|
CreatedAt: sourceRestore.CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if sourceRestore.StartedAt != nil && sourceRestore.CompletedAt != nil {
|
||||||
|
builtResponse.DurationSeconds = sourceRestore.CompletedAt.Sub(*sourceRestore.StartedAt).Seconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
return builtResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRestoreIdentifier liest die Auftragskennung aus dem Pfad.
|
||||||
|
func parseRestoreIdentifier(request *http.Request) (uuid.UUID, *APIError) {
|
||||||
|
restoreIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
return uuid.Nil, NewBadRequestError("Die Auftragskennung ist keine gueltige UUID.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return restoreIdentifier, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateRestoreError bildet Fehler der Fachschicht auf API-Fehler ab.
|
||||||
|
func translateRestoreError(occurredError error) *APIError {
|
||||||
|
switch {
|
||||||
|
case errors.Is(occurredError, recovery.ErrRestoreNotFound):
|
||||||
|
return NewNotFoundError("Der Wiederherstellungsauftrag wurde nicht gefunden.")
|
||||||
|
|
||||||
|
case errors.Is(occurredError, recovery.ErrRestoreNotResumable):
|
||||||
|
// Ein gelungener oder laufender Auftrag laesst sich nicht fortsetzen —
|
||||||
|
// das ist kein Serverfehler, sondern der falsche Zustand.
|
||||||
|
notResumableError := NewValidationError(
|
||||||
|
"Nur ein gescheiterter oder abgebrochener Auftrag laesst sich fortsetzen.")
|
||||||
|
notResumableError.Code = ErrorCodeConflict
|
||||||
|
notResumableError.StatusCode = http.StatusConflict
|
||||||
|
|
||||||
|
return notResumableError
|
||||||
|
|
||||||
|
case errors.Is(occurredError, recovery.ErrTargetBusy):
|
||||||
|
// 409 und nicht 500: Der Aufrufer hat nichts falsch gemacht, in das Ziel
|
||||||
|
// wird nur bereits geschrieben.
|
||||||
|
conflictError := NewValidationError(
|
||||||
|
"In dieses Ziel wird bereits wiederhergestellt. Warten Sie das Ende ab.")
|
||||||
|
conflictError.Code = ErrorCodeConflict
|
||||||
|
conflictError.StatusCode = http.StatusConflict
|
||||||
|
|
||||||
|
return conflictError
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NewInternalError(occurredError)
|
||||||
|
}
|
||||||
|
}
|
||||||
943
apps/api/internal/httpapi/retention_handler.go
Normal file
943
apps/api/internal/httpapi/retention_handler.go
Normal file
@ -0,0 +1,943 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/syncova/syncova/packages/audit"
|
||||||
|
"github.com/syncova/syncova/packages/auth"
|
||||||
|
"github.com/syncova/syncova/packages/jobs"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
"github.com/syncova/syncova/packages/repository"
|
||||||
|
"github.com/syncova/syncova/packages/retention"
|
||||||
|
)
|
||||||
|
|
||||||
|
// retentionHandler bedient Aufbewahrung und Unveraenderlichkeit (Phase 11).
|
||||||
|
type retentionHandler struct {
|
||||||
|
// retentionStore ist die Datenzugriffsschicht der Aufbewahrung.
|
||||||
|
retentionStore *retention.Store
|
||||||
|
// jobStore loest Backups und Repositories auf.
|
||||||
|
jobStore *jobs.PostgresStore
|
||||||
|
// auditRecorder protokolliert destruktive Handlungen.
|
||||||
|
auditRecorder audit.Recorder
|
||||||
|
// logger protokolliert technische Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// policyRequest ist der Rumpf von POST und PATCH auf /retention-policies.
|
||||||
|
type policyRequest struct {
|
||||||
|
// Name ist die Bezeichnung.
|
||||||
|
Name string `json:"name"`
|
||||||
|
// KeepWithinSeconds haelt alles Juengere.
|
||||||
|
KeepWithinSeconds int64 `json:"keep_within_seconds,omitempty"`
|
||||||
|
// KeepLast haelt die juengsten n Backups.
|
||||||
|
KeepLast int `json:"keep_last,omitempty"`
|
||||||
|
// KeepDaily haelt n Tagesstaende.
|
||||||
|
KeepDaily int `json:"keep_daily,omitempty"`
|
||||||
|
// KeepWeekly haelt n Wochenstaende.
|
||||||
|
KeepWeekly int `json:"keep_weekly,omitempty"`
|
||||||
|
// KeepMonthly haelt n Monatsstaende.
|
||||||
|
KeepMonthly int `json:"keep_monthly,omitempty"`
|
||||||
|
// KeepYearly haelt n Jahresstaende.
|
||||||
|
KeepYearly int `json:"keep_yearly,omitempty"`
|
||||||
|
// TimeZone ist die Zeitzone der Tages- und Monatsgrenzen.
|
||||||
|
TimeZone string `json:"time_zone,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// toPolicy wandelt die Anfrage in eine Regel.
|
||||||
|
//
|
||||||
|
// KeepLast wird auf 1 ergaenzt, wenn die Anfrage **andere** Haltevorgaben macht,
|
||||||
|
// aber keine Zahl fuer die juengsten Backups nennt. Das ist die Sicherung gegen
|
||||||
|
// den haeufigsten Totalverlust: Ein System sichert monatelang nicht, alle
|
||||||
|
// Backups fallen aus der Frist, und die Regel loescht das letzte vorhandene.
|
||||||
|
//
|
||||||
|
// Was hier **nicht** geschieht: Eine Anfrage ohne jede Haltevorgabe wird nicht
|
||||||
|
// stillschweigend zu „behalte das letzte" gemacht. Sie ist ein Tippfehler oder
|
||||||
|
// ein Missverstaendnis und wird abgelehnt — eine ungebetene Korrektur der
|
||||||
|
// Eingabe faellt sonst erst auf, wenn die Regel etwas anderes tut als gedacht.
|
||||||
|
func (payload policyRequest) toPolicy() retention.Policy {
|
||||||
|
convertedPolicy := retention.Policy{
|
||||||
|
Name: payload.Name,
|
||||||
|
KeepWithin: time.Duration(payload.KeepWithinSeconds) * time.Second,
|
||||||
|
KeepLast: payload.KeepLast,
|
||||||
|
KeepDaily: payload.KeepDaily,
|
||||||
|
KeepWeekly: payload.KeepWeekly,
|
||||||
|
KeepMonthly: payload.KeepMonthly,
|
||||||
|
KeepYearly: payload.KeepYearly,
|
||||||
|
TimeZone: payload.TimeZone,
|
||||||
|
}
|
||||||
|
|
||||||
|
if convertedPolicy.KeepLast <= 0 && payload.hasAnyKeepRule() {
|
||||||
|
convertedPolicy.KeepLast = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertedPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasAnyKeepRule meldet, ob die Anfrage ueberhaupt eine Haltevorgabe macht.
|
||||||
|
func (payload policyRequest) hasAnyKeepRule() bool {
|
||||||
|
return payload.KeepWithinSeconds > 0 || payload.KeepLast > 0 || payload.KeepDaily > 0 ||
|
||||||
|
payload.KeepWeekly > 0 || payload.KeepMonthly > 0 || payload.KeepYearly > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyRequest ist der Rumpf von POST /repositories/{id}/retention/apply.
|
||||||
|
type applyRequest struct {
|
||||||
|
// PolicyID ist die anzuwendende Regel.
|
||||||
|
PolicyID uuid.UUID `json:"policy_id"`
|
||||||
|
// DryRun beschraenkt den Lauf auf eine Vorschau.
|
||||||
|
DryRun bool `json:"dry_run"`
|
||||||
|
// ConfirmDeletion bestaetigt die Loeschung woertlich.
|
||||||
|
//
|
||||||
|
// Der Wert muss die Zahl der zu loeschenden Backups wiederholen. Wer sie
|
||||||
|
// abtippt, hat die Vorschau gelesen — ein versehentlich gesetztes
|
||||||
|
// dry_run:false reicht damit nicht aus, um Daten zu vernichten.
|
||||||
|
ConfirmDeletion string `json:"confirm_deletion,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// legalHoldRequest ist der Rumpf von POST /backups/{id}/legal-hold.
|
||||||
|
type legalHoldRequest struct {
|
||||||
|
// Reason begruendet die Anordnung oder ihre Aufhebung.
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// extendRetentionRequest ist der Rumpf von POST /backups/{id}/retention/extend.
|
||||||
|
type extendRetentionRequest struct {
|
||||||
|
// ImmutableUntil ist das neue Ende der Frist in UTC.
|
||||||
|
ImmutableUntil time.Time `json:"immutable_until"`
|
||||||
|
// Reason begruendet die Verlaengerung.
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteBackupRequest ist der Rumpf von DELETE /backups/{id}.
|
||||||
|
type deleteBackupRequest struct {
|
||||||
|
// ConfirmBackupID wiederholt die Kennung des Backups im Repository.
|
||||||
|
ConfirmBackupID string `json:"confirm_backup_id"`
|
||||||
|
// Reason begruendet die Loeschung.
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// protectionResponse ist die Schutzlage eines Backups.
|
||||||
|
type protectionResponse struct {
|
||||||
|
// BackupID ist das betrachtete Backup.
|
||||||
|
BackupID uuid.UUID `json:"backup_id"`
|
||||||
|
// BackupIDInRepository ist seine Kennung im Repository.
|
||||||
|
BackupIDInRepository string `json:"backup_id_in_repository"`
|
||||||
|
// ImmutableUntil ist das Ende der geltenden Frist in UTC.
|
||||||
|
ImmutableUntil *time.Time `json:"immutable_until,omitempty"`
|
||||||
|
// LegalHold meldet einen unbefristeten Schutz.
|
||||||
|
LegalHold bool `json:"legal_hold"`
|
||||||
|
// LegalHoldReason begruendet den unbefristeten Schutz.
|
||||||
|
LegalHoldReason string `json:"legal_hold_reason,omitempty"`
|
||||||
|
// IsProtected meldet, ob eine Loeschung derzeit unzulaessig ist.
|
||||||
|
IsProtected bool `json:"is_protected"`
|
||||||
|
// Description erklaert die Schutzlage in einem Satz.
|
||||||
|
Description string `json:"description"`
|
||||||
|
// EnforcementLevel ist die gemessene Durchsetzungsstufe des Repositorys.
|
||||||
|
//
|
||||||
|
// Sie steht hier, weil sie den Wert der uebrigen Felder bestimmt: Ein
|
||||||
|
// „geschuetzt bis" bei advisory bedeutet etwas anderes als bei filesystem.
|
||||||
|
EnforcementLevel string `json:"enforcement_level,omitempty"`
|
||||||
|
// EnforcementExplanation erklaert die Durchsetzungsstufe.
|
||||||
|
EnforcementExplanation string `json:"enforcement_explanation,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListPolicies bedient GET /retention-policies.
|
||||||
|
func (handler *retentionHandler) handleListPolicies(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
storedPolicies, listError := handler.retentionStore.ListPolicies(request.Context())
|
||||||
|
if listError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die vorgefertigten Regeln kommen mit: Wer eine Regel anlegen will, soll
|
||||||
|
// nicht raten muessen, was ueblich ist — und die mitgelieferten schuetzen
|
||||||
|
// alle das letzte vorhandene Backup.
|
||||||
|
predefinedPolicies := make([]map[string]any, 0, 6)
|
||||||
|
|
||||||
|
for _, predefinedPolicy := range retention.PredefinedPolicies() {
|
||||||
|
predefinedPolicies = append(predefinedPolicies, map[string]any{
|
||||||
|
"name": predefinedPolicy.Name,
|
||||||
|
"description": predefinedPolicy.Describe(),
|
||||||
|
"keep_within_seconds": int64(predefinedPolicy.KeepWithin.Seconds()),
|
||||||
|
"keep_last": predefinedPolicy.KeepLast,
|
||||||
|
"keep_daily": predefinedPolicy.KeepDaily,
|
||||||
|
"keep_weekly": predefinedPolicy.KeepWeekly,
|
||||||
|
"keep_monthly": predefinedPolicy.KeepMonthly,
|
||||||
|
"keep_yearly": predefinedPolicy.KeepYearly,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"policies": storedPolicies,
|
||||||
|
"predefined": predefinedPolicies,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreatePolicy bedient POST /retention-policies.
|
||||||
|
func (handler *retentionHandler) handleCreatePolicy(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
var policyPayload policyRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &policyPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createdPolicy, createError := handler.retentionStore.CreatePolicy(request.Context(),
|
||||||
|
policyPayload.toPolicy(), &actingUser.ID)
|
||||||
|
if createError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateRetentionError(createError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionRetentionPolicyCreated, "retention_policy",
|
||||||
|
&createdPolicy.ID, map[string]any{
|
||||||
|
"name": createdPolicy.Policy.Name,
|
||||||
|
"description": createdPolicy.Description,
|
||||||
|
})
|
||||||
|
|
||||||
|
responsePayload := map[string]any{"policy": createdPolicy}
|
||||||
|
|
||||||
|
// Eine ergaenzte Haltevorgabe wird ausgesprochen. Eine Regel, die anders
|
||||||
|
// aussieht als abgeschickt, ohne dass es jemand sagt, ist eine Ueberraschung
|
||||||
|
// mit Ansage.
|
||||||
|
if payloadHadNoKeepLast(policyPayload) {
|
||||||
|
responsePayload["notice"] = "Ohne Angabe von keep_last wurde 1 ergaenzt: Die Regel behaelt damit " +
|
||||||
|
"immer das juengste Backup, auch wenn es aelter ist als die Frist."
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusCreated, responsePayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdatePolicy bedient PATCH /retention-policies/{id}.
|
||||||
|
//
|
||||||
|
// Eine Regelaenderung wird immer auditiert, auch die harmlose: Ob sie harmlos
|
||||||
|
// war, stellt sich erst beim naechsten Lauf heraus (PROMPT.md §16 verlangt
|
||||||
|
// bestaetigt, protokolliert, auditierbar).
|
||||||
|
func (handler *retentionHandler) handleUpdatePolicy(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
policyIdentifier, parseError := parsePathIdentifier(request, "Die Regelkennung ist keine gueltige UUID.")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existingPolicy, readError := handler.retentionStore.GetPolicy(request.Context(), policyIdentifier)
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateRetentionError(readError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var policyPayload policyRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &policyPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedPolicy, updateError := handler.retentionStore.UpdatePolicy(request.Context(),
|
||||||
|
policyIdentifier, policyPayload.toPolicy())
|
||||||
|
if updateError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateRetentionError(updateError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionRetentionPolicyUpdated, "retention_policy",
|
||||||
|
&policyIdentifier, map[string]any{
|
||||||
|
"previous": existingPolicy.Description,
|
||||||
|
"new": updatedPolicy.Description,
|
||||||
|
"affected_jobs": existingPolicy.UsedByJobCount,
|
||||||
|
"policy_name": updatedPolicy.Policy.Name,
|
||||||
|
"changed_by_admin": actingUser.Username,
|
||||||
|
})
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"policy": updatedPolicy,
|
||||||
|
"message": "Die Regel wurde geaendert. Sie wirkt erst beim naechsten Aufbewahrungslauf; " +
|
||||||
|
"pruefen Sie ihn zuerst als Vorschau.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeletePolicy bedient DELETE /retention-policies/{id}.
|
||||||
|
func (handler *retentionHandler) handleDeletePolicy(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
policyIdentifier, parseError := parsePathIdentifier(request, "Die Regelkennung ist keine gueltige UUID.")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if deleteError := handler.retentionStore.DeletePolicy(request.Context(), policyIdentifier); deleteError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateRetentionError(deleteError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionRetentionPolicyDeleted, "retention_policy",
|
||||||
|
&policyIdentifier, nil)
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"message": "Die Aufbewahrungsregel wurde geloescht.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePreviewRetention bedient POST /repositories/{id}/retention/preview.
|
||||||
|
//
|
||||||
|
// Der Endpunkt schreibt **nichts**. Er ist der Weg, eine unbekannte Regel
|
||||||
|
// gefahrlos auszuprobieren — und er nennt je Backup den Grund, warum es bleibt
|
||||||
|
// oder geht.
|
||||||
|
func (handler *retentionHandler) handlePreviewRetention(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
handler.runRetention(responseWriter, request, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleApplyRetention bedient POST /repositories/{id}/retention/apply.
|
||||||
|
func (handler *retentionHandler) handleApplyRetention(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
handler.runRetention(responseWriter, request, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRetention fuehrt Vorschau oder Anwendung einer Aufbewahrungsregel aus.
|
||||||
|
func (handler *retentionHandler) runRetention(responseWriter http.ResponseWriter, request *http.Request, forcePreview bool) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
repositoryIdentifier, parseError := parsePathIdentifier(request, "Die Repositorykennung ist keine gueltige UUID.")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var applyPayload applyRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &applyPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storedPolicy, policyError := handler.retentionStore.GetPolicy(request.Context(), applyPayload.PolicyID)
|
||||||
|
if policyError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateRetentionError(policyError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repositoryRecord, repositoryError := handler.jobStore.GetRepository(request.Context(), repositoryIdentifier)
|
||||||
|
if repositoryError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewNotFoundError("Das Repository wurde nicht gefunden."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isDryRun := forcePreview || applyPayload.DryRun
|
||||||
|
|
||||||
|
// Die Vorschau oeffnet schreibgeschuetzt: Sie soll neben einer laufenden
|
||||||
|
// Sicherung stattfinden koennen und darf unter keinen Umstaenden etwas
|
||||||
|
// veraendern.
|
||||||
|
openedRepository, openError := repository.Open(request.Context(), repositoryRecord.Location,
|
||||||
|
repository.OpenOptions{ReadOnly: isDryRun}, handler.logger)
|
||||||
|
if openError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewServiceUnavailableError(
|
||||||
|
"Das Repository ist derzeit nicht erreichbar oder wird gerade beschrieben."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = openedRepository.Close() }()
|
||||||
|
|
||||||
|
enforcer, enforcerError := retention.NewEnforcer(openedRepository, handler.logger)
|
||||||
|
if enforcerError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(enforcerError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
executionPlan, planError := enforcer.Preview(request.Context(), storedPolicy.Policy)
|
||||||
|
if planError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateRetentionError(planError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
correlationIdentifier := correlationUUIDOrNew(request.Context())
|
||||||
|
|
||||||
|
if isDryRun {
|
||||||
|
if _, recordError := handler.retentionStore.RecordRun(request.Context(), repositoryIdentifier,
|
||||||
|
&storedPolicy.ID, executionPlan, nil, correlationIdentifier, &actingUser.ID); recordError != nil {
|
||||||
|
requestLogger.Warn("der vorschaulauf konnte nicht festgehalten werden",
|
||||||
|
slog.String("grund", recordError.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"dry_run": true,
|
||||||
|
"plan": executionPlan,
|
||||||
|
"summary": executionPlan.Summary(),
|
||||||
|
"message": "Es wurde nichts geloescht. Zum Ausfuehren senden Sie dieselbe Anfrage an " +
|
||||||
|
"/retention/apply und bestaetigen die Zahl der zu loeschenden Backups.",
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if confirmationError := checkDeletionConfirmation(applyPayload.ConfirmDeletion, executionPlan); confirmationError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, confirmationError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
executionResult, executeError := enforcer.Execute(request.Context(), storedPolicy.Policy)
|
||||||
|
if executeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateRetentionError(executeError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if markError := handler.retentionStore.MarkBackupsDeleted(request.Context(), repositoryIdentifier,
|
||||||
|
executionResult.DeletedBackupIDs, &actingUser.ID,
|
||||||
|
"Aufbewahrungsregel "+storedPolicy.Policy.Name); markError != nil {
|
||||||
|
requestLogger.Error("die loeschungen konnten in der control plane nicht vermerkt werden",
|
||||||
|
slog.String("grund", markError.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
runIdentifier, recordError := handler.retentionStore.RecordRun(request.Context(), repositoryIdentifier,
|
||||||
|
&storedPolicy.ID, executionResult.Plan, executionResult, correlationIdentifier, &actingUser.ID)
|
||||||
|
if recordError != nil {
|
||||||
|
requestLogger.Error("der aufbewahrungslauf konnte nicht festgehalten werden",
|
||||||
|
slog.String("grund", recordError.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionRetentionApplied, "repository",
|
||||||
|
&repositoryIdentifier, map[string]any{
|
||||||
|
"policy": storedPolicy.Policy.Name,
|
||||||
|
"deleted_count": len(executionResult.DeletedBackupIDs),
|
||||||
|
"deleted_ids": executionResult.DeletedBackupIDs,
|
||||||
|
"protected": executionResult.Plan.ProtectedCount,
|
||||||
|
"chunks_removed": executionResult.ChunksRemoved,
|
||||||
|
"bytes_freed": executionResult.BytesFreed,
|
||||||
|
"run_id": runIdentifier.String(),
|
||||||
|
})
|
||||||
|
|
||||||
|
statusCode := http.StatusOK
|
||||||
|
if executionResult.IsPartialFailure() {
|
||||||
|
// Ein Teilfehler ist kein Erfolg. Er bekommt einen eigenen Status, damit
|
||||||
|
// ein Skript ihn nicht uebersieht (PROMPT.md §137).
|
||||||
|
statusCode = http.StatusMultiStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, statusCode, map[string]any{
|
||||||
|
"dry_run": false,
|
||||||
|
"result": executionResult,
|
||||||
|
"summary": executionResult.Summary(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkDeletionConfirmation prueft die woertliche Bestaetigung einer Loeschung.
|
||||||
|
func checkDeletionConfirmation(confirmationValue string, executionPlan *retention.Plan) *APIError {
|
||||||
|
if executionPlan.DeletableCount == 0 {
|
||||||
|
// Nichts zu loeschen, nichts zu bestaetigen. Ein Ritual ohne Anlass
|
||||||
|
// gewoehnt das Wegklicken an.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedConfirmation := formatInteger(executionPlan.DeletableCount)
|
||||||
|
|
||||||
|
if confirmationValue != expectedConfirmation {
|
||||||
|
confirmationError := NewValidationError(
|
||||||
|
"Diese Anwendung loescht Backups unwiderruflich. Wiederholen Sie zur Bestaetigung die " +
|
||||||
|
"Zahl der zu loeschenden Backups im Feld confirm_deletion.")
|
||||||
|
confirmationError.Details = map[string]any{
|
||||||
|
"deletable_count": executionPlan.DeletableCount,
|
||||||
|
"confirm_with": expectedConfirmation,
|
||||||
|
"summary": executionPlan.Summary(),
|
||||||
|
"backup_ids": executionPlan.DeletableBackupIDs(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return confirmationError
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetProtection bedient GET /backups/{id}/protection.
|
||||||
|
func (handler *retentionHandler) handleGetProtection(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
backupIdentifier, parseError := parsePathIdentifier(request, "Die Backupkennung ist keine gueltige UUID.")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
protectionStatus, backupRecord, statusError := handler.readProtectionStatus(request.Context(), backupIdentifier)
|
||||||
|
if statusError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, statusError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK,
|
||||||
|
handler.buildProtectionResponse(request.Context(), backupIdentifier, backupRecord, protectionStatus))
|
||||||
|
}
|
||||||
|
|
||||||
|
// readProtectionStatus liest die Schutzlage eines Backups aus dem Repository.
|
||||||
|
//
|
||||||
|
// Massgeblich ist das Repository, nicht die Datenbank: Es muss auch ohne Control
|
||||||
|
// Server deutbar bleiben, und bei einem Widerspruch gewinnt die Seite, die die
|
||||||
|
// Daten haelt.
|
||||||
|
func (handler *retentionHandler) readProtectionStatus(readContext context.Context, backupIdentifier uuid.UUID) (repository.ProtectionStatus, *jobs.StoredBackup, *APIError) {
|
||||||
|
backupRecord, backupError := handler.jobStore.GetBackup(readContext, backupIdentifier)
|
||||||
|
if backupError != nil {
|
||||||
|
if errors.Is(backupError, jobs.ErrBackupNotFound) {
|
||||||
|
return repository.ProtectionStatus{}, nil, NewNotFoundError("Das Backup wurde nicht gefunden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return repository.ProtectionStatus{}, nil, NewInternalError(backupError)
|
||||||
|
}
|
||||||
|
|
||||||
|
repositoryRecord, repositoryError := handler.jobStore.GetRepository(readContext, backupRecord.RepositoryID)
|
||||||
|
if repositoryError != nil {
|
||||||
|
return repository.ProtectionStatus{}, nil, NewInternalError(repositoryError)
|
||||||
|
}
|
||||||
|
|
||||||
|
openedRepository, openError := repository.Open(readContext, repositoryRecord.Location,
|
||||||
|
repository.OpenOptions{ReadOnly: true}, handler.logger)
|
||||||
|
if openError != nil {
|
||||||
|
return repository.ProtectionStatus{}, nil, NewServiceUnavailableError(
|
||||||
|
"Das Repository des Backups ist derzeit nicht erreichbar.")
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = openedRepository.Close() }()
|
||||||
|
|
||||||
|
protectionStatus, statusError := openedRepository.ProtectionStatusOf(readContext,
|
||||||
|
backupRecord.BackupIDInRepository)
|
||||||
|
if statusError != nil {
|
||||||
|
return repository.ProtectionStatus{}, nil, NewInternalError(statusError)
|
||||||
|
}
|
||||||
|
|
||||||
|
return protectionStatus, backupRecord, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildProtectionResponse wandelt eine Schutzlage in ihre Darstellung.
|
||||||
|
func (handler *retentionHandler) buildProtectionResponse(buildContext context.Context, backupIdentifier uuid.UUID, backupRecord *jobs.StoredBackup, protectionStatus repository.ProtectionStatus) protectionResponse {
|
||||||
|
builtResponse := protectionResponse{
|
||||||
|
BackupID: backupIdentifier,
|
||||||
|
BackupIDInRepository: backupRecord.BackupIDInRepository,
|
||||||
|
ImmutableUntil: protectionStatus.ImmutableUntil,
|
||||||
|
LegalHold: protectionStatus.LegalHold,
|
||||||
|
LegalHoldReason: protectionStatus.LegalHoldReason,
|
||||||
|
IsProtected: protectionStatus.IsProtected(time.Now()),
|
||||||
|
Description: protectionStatus.Describe(time.Now()),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die gemessene Durchsetzungsstufe kommt mit: Ohne sie liest sich
|
||||||
|
// „geschuetzt bis" wie eine Garantie, die es je nach Speicher nicht gibt.
|
||||||
|
repositoryRecord, repositoryError := handler.jobStore.GetRepository(buildContext, backupRecord.RepositoryID)
|
||||||
|
if repositoryError == nil && repositoryRecord.EnforcementLevel != "" {
|
||||||
|
builtResponse.EnforcementLevel = repositoryRecord.EnforcementLevel
|
||||||
|
builtResponse.EnforcementExplanation =
|
||||||
|
repository.EnforcementLevel(repositoryRecord.EnforcementLevel).Describe()
|
||||||
|
}
|
||||||
|
|
||||||
|
return builtResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePlaceLegalHold bedient POST /backups/{id}/legal-hold.
|
||||||
|
func (handler *retentionHandler) handlePlaceLegalHold(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
handler.changeLegalHold(responseWriter, request, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReleaseLegalHold bedient DELETE /backups/{id}/legal-hold.
|
||||||
|
func (handler *retentionHandler) handleReleaseLegalHold(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
handler.changeLegalHold(responseWriter, request, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// changeLegalHold setzt oder hebt einen unbefristeten Schutz auf.
|
||||||
|
func (handler *retentionHandler) changeLegalHold(responseWriter http.ResponseWriter, request *http.Request, shouldPlace bool) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
backupIdentifier, parseError := parsePathIdentifier(request, "Die Backupkennung ist keine gueltige UUID.")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var holdPayload legalHoldRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &holdPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if holdPayload.Reason == "" {
|
||||||
|
// Ohne Begruendung liesse sich der Hold spaeter nicht mehr aufloesen —
|
||||||
|
// und seine Aufhebung nicht rechtfertigen.
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewValidationError(
|
||||||
|
"Ein Legal Hold und seine Aufhebung verlangen eine Begruendung."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openedRepository, backupRecord, openError := handler.openWritableRepositoryOfBackup(request.Context(), backupIdentifier)
|
||||||
|
if openError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, openError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = openedRepository.Close() }()
|
||||||
|
|
||||||
|
var (
|
||||||
|
resultingStatus repository.ProtectionStatus
|
||||||
|
changeError error
|
||||||
|
)
|
||||||
|
|
||||||
|
if shouldPlace {
|
||||||
|
resultingStatus, changeError = openedRepository.PlaceLegalHold(request.Context(),
|
||||||
|
backupRecord.BackupIDInRepository, actingUser.Username, holdPayload.Reason)
|
||||||
|
} else {
|
||||||
|
resultingStatus, changeError = openedRepository.ReleaseLegalHold(request.Context(),
|
||||||
|
backupRecord.BackupIDInRepository, actingUser.Username, holdPayload.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
if changeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateRetentionError(changeError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if saveError := handler.retentionStore.SaveLegalHold(request.Context(), backupIdentifier,
|
||||||
|
shouldPlace, holdPayload.Reason, &actingUser.ID); saveError != nil {
|
||||||
|
requestLogger.Error("der legal hold konnte in der control plane nicht vermerkt werden",
|
||||||
|
slog.String("grund", saveError.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
auditAction := audit.ActionLegalHoldPlaced
|
||||||
|
if !shouldPlace {
|
||||||
|
auditAction = audit.ActionLegalHoldReleased
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, auditAction, "backup", &backupIdentifier, map[string]any{
|
||||||
|
"backup_in_repository": backupRecord.BackupIDInRepository,
|
||||||
|
"reason": holdPayload.Reason,
|
||||||
|
})
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK,
|
||||||
|
handler.buildProtectionResponse(request.Context(), backupIdentifier, backupRecord, resultingStatus))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleExtendRetention bedient POST /backups/{id}/retention/extend.
|
||||||
|
func (handler *retentionHandler) handleExtendRetention(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
backupIdentifier, parseError := parsePathIdentifier(request, "Die Backupkennung ist keine gueltige UUID.")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var extendPayload extendRetentionRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &extendPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if extendPayload.ImmutableUntil.IsZero() {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewValidationError("Es wurde kein neues Ende der Aufbewahrungsfrist angegeben."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openedRepository, backupRecord, openError := handler.openWritableRepositoryOfBackup(request.Context(), backupIdentifier)
|
||||||
|
if openError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, openError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = openedRepository.Close() }()
|
||||||
|
|
||||||
|
extendedStatus, extendError := openedRepository.ExtendRetention(request.Context(),
|
||||||
|
backupRecord.BackupIDInRepository, extendPayload.ImmutableUntil,
|
||||||
|
actingUser.Username, extendPayload.Reason)
|
||||||
|
if extendError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateRetentionError(extendError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if saveError := handler.retentionStore.SaveImmutableUntil(request.Context(), backupIdentifier,
|
||||||
|
extendPayload.ImmutableUntil); saveError != nil {
|
||||||
|
requestLogger.Error("die verlaengerte frist konnte in der control plane nicht vermerkt werden",
|
||||||
|
slog.String("grund", saveError.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionRetentionExtended, "backup",
|
||||||
|
&backupIdentifier, map[string]any{
|
||||||
|
"backup_in_repository": backupRecord.BackupIDInRepository,
|
||||||
|
"immutable_until": extendPayload.ImmutableUntil.Format(time.RFC3339),
|
||||||
|
"reason": extendPayload.Reason,
|
||||||
|
})
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK,
|
||||||
|
handler.buildProtectionResponse(request.Context(), backupIdentifier, backupRecord, extendedStatus))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteBackup bedient DELETE /backups/{id}.
|
||||||
|
//
|
||||||
|
// Drei Huerden vor der Loeschung: das Recht backups.delete, die woertliche
|
||||||
|
// Wiederholung der Backupkennung und der Aufbewahrungsschutz des Repositorys,
|
||||||
|
// der auch dann greift, wenn die ersten beiden erfuellt sind.
|
||||||
|
func (handler *retentionHandler) handleDeleteBackup(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
backupIdentifier, parseError := parsePathIdentifier(request, "Die Backupkennung ist keine gueltige UUID.")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletePayload deleteBackupRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &deletePayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
openedRepository, backupRecord, openError := handler.openWritableRepositoryOfBackup(request.Context(), backupIdentifier)
|
||||||
|
if openError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, openError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = openedRepository.Close() }()
|
||||||
|
|
||||||
|
if deletePayload.ConfirmBackupID != backupRecord.BackupIDInRepository {
|
||||||
|
confirmationError := NewValidationError(
|
||||||
|
"Diese Loeschung ist unwiderruflich. Wiederholen Sie zur Bestaetigung die Kennung des " +
|
||||||
|
"Backups im Feld confirm_backup_id.")
|
||||||
|
confirmationError.Details = map[string]any{
|
||||||
|
"confirm_with": backupRecord.BackupIDInRepository,
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteError(responseWriter, request, requestLogger, confirmationError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if deleteError := openedRepository.DeleteBackup(request.Context(),
|
||||||
|
backupRecord.BackupIDInRepository); deleteError != nil {
|
||||||
|
// Der Aufbewahrungsschutz meldet sich hier — und zwar auch dann, wenn
|
||||||
|
// Recht und Bestaetigung vorliegen. Das ist sein Zweck.
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionBackupDeletionDenied, "backup",
|
||||||
|
&backupIdentifier, map[string]any{
|
||||||
|
"backup_in_repository": backupRecord.BackupIDInRepository,
|
||||||
|
"reason": deleteError.Error(),
|
||||||
|
})
|
||||||
|
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateRetentionError(deleteError))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if markError := handler.retentionStore.MarkBackupsDeleted(request.Context(), backupRecord.RepositoryID,
|
||||||
|
[]string{backupRecord.BackupIDInRepository}, &actingUser.ID, deletePayload.Reason); markError != nil {
|
||||||
|
requestLogger.Error("die loeschung konnte in der control plane nicht vermerkt werden",
|
||||||
|
slog.String("grund", markError.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionBackupDeleted, "backup",
|
||||||
|
&backupIdentifier, map[string]any{
|
||||||
|
"backup_in_repository": backupRecord.BackupIDInRepository,
|
||||||
|
"reason": deletePayload.Reason,
|
||||||
|
})
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"message": "Das Backup wurde geloescht. Seine Datenbloecke verschwinden mit der naechsten " +
|
||||||
|
"Bereinigung, sofern kein anderes Backup sie noch braucht.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMeasureEnforcement bedient POST /repositories/{id}/enforcement/measure.
|
||||||
|
//
|
||||||
|
// Die Messung ist der Kern der Ehrlichkeit dieser Phase: Sie stellt fest, was
|
||||||
|
// das Dateisystem tatsaechlich verhindert, statt es zu behaupten.
|
||||||
|
func (handler *retentionHandler) handleMeasureEnforcement(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
repositoryIdentifier, parseError := parsePathIdentifier(request, "Die Repositorykennung ist keine gueltige UUID.")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
repositoryRecord, repositoryError := handler.jobStore.GetRepository(request.Context(), repositoryIdentifier)
|
||||||
|
if repositoryError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Repository wurde nicht gefunden."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enforcementReport, measureError := repository.MeasureEnforcement(request.Context(),
|
||||||
|
repositoryRecord.Location, handler.logger)
|
||||||
|
if measureError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewServiceUnavailableError(
|
||||||
|
"Die Durchsetzungsstufe liess sich nicht messen; das Repository ist nicht erreichbar."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if saveError := handler.retentionStore.SaveEnforcementLevel(request.Context(),
|
||||||
|
repositoryIdentifier, enforcementReport); saveError != nil {
|
||||||
|
requestLogger.Warn("die gemessene stufe konnte nicht gespeichert werden",
|
||||||
|
slog.String("grund", saveError.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, enforcementReport)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openWritableRepositoryOfBackup oeffnet das Repository eines Backups schreibend.
|
||||||
|
func (handler *retentionHandler) openWritableRepositoryOfBackup(openContext context.Context, backupIdentifier uuid.UUID) (*repository.LocalRepository, *jobs.StoredBackup, *APIError) {
|
||||||
|
backupRecord, backupError := handler.jobStore.GetBackup(openContext, backupIdentifier)
|
||||||
|
if backupError != nil {
|
||||||
|
if errors.Is(backupError, jobs.ErrBackupNotFound) {
|
||||||
|
return nil, nil, NewNotFoundError("Das Backup wurde nicht gefunden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil, NewInternalError(backupError)
|
||||||
|
}
|
||||||
|
|
||||||
|
repositoryRecord, repositoryError := handler.jobStore.GetRepository(openContext, backupRecord.RepositoryID)
|
||||||
|
if repositoryError != nil {
|
||||||
|
return nil, nil, NewInternalError(repositoryError)
|
||||||
|
}
|
||||||
|
|
||||||
|
openedRepository, openError := repository.Open(openContext, repositoryRecord.Location,
|
||||||
|
repository.OpenOptions{}, handler.logger)
|
||||||
|
if openError != nil {
|
||||||
|
return nil, nil, NewServiceUnavailableError(
|
||||||
|
"Das Repository ist derzeit nicht erreichbar oder wird gerade beschrieben.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return openedRepository, backupRecord, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordAudit schreibt ein Auditereignis.
|
||||||
|
func (handler *retentionHandler) recordAudit(request *http.Request, actingUser auth.User, auditAction audit.Action, entityType string, entityIdentifier *uuid.UUID, auditDetails map[string]any) {
|
||||||
|
if handler.auditRecorder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
correlationIdentifier, _ := logging.CorrelationIDFromContext(request.Context())
|
||||||
|
|
||||||
|
recordError := handler.auditRecorder.Record(request.Context(), audit.Event{
|
||||||
|
UserID: &actingUser.ID,
|
||||||
|
ActorUsername: actingUser.Username,
|
||||||
|
Action: auditAction,
|
||||||
|
EntityType: entityType,
|
||||||
|
EntityID: entityIdentifier,
|
||||||
|
Result: audit.ResultSuccess,
|
||||||
|
IPAddress: clientIPAddress(request),
|
||||||
|
UserAgent: request.UserAgent(),
|
||||||
|
CorrelationID: correlationIdentifier,
|
||||||
|
Details: auditDetails,
|
||||||
|
})
|
||||||
|
|
||||||
|
if recordError != nil {
|
||||||
|
logging.WithContext(request.Context(), handler.logger).Error(
|
||||||
|
"das auditereignis konnte nicht geschrieben werden",
|
||||||
|
slog.String("aktion", string(auditAction)),
|
||||||
|
slog.String("grund", recordError.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePathIdentifier liest eine Kennung aus dem Pfad.
|
||||||
|
func parsePathIdentifier(request *http.Request, errorMessage string) (uuid.UUID, *APIError) {
|
||||||
|
parsedIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
return uuid.Nil, NewBadRequestError(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedIdentifier, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatInteger schreibt eine Zahl als Zeichenkette.
|
||||||
|
func formatInteger(value int) string {
|
||||||
|
return fmt.Sprintf("%d", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateRetentionError bildet Fehler der Fachschicht auf API-Fehler ab.
|
||||||
|
func translateRetentionError(occurredError error) *APIError {
|
||||||
|
switch {
|
||||||
|
case errors.Is(occurredError, retention.ErrPolicyNotFound):
|
||||||
|
return NewNotFoundError("Die Aufbewahrungsregel wurde nicht gefunden.")
|
||||||
|
|
||||||
|
case errors.Is(occurredError, retention.ErrPolicyInUse):
|
||||||
|
conflictError := NewValidationError(
|
||||||
|
"Diese Aufbewahrungsregel wird noch von Sicherungsauftraegen verwendet. " +
|
||||||
|
"Weisen Sie ihnen zuerst eine andere Regel zu.")
|
||||||
|
conflictError.Code = ErrorCodeConflict
|
||||||
|
conflictError.StatusCode = http.StatusConflict
|
||||||
|
|
||||||
|
return conflictError
|
||||||
|
|
||||||
|
case errors.Is(occurredError, retention.ErrPolicyEmpty), errors.Is(occurredError, retention.ErrPolicyInvalid):
|
||||||
|
return NewValidationError(occurredError.Error())
|
||||||
|
|
||||||
|
case errors.Is(occurredError, repository.ErrLegalHold):
|
||||||
|
// 409 und nicht 403: Der Aufrufer hat das Recht, das Backup ist nur
|
||||||
|
// geschuetzt. Der Unterschied entscheidet, was er als Naechstes tut.
|
||||||
|
holdError := NewValidationError(
|
||||||
|
"Dieses Backup wird fuer Beweiszwecke gehalten und kann nicht geloescht werden. " +
|
||||||
|
"Heben Sie zuerst den Legal Hold auf.")
|
||||||
|
holdError.Code = ErrorCodeConflict
|
||||||
|
holdError.StatusCode = http.StatusConflict
|
||||||
|
|
||||||
|
return holdError
|
||||||
|
|
||||||
|
case errors.Is(occurredError, repository.ErrRetentionLocked):
|
||||||
|
lockedError := NewValidationError(
|
||||||
|
"Dieses Backup steht unter Aufbewahrungsschutz und kann noch nicht geloescht werden.")
|
||||||
|
lockedError.Code = ErrorCodeConflict
|
||||||
|
lockedError.StatusCode = http.StatusConflict
|
||||||
|
lockedError.Details = map[string]any{"detail": occurredError.Error()}
|
||||||
|
|
||||||
|
return lockedError
|
||||||
|
|
||||||
|
case errors.Is(occurredError, repository.ErrRetentionCannotBeShortened):
|
||||||
|
shortenError := NewValidationError(
|
||||||
|
"Eine Aufbewahrungsfrist laesst sich verlaengern, aber niemals verkuerzen.")
|
||||||
|
shortenError.Details = map[string]any{"detail": occurredError.Error()}
|
||||||
|
|
||||||
|
return shortenError
|
||||||
|
|
||||||
|
case errors.Is(occurredError, repository.ErrBackupNotFound):
|
||||||
|
return NewNotFoundError("Das Backup wurde im Repository nicht gefunden.")
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NewInternalError(occurredError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// correlationUUIDOrNew liefert die Correlation ID des Requests als UUID.
|
||||||
|
//
|
||||||
|
// Die Spalte verlangt eine UUID; die Middleware laesst nur gueltige UUIDs
|
||||||
|
// durch, doch bei einem Aufruf ohne Header stuende hier eine leere Zeichenkette.
|
||||||
|
// Eine neue Kennung ist dann besser als ein abgebrochener Lauf — die Zuordnung
|
||||||
|
// zu den Protokollzeilen geht verloren, der Aufbewahrungslauf nicht.
|
||||||
|
func correlationUUIDOrNew(requestContext context.Context) uuid.UUID {
|
||||||
|
correlationText, isPresent := logging.CorrelationIDFromContext(requestContext)
|
||||||
|
if !isPresent {
|
||||||
|
return uuid.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedIdentifier, parseError := uuid.Parse(correlationText)
|
||||||
|
if parseError != nil {
|
||||||
|
return uuid.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// payloadHadNoKeepLast meldet eine Anfrage ohne Angabe zu den juengsten Backups.
|
||||||
|
func payloadHadNoKeepLast(payload policyRequest) bool {
|
||||||
|
return payload.KeepLast <= 0
|
||||||
|
}
|
||||||
277
apps/api/internal/httpapi/role_handler.go
Normal file
277
apps/api/internal/httpapi/role_handler.go
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/syncova/syncova/packages/audit"
|
||||||
|
"github.com/syncova/syncova/packages/auth"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// roleHandler bedient Rollen und Berechtigungen (SYNCOVA_API.md §5).
|
||||||
|
type roleHandler struct {
|
||||||
|
// repository ist die Datenzugriffsschicht der Identitätsverwaltung.
|
||||||
|
repository *auth.Repository
|
||||||
|
// auditRecorder protokolliert Änderungen an Rollen.
|
||||||
|
auditRecorder audit.Recorder
|
||||||
|
// logger protokolliert technische Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// roleRequest ist der Rumpf von POST und PATCH auf /roles.
|
||||||
|
type roleRequest struct {
|
||||||
|
// Name ist der technische Name der Rolle (nur beim Anlegen ausgewertet).
|
||||||
|
Name string `json:"name"`
|
||||||
|
// Description erklärt den Zweck der Rolle.
|
||||||
|
Description *string `json:"description"`
|
||||||
|
// Permissions sind die zuzuordnenden Berechtigungen.
|
||||||
|
Permissions []string `json:"permissions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListRoles bedient GET /roles.
|
||||||
|
func (handler *roleHandler) handleListRoles(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
loadedRoles, listError := handler.repository.ListRoles(request.Context())
|
||||||
|
if listError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, loadedRoles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetRole bedient GET /roles/{id}.
|
||||||
|
func (handler *roleHandler) handleGetRole(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
roleID, parseError := parsePathUUID(request, "id")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedRole, loadError := handler.repository.FindRoleByID(request.Context(), roleID)
|
||||||
|
if loadError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAuthError(loadError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, loadedRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateRole bedient POST /roles.
|
||||||
|
func (handler *roleHandler) handleCreateRole(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
var rolePayload roleRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &rolePayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rolePayload.Name == "" {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewValidationError("Der Rollenname ist erforderlich."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eine Rolle ohne Berechtigungen wäre wirkungslos und fast immer ein Versehen.
|
||||||
|
if len(rolePayload.Permissions) == 0 {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewValidationError("Es muss mindestens eine Berechtigung zugeordnet werden."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
description := ""
|
||||||
|
if rolePayload.Description != nil {
|
||||||
|
description = *rolePayload.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
createdRoleID, createError := handler.repository.CreateRole(
|
||||||
|
request.Context(), rolePayload.Name, description, rolePayload.Permissions)
|
||||||
|
if createError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAuthError(createError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordRoleAudit(request, audit.ActionRoleCreated, createdRoleID, actingUser, map[string]any{
|
||||||
|
"name": rolePayload.Name,
|
||||||
|
"berechtigungen": rolePayload.Permissions,
|
||||||
|
})
|
||||||
|
|
||||||
|
createdRole, loadError := handler.repository.FindRoleByID(request.Context(), createdRoleID)
|
||||||
|
if loadError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(loadError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusCreated, createdRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateRole bedient PATCH /roles/{id}.
|
||||||
|
func (handler *roleHandler) handleUpdateRole(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
roleID, parseError := parsePathUUID(request, "id")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var rolePayload roleRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &rolePayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if updateError := handler.repository.UpdateRole(
|
||||||
|
request.Context(), roleID, rolePayload.Description, rolePayload.Permissions); updateError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAuthError(updateError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordRoleAudit(request, audit.ActionRoleUpdated, roleID, actingUser, map[string]any{
|
||||||
|
"berechtigungen": rolePayload.Permissions,
|
||||||
|
})
|
||||||
|
|
||||||
|
updatedRole, loadError := handler.repository.FindRoleByID(request.Context(), roleID)
|
||||||
|
if loadError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(loadError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, updatedRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteRole bedient DELETE /roles/{id}.
|
||||||
|
func (handler *roleHandler) handleDeleteRole(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
roleID, parseError := parsePathUUID(request, "id")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if deleteError := handler.repository.DeleteRole(request.Context(), roleID); deleteError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAuthError(deleteError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordRoleAudit(request, audit.ActionRoleDeleted, roleID, actingUser, nil)
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusNoContent, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListPermissions bedient GET /permissions.
|
||||||
|
func (handler *roleHandler) handleListPermissions(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
loadedPermissions, listError := handler.repository.ListPermissions(request.Context())
|
||||||
|
if listError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, loadedPermissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordRoleAudit protokolliert eine Rollenänderung.
|
||||||
|
func (handler *roleHandler) recordRoleAudit(request *http.Request, auditAction audit.Action, roleID uuid.UUID, actingUser auth.User, auditDetails map[string]any) {
|
||||||
|
correlationID, _ := logging.CorrelationIDFromContext(request.Context())
|
||||||
|
|
||||||
|
if recordError := handler.auditRecorder.Record(request.Context(), audit.Event{
|
||||||
|
UserID: &actingUser.ID,
|
||||||
|
ActorUsername: actingUser.Username,
|
||||||
|
Action: auditAction,
|
||||||
|
EntityType: "role",
|
||||||
|
EntityID: &roleID,
|
||||||
|
Result: audit.ResultSuccess,
|
||||||
|
IPAddress: clientIPAddress(request),
|
||||||
|
UserAgent: request.UserAgent(),
|
||||||
|
CorrelationID: correlationID,
|
||||||
|
Details: auditDetails,
|
||||||
|
}); recordError != nil {
|
||||||
|
logging.WithContext(request.Context(), handler.logger).Error(
|
||||||
|
"rollenänderung konnte nicht protokolliert werden",
|
||||||
|
slog.String("action", string(auditAction)),
|
||||||
|
slog.String("error", recordError.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auditprotokoll
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// auditHandler bedient die Auditabfrage (SYNCOVA_API.md §17).
|
||||||
|
type auditHandler struct {
|
||||||
|
// auditRecorder liest die protokollierten Ereignisse.
|
||||||
|
auditRecorder audit.Recorder
|
||||||
|
// logger protokolliert technische Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListAuditEvents bedient GET /audit-events.
|
||||||
|
func (handler *auditHandler) handleListAuditEvents(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
page, pageSize := parsePagination(request)
|
||||||
|
queryParameters := request.URL.Query()
|
||||||
|
|
||||||
|
queryFilter := audit.Filter{
|
||||||
|
Action: queryParameters.Get("action"),
|
||||||
|
Result: queryParameters.Get("result"),
|
||||||
|
Page: page,
|
||||||
|
PageSize: pageSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
if rawUserID := queryParameters.Get("user_id"); rawUserID != "" {
|
||||||
|
parsedUserID, parseError := uuid.Parse(rawUserID)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError("Der Parameter user_id ist keine gültige UUID."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
queryFilter.UserID = &parsedUserID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeitangaben werden in ISO 8601 erwartet, passend zur Ausgabe der API.
|
||||||
|
if rawFrom := queryParameters.Get("from"); rawFrom != "" {
|
||||||
|
parsedFrom, parseError := time.Parse(time.RFC3339, rawFrom)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError("Der Parameter from muss im Format RFC 3339 angegeben werden."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
queryFilter.From = &parsedFrom
|
||||||
|
}
|
||||||
|
|
||||||
|
if rawTo := queryParameters.Get("to"); rawTo != "" {
|
||||||
|
parsedTo, parseError := time.Parse(time.RFC3339, rawTo)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError("Der Parameter to muss im Format RFC 3339 angegeben werden."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
queryFilter.To = &parsedTo
|
||||||
|
}
|
||||||
|
|
||||||
|
auditEvents, totalCount, queryError := handler.auditRecorder.Query(request.Context(), queryFilter)
|
||||||
|
if queryError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(queryError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WritePaginatedSuccess(responseWriter, request, auditEvents, PaginationMeta{
|
||||||
|
Page: page, PageSize: pageSize, Total: totalCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
705
apps/api/internal/httpapi/router.go
Normal file
705
apps/api/internal/httpapi/router.go
Normal file
@ -0,0 +1,705 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/agentregistry"
|
||||||
|
"github.com/syncova/syncova/packages/agenttasks"
|
||||||
|
"github.com/syncova/syncova/packages/alerting"
|
||||||
|
"github.com/syncova/syncova/packages/audit"
|
||||||
|
"github.com/syncova/syncova/packages/auth"
|
||||||
|
"github.com/syncova/syncova/packages/hypervisor"
|
||||||
|
"github.com/syncova/syncova/packages/jobs"
|
||||||
|
"github.com/syncova/syncova/packages/metrics"
|
||||||
|
"github.com/syncova/syncova/packages/platform/config"
|
||||||
|
"github.com/syncova/syncova/packages/platform/crypto"
|
||||||
|
"github.com/syncova/syncova/packages/platform/health"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
"github.com/syncova/syncova/packages/platform/netguard"
|
||||||
|
"github.com/syncova/syncova/packages/ransomware"
|
||||||
|
"github.com/syncova/syncova/packages/recovery"
|
||||||
|
"github.com/syncova/syncova/packages/reports"
|
||||||
|
"github.com/syncova/syncova/packages/retention"
|
||||||
|
"github.com/syncova/syncova/packages/security"
|
||||||
|
"github.com/syncova/syncova/packages/verification"
|
||||||
|
)
|
||||||
|
|
||||||
|
// apiBasePath ist das Präfix aller fachlichen Endpunkte (SYNCOVA_API.md).
|
||||||
|
// Die Version ist Teil des Pfads, damit spätere Verträge parallel bestehen können.
|
||||||
|
const apiBasePath = "/api/v1"
|
||||||
|
|
||||||
|
// Grenzen des Anmeldeschutzes.
|
||||||
|
//
|
||||||
|
// Sie greifen je Absenderadresse und ergänzen die kontobezogene Sperre: ohne sie
|
||||||
|
// könnte ein Angreifer viele Konten mit je wenigen Versuchen durchprobieren.
|
||||||
|
const (
|
||||||
|
// loginRateLimitAttempts ist die Anzahl erlaubter Anmeldeversuche je Zeitfenster.
|
||||||
|
loginRateLimitAttempts = 10
|
||||||
|
// loginRateLimitWindow ist das betrachtete Zeitfenster.
|
||||||
|
loginRateLimitWindow = 5 * time.Minute
|
||||||
|
// rateLimiterCleanupInterval ist der Abstand der Speicherbereinigung.
|
||||||
|
rateLimiterCleanupInterval = 10 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// RouterDependencies bündelt, was der Router zur Bedienung der Endpunkte braucht.
|
||||||
|
type RouterDependencies struct {
|
||||||
|
// Config ist die geladene Dienstkonfiguration.
|
||||||
|
Config config.Config
|
||||||
|
// Logger ist der Basis-Logger des Dienstes.
|
||||||
|
Logger *slog.Logger
|
||||||
|
// HealthRegistry liefert den Zustand aller überwachten Komponenten.
|
||||||
|
HealthRegistry *health.Registry
|
||||||
|
// BuildVersion ist die ausgelieferte Version, sichtbar unter /health.
|
||||||
|
BuildVersion string
|
||||||
|
// AuthService ist die Domänenlogik der Identitätsverwaltung.
|
||||||
|
AuthService *auth.Service
|
||||||
|
// AuthRepository ist die Datenzugriffsschicht für Rollen und Berechtigungen.
|
||||||
|
AuthRepository *auth.Repository
|
||||||
|
// AuditRecorder protokolliert sicherheitsrelevante Handlungen.
|
||||||
|
AuditRecorder audit.Recorder
|
||||||
|
// AgentService ist die Domänenlogik der Agent-Verwaltung.
|
||||||
|
AgentService *agentregistry.Service
|
||||||
|
// RestoreStore ist die Datenzugriffsschicht der Wiederherstellungen.
|
||||||
|
//
|
||||||
|
// Sie darf nil sein; dann werden die Wiederherstellungsrouten nicht
|
||||||
|
// eingebunden.
|
||||||
|
RestoreStore *recovery.Store
|
||||||
|
// JobStore ist die Datenzugriffsschicht der Sicherungsaufträge.
|
||||||
|
//
|
||||||
|
// Sie darf nil sein; dann werden die Auftragsrouten nicht eingebunden. Das
|
||||||
|
// erlaubt einen Dienst ohne Auftragsverwaltung, ohne dass ein Aufruf in
|
||||||
|
// einen Nil-Zeiger läuft.
|
||||||
|
JobStore *jobs.PostgresStore
|
||||||
|
// VerificationStore ist die Datenzugriffsschicht der Prüfungen.
|
||||||
|
//
|
||||||
|
// Sie darf nil sein; dann werden die Prüfrouten nicht eingebunden.
|
||||||
|
VerificationStore *verification.Store
|
||||||
|
// RetentionStore ist die Datenzugriffsschicht der Aufbewahrung.
|
||||||
|
//
|
||||||
|
// Sie darf nil sein; dann werden die Aufbewahrungsrouten nicht eingebunden.
|
||||||
|
RetentionStore *retention.Store
|
||||||
|
// MetricsStore bildet die Zeitreihen der Diagramme.
|
||||||
|
//
|
||||||
|
// Sie darf nil sein; dann werden die Kennzahlenrouten nicht eingebunden.
|
||||||
|
MetricsStore *metrics.Store
|
||||||
|
// AlertStore ist die Datenzugriffsschicht der Meldungen.
|
||||||
|
//
|
||||||
|
// Sie darf nil sein; dann werden die Meldungsrouten nicht eingebunden.
|
||||||
|
AlertStore *alerting.Store
|
||||||
|
// SecretStore verschlüsselt die Zugangsgeheimnisse der Kanäle.
|
||||||
|
SecretStore crypto.SecretStore
|
||||||
|
// SecurityInspector beurteilt die Sicherheitslage.
|
||||||
|
//
|
||||||
|
// Er darf nil sein; dann wird das Security Center nicht eingebunden.
|
||||||
|
SecurityInspector *security.Inspector
|
||||||
|
// RansomwareDetector bewertet Sicherungslaeufe statistisch.
|
||||||
|
//
|
||||||
|
// Er darf nil sein; dann wird die Auffaelligkeitsbewertung nicht eingebunden.
|
||||||
|
RansomwareDetector *ransomware.Detector
|
||||||
|
// ReportGenerator erzeugt die Berichte.
|
||||||
|
//
|
||||||
|
// Er darf nil sein; dann werden die Berichtsrouten nicht eingebunden.
|
||||||
|
ReportGenerator *reports.Generator
|
||||||
|
// AgentTaskStore vermittelt Sicherungsaufträge an Agenten.
|
||||||
|
//
|
||||||
|
// Er darf nil sein; dann werden die Auftragsrouten nicht eingebunden.
|
||||||
|
AgentTaskStore *agenttasks.Store
|
||||||
|
// HypervisorStore verwaltet die Virtualisierungsumgebungen (Phase 7).
|
||||||
|
//
|
||||||
|
// Er darf nil sein; dann werden die Verbundrouten nicht eingebunden. Das
|
||||||
|
// ist der Normalfall ohne Schlüsselmaterial: Ohne SecretStore lassen sich
|
||||||
|
// keine Zugangsdaten ablegen, und ein Verbund ohne Zugangsdaten wäre eine
|
||||||
|
// Maske, die nichts bewirkt.
|
||||||
|
HypervisorStore *hypervisor.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRouter baut den vollständigen HTTP-Handler des API-Dienstes.
|
||||||
|
//
|
||||||
|
// Die Middleware-Reihenfolge ist bewusst gewählt: Recovery und Correlation ID
|
||||||
|
// liegen außen, damit auch Fehler innerer Schichten protokolliert und einer
|
||||||
|
// Operation zugeordnet werden können.
|
||||||
|
func NewRouter(routerDependencies RouterDependencies) http.Handler {
|
||||||
|
requestMultiplexer := http.NewServeMux()
|
||||||
|
|
||||||
|
registerHealthRoutes(requestMultiplexer, routerDependencies)
|
||||||
|
registerAuthRoutes(requestMultiplexer, routerDependencies)
|
||||||
|
registerUserRoutes(requestMultiplexer, routerDependencies)
|
||||||
|
registerRoleRoutes(requestMultiplexer, routerDependencies)
|
||||||
|
registerAgentRoutes(requestMultiplexer, routerDependencies)
|
||||||
|
registerJobRoutes(requestMultiplexer, routerDependencies)
|
||||||
|
registerRestoreRoutes(requestMultiplexer, routerDependencies)
|
||||||
|
registerVerificationRoutes(requestMultiplexer, routerDependencies)
|
||||||
|
registerRetentionRoutes(requestMultiplexer, routerDependencies)
|
||||||
|
registerMetricsRoutes(requestMultiplexer, routerDependencies)
|
||||||
|
registerAlertRoutes(requestMultiplexer, routerDependencies)
|
||||||
|
registerSecurityRoutes(requestMultiplexer, routerDependencies)
|
||||||
|
registerRansomwareRoutes(requestMultiplexer, routerDependencies)
|
||||||
|
registerReportRoutes(requestMultiplexer, routerDependencies)
|
||||||
|
registerHypervisorRoutes(requestMultiplexer, routerDependencies)
|
||||||
|
|
||||||
|
// Alles Unbekannte wird in der Standard-Fehlerhülle beantwortet, damit Clients
|
||||||
|
// nie eine HTML-Fehlerseite von net/http erhalten.
|
||||||
|
requestMultiplexer.HandleFunc("/", func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), routerDependencies.Logger)
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der angefragte Endpunkt existiert nicht."))
|
||||||
|
})
|
||||||
|
|
||||||
|
middlewareChain := []Middleware{
|
||||||
|
RecoveryMiddleware(routerDependencies.Logger),
|
||||||
|
CorrelationMiddleware(),
|
||||||
|
SecurityHeadersMiddleware(),
|
||||||
|
CORSMiddleware(routerDependencies.Config.HTTP.AllowedOrigins),
|
||||||
|
BodyLimitMiddleware(routerDependencies.Config.HTTP.MaxRequestBodyBytes),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der allgemeine Begrenzer schuetzt die gesamte API, nicht nur die
|
||||||
|
// Anmeldung (Phase 19).
|
||||||
|
//
|
||||||
|
// Bis dahin war ausschliesslich der Login begrenzt: Ein angemeldeter
|
||||||
|
// Benutzer — oder ein entwendetes Token — konnte die Anlage mit Anfragen
|
||||||
|
// ueberfluten. Besonders teuer sind die Endpunkte, die im Hintergrund
|
||||||
|
// arbeiten: Ein Bericht erzeugt ein PDF, eine Vorabpruefung liest jeden
|
||||||
|
// Block eines Backups, das Security Center stellt zehn Abfragen.
|
||||||
|
if routerDependencies.Config.HTTP.RequestsPerMinute > 0 {
|
||||||
|
generalRateLimiter := NewRateLimiter(
|
||||||
|
routerDependencies.Config.HTTP.RequestsPerMinute, time.Minute)
|
||||||
|
startRateLimiterCleanup(generalRateLimiter)
|
||||||
|
|
||||||
|
middlewareChain = append(middlewareChain,
|
||||||
|
RateLimitMiddleware(generalRateLimiter, routerDependencies.Logger))
|
||||||
|
}
|
||||||
|
|
||||||
|
middlewareChain = append(middlewareChain, AccessLogMiddleware(routerDependencies.Logger))
|
||||||
|
|
||||||
|
return Chain(requestMultiplexer, middlewareChain...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerHealthRoutes bindet die Betriebs- und Zustandsendpunkte ein.
|
||||||
|
//
|
||||||
|
// Sie sind bewusst ohne Anmeldung erreichbar: ein Load Balancer kann sich nicht
|
||||||
|
// anmelden, und der Zustandsbericht enthält keine vertraulichen Angaben.
|
||||||
|
func registerHealthRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
|
||||||
|
healthHandlerInstance := &healthHandler{
|
||||||
|
healthRegistry: routerDependencies.HealthRegistry,
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "health"),
|
||||||
|
buildVersion: routerDependencies.BuildVersion,
|
||||||
|
environment: routerDependencies.Config.Environment,
|
||||||
|
}
|
||||||
|
|
||||||
|
requestMultiplexer.HandleFunc("GET /health/live", healthHandlerInstance.handleLiveness)
|
||||||
|
requestMultiplexer.HandleFunc("GET /health/ready", healthHandlerInstance.handleReadiness)
|
||||||
|
requestMultiplexer.HandleFunc("GET "+apiBasePath+"/health", healthHandlerInstance.handleSystemHealth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerAuthRoutes bindet Anmeldung und Selbstverwaltung ein.
|
||||||
|
func registerAuthRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
|
||||||
|
authHandlerInstance := &authHandler{
|
||||||
|
authService: routerDependencies.AuthService,
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "auth"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Begrenzer schützt die unauthentifizierten Endpunkte vor dem
|
||||||
|
// Durchprobieren von Zugangsdaten.
|
||||||
|
loginRateLimiter := NewRateLimiter(loginRateLimitAttempts, loginRateLimitWindow)
|
||||||
|
startRateLimiterCleanup(loginRateLimiter)
|
||||||
|
|
||||||
|
rateLimitedChain := func(handlerFunction http.HandlerFunc) http.HandlerFunc {
|
||||||
|
limitedHandler := RateLimitMiddleware(loginRateLimiter, routerDependencies.Logger)(handlerFunction)
|
||||||
|
return limitedHandler.ServeHTTP
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anmeldung: ohne Nachweis erreichbar, aber mengenmäßig begrenzt.
|
||||||
|
requestMultiplexer.HandleFunc("POST "+apiBasePath+"/auth/login", rateLimitedChain(authHandlerInstance.handleLogin))
|
||||||
|
requestMultiplexer.HandleFunc("POST "+apiBasePath+"/auth/mfa/verify", rateLimitedChain(authHandlerInstance.handleVerifyMFA))
|
||||||
|
requestMultiplexer.HandleFunc("POST "+apiBasePath+"/auth/refresh", rateLimitedChain(authHandlerInstance.handleRefresh))
|
||||||
|
|
||||||
|
// Alles Weitere setzt eine gültige Sitzung voraus.
|
||||||
|
authenticated := authenticatedHandler(routerDependencies)
|
||||||
|
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/auth/logout", authenticated(authHandlerInstance.handleLogout))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/me", authenticated(authHandlerInstance.handleGetCurrentUser))
|
||||||
|
|
||||||
|
// Den eigenen zweiten Faktor darf jeder angemeldete Benutzer einrichten;
|
||||||
|
// dafür ist keine gesonderte Berechtigung nötig.
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/me/mfa/enroll", authenticated(authHandlerInstance.handleBeginMFAEnrollment))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/me/mfa/confirm", authenticated(authHandlerInstance.handleConfirmMFAEnrollment))
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerUserRoutes bindet die Benutzerverwaltung ein.
|
||||||
|
func registerUserRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
|
||||||
|
userHandlerInstance := &userHandler{
|
||||||
|
authService: routerDependencies.AuthService,
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "users"),
|
||||||
|
}
|
||||||
|
|
||||||
|
protected := protectedHandler(routerDependencies)
|
||||||
|
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/users", protected("users.read", userHandlerInstance.handleListUsers))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/users", protected("users.write", userHandlerInstance.handleCreateUser))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/users/{id}", protected("users.read", userHandlerInstance.handleGetUser))
|
||||||
|
requestMultiplexer.Handle("PATCH "+apiBasePath+"/users/{id}", protected("users.write", userHandlerInstance.handleUpdateUser))
|
||||||
|
requestMultiplexer.Handle("DELETE "+apiBasePath+"/users/{id}", protected("users.write", userHandlerInstance.handleDeleteUser))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/users/{id}/mfa/disable", protected("users.write", userHandlerInstance.handleDisableUserMFA))
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerRoleRoutes bindet Rollen, Berechtigungen und das Auditprotokoll ein.
|
||||||
|
func registerRoleRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
|
||||||
|
roleHandlerInstance := &roleHandler{
|
||||||
|
repository: routerDependencies.AuthRepository,
|
||||||
|
auditRecorder: routerDependencies.AuditRecorder,
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "roles"),
|
||||||
|
}
|
||||||
|
|
||||||
|
auditHandlerInstance := &auditHandler{
|
||||||
|
auditRecorder: routerDependencies.AuditRecorder,
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "audit"),
|
||||||
|
}
|
||||||
|
|
||||||
|
protected := protectedHandler(routerDependencies)
|
||||||
|
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/roles", protected("roles.read", roleHandlerInstance.handleListRoles))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/roles", protected("roles.write", roleHandlerInstance.handleCreateRole))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/roles/{id}", protected("roles.read", roleHandlerInstance.handleGetRole))
|
||||||
|
requestMultiplexer.Handle("PATCH "+apiBasePath+"/roles/{id}", protected("roles.write", roleHandlerInstance.handleUpdateRole))
|
||||||
|
requestMultiplexer.Handle("DELETE "+apiBasePath+"/roles/{id}", protected("roles.write", roleHandlerInstance.handleDeleteRole))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/permissions", protected("roles.read", roleHandlerInstance.handleListPermissions))
|
||||||
|
|
||||||
|
// Der Zugriff auf das Auditprotokoll ist gesondert beschränkt (SYNCOVA_API.md §17).
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/audit-events", protected("audit.read", auditHandlerInstance.handleListAuditEvents))
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerJobRoutes bindet die Sicherungsaufträge ein (SYNCOVA_API.md §9).
|
||||||
|
func registerJobRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
|
||||||
|
if routerDependencies.JobStore == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobHandlerInstance := &jobHandler{
|
||||||
|
store: routerDependencies.JobStore,
|
||||||
|
auditRecorder: routerDependencies.AuditRecorder,
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "jobs"),
|
||||||
|
}
|
||||||
|
|
||||||
|
protected := protectedHandler(routerDependencies)
|
||||||
|
|
||||||
|
// Die Repositoryliste gehört fachlich zu den Zielen, wird aber vom
|
||||||
|
// Auftrags-Handler bedient: Sie hat kein eigenes Verhalten, und ein zweiter
|
||||||
|
// Handler für eine einzelne lesende Route wäre Aufwand ohne Nutzen.
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/repositories", protected("repositories.read", jobHandlerInstance.handleListRepositories))
|
||||||
|
|
||||||
|
// Eintragen, ansehen, umschalten und prüfen (SYNCOVA_API.md §8).
|
||||||
|
//
|
||||||
|
// Bis Phase 22 fehlten diese Endpunkte vollständig: Ein Repository ließ
|
||||||
|
// sich nur über SQL in die Control Plane bringen. Aufgefallen ist das erst
|
||||||
|
// beim vollständigen Durchlauf — die Anlage war über ihre eigene API nicht
|
||||||
|
// in Betrieb zu nehmen.
|
||||||
|
repositoryHandlerInstance := &repositoryHandler{
|
||||||
|
store: routerDependencies.JobStore,
|
||||||
|
auditRecorder: routerDependencies.AuditRecorder,
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "repositories"),
|
||||||
|
}
|
||||||
|
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/repositories",
|
||||||
|
protected("repositories.write", repositoryHandlerInstance.handleRegisterRepository))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/repositories/{id}",
|
||||||
|
protected("repositories.read", repositoryHandlerInstance.handleGetRepository))
|
||||||
|
requestMultiplexer.Handle("PATCH "+apiBasePath+"/repositories/{id}",
|
||||||
|
protected("repositories.write", repositoryHandlerInstance.handleUpdateRepository))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/test",
|
||||||
|
protected("repositories.read", repositoryHandlerInstance.handleTestRepository))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/health-check",
|
||||||
|
protected("repositories.read", repositoryHandlerInstance.handleRepositoryHealth))
|
||||||
|
|
||||||
|
// Integritätslauf und Katalogaufbau hängen am Schreibrecht: Beide lesen das
|
||||||
|
// gesamte Repository und binden dessen Datenträger für die Dauer des Laufs.
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/integrity-scan",
|
||||||
|
protected("repositories.write", repositoryHandlerInstance.handleRepositoryIntegrityScan))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/rebuild-catalog",
|
||||||
|
protected("repositories.write", repositoryHandlerInstance.handleRebuildCatalog))
|
||||||
|
|
||||||
|
// Die Wiederherstellungspunkte sind die zentrale Auskunft der Anlage. Sie
|
||||||
|
// hängen am Leserecht für Backups, nicht an dem für Aufträge: Wer beurteilen
|
||||||
|
// soll, ob etwas wiederherstellbar ist, braucht keinen Zugriff auf die
|
||||||
|
// Zeitpläne.
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/backups", protected("backups.read", jobHandlerInstance.handleListBackups))
|
||||||
|
|
||||||
|
// Die Übersicht fasst zusammen, was ohnehin lesbar ist, und braucht deshalb
|
||||||
|
// kein eigenes Recht über das Lesen von Backups hinaus.
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/dashboard", protected("backups.read", jobHandlerInstance.handleDashboard))
|
||||||
|
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/jobs", protected("jobs.read", jobHandlerInstance.handleListJobs))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/jobs", protected("jobs.write", jobHandlerInstance.handleCreateJob))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/jobs/{id}", protected("jobs.read", jobHandlerInstance.handleGetJob))
|
||||||
|
|
||||||
|
// Das Löschen verlangt dasselbe Recht wie das Ändern (siehe 000002: die
|
||||||
|
// Beschreibung von jobs.write nennt das Löschen ausdrücklich mit). Es wird
|
||||||
|
// immer auditiert.
|
||||||
|
requestMultiplexer.Handle("DELETE "+apiBasePath+"/jobs/{id}", protected("jobs.write", jobHandlerInstance.handleDeleteJob))
|
||||||
|
|
||||||
|
// Ausführen, Aussetzen und Fortsetzen greifen in den Betrieb ein, ändern
|
||||||
|
// aber keine Konfiguration — dafür gibt es jobs.run.
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/jobs/{id}/run", protected("jobs.run", jobHandlerInstance.handleRunJob))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/jobs/{id}/pause", protected("jobs.run", jobHandlerInstance.handlePauseJob))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/jobs/{id}/resume", protected("jobs.run", jobHandlerInstance.handleResumeJob))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/jobs/{id}/runs", protected("jobs.read", jobHandlerInstance.handleListJobRuns))
|
||||||
|
|
||||||
|
// Der Abbruch eines Laufs steht unter /backup-runs (SYNCOVA_API.md §10):
|
||||||
|
// Er betrifft den Lauf, nicht den Auftrag, und der Aufrufer kennt an dieser
|
||||||
|
// Stelle oft nur die Laufkennung.
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/backup-runs/{id}/cancel", protected("jobs.run", jobHandlerInstance.handleCancelRun))
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerRestoreRoutes bindet die Wiederherstellung ein (SYNCOVA_API.md §13).
|
||||||
|
func registerRestoreRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
|
||||||
|
if routerDependencies.RestoreStore == nil || routerDependencies.JobStore == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreHandlerInstance := &restoreHandler{
|
||||||
|
restoreStore: routerDependencies.RestoreStore,
|
||||||
|
jobStore: routerDependencies.JobStore,
|
||||||
|
auditRecorder: routerDependencies.AuditRecorder,
|
||||||
|
targetGuard: recovery.NewTargetGuard(routerDependencies.Config.Hardening.RestoreAllowedRoots),
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "restores"),
|
||||||
|
}
|
||||||
|
|
||||||
|
protected := protectedHandler(routerDependencies)
|
||||||
|
|
||||||
|
// Die Vorabprüfung schreibt nichts und braucht deshalb nur das Leserecht.
|
||||||
|
// Sie soll niedrigschwellig sein: Wer den Zustand der Backups beurteilen
|
||||||
|
// soll, muss sie ausführen können.
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/validate", protected("restores.read", restoreHandlerInstance.handleValidateRestore))
|
||||||
|
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/restores", protected("restores.read", restoreHandlerInstance.handleListRestores))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/restores", protected("restores.execute", restoreHandlerInstance.handleCreateRestore))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/restores/{id}", protected("restores.read", restoreHandlerInstance.handleGetRestore))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/cancel", protected("restores.execute", restoreHandlerInstance.handleCancelRestore))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/resume", protected("restores.execute", restoreHandlerInstance.handleResumeRestore))
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerVerificationRoutes bindet die Prüfung ein (SYNCOVA_API.md §14).
|
||||||
|
func registerVerificationRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
|
||||||
|
if routerDependencies.VerificationStore == nil || routerDependencies.JobStore == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
verificationHandlerInstance := &verificationHandler{
|
||||||
|
verificationStore: routerDependencies.VerificationStore,
|
||||||
|
jobStore: routerDependencies.JobStore,
|
||||||
|
auditRecorder: routerDependencies.AuditRecorder,
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "verification"),
|
||||||
|
}
|
||||||
|
|
||||||
|
protected := protectedHandler(routerDependencies)
|
||||||
|
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/verification", protected("verification.read", verificationHandlerInstance.handleListVerifications))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/verification", protected("verification.write", verificationHandlerInstance.handleCreateVerification))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/verification/{id}", protected("verification.read", verificationHandlerInstance.handleGetVerification))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/verification/{id}/results", protected("verification.read", verificationHandlerInstance.handleGetVerificationResults))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/verification/{id}/cancel", protected("verification.write", verificationHandlerInstance.handleCancelVerification))
|
||||||
|
|
||||||
|
// Die Bewertung steht am Backup, nicht bei den Prüfungen: Sie beantwortet
|
||||||
|
// eine Frage über das Backup („kann ich mich darauf verlassen?"), nicht über
|
||||||
|
// einen einzelnen Prüflauf. Sie schreibt nichts und braucht deshalb nur das
|
||||||
|
// Leserecht auf Backups.
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/backups/{id}/assurance", protected("backups.read", verificationHandlerInstance.handleGetAssurance))
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerRetentionRoutes bindet Aufbewahrung und Unveränderlichkeit ein (Phase 11).
|
||||||
|
func registerRetentionRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
|
||||||
|
if routerDependencies.RetentionStore == nil || routerDependencies.JobStore == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
retentionHandlerInstance := &retentionHandler{
|
||||||
|
retentionStore: routerDependencies.RetentionStore,
|
||||||
|
jobStore: routerDependencies.JobStore,
|
||||||
|
auditRecorder: routerDependencies.AuditRecorder,
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "retention"),
|
||||||
|
}
|
||||||
|
|
||||||
|
protected := protectedHandler(routerDependencies)
|
||||||
|
|
||||||
|
// Aufbewahrungsregeln.
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/retention-policies", protected("repositories.read", retentionHandlerInstance.handleListPolicies))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/retention-policies", protected("retention.write", retentionHandlerInstance.handleCreatePolicy))
|
||||||
|
requestMultiplexer.Handle("PATCH "+apiBasePath+"/retention-policies/{id}", protected("retention.write", retentionHandlerInstance.handleUpdatePolicy))
|
||||||
|
requestMultiplexer.Handle("DELETE "+apiBasePath+"/retention-policies/{id}", protected("retention.write", retentionHandlerInstance.handleDeletePolicy))
|
||||||
|
|
||||||
|
// Die Vorschau schreibt nichts und braucht deshalb nur das Leserecht. Sie
|
||||||
|
// soll niedrigschwellig sein: Wer wissen will, was eine Regel anrichtet, muss
|
||||||
|
// sie ausprobieren können, ohne sie ausführen zu dürfen.
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/retention/preview", protected("repositories.read", retentionHandlerInstance.handlePreviewRetention))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/retention/apply", protected("retention.write", retentionHandlerInstance.handleApplyRetention))
|
||||||
|
|
||||||
|
// Die Messung fasst nur eine Probedatei an und beantwortet die wichtigste
|
||||||
|
// Frage des gehärteten Betriebs: Was verhindert dieser Speicher wirklich?
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/enforcement/measure", protected("repositories.write", retentionHandlerInstance.handleMeasureEnforcement))
|
||||||
|
|
||||||
|
// Schutzlage eines einzelnen Backups.
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/backups/{id}/protection", protected("backups.read", retentionHandlerInstance.handleGetProtection))
|
||||||
|
|
||||||
|
// Verlängern und Legal Hold hängen an immutability.manage — nicht an
|
||||||
|
// backups.delete. Wer aufräumen darf, darf deshalb noch lange keinen
|
||||||
|
// Aufbewahrungsschutz aufheben.
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/backups/{id}/retention/extend", protected("immutability.manage", retentionHandlerInstance.handleExtendRetention))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/backups/{id}/legal-hold", protected("immutability.manage", retentionHandlerInstance.handlePlaceLegalHold))
|
||||||
|
requestMultiplexer.Handle("DELETE "+apiBasePath+"/backups/{id}/legal-hold", protected("immutability.manage", retentionHandlerInstance.handleReleaseLegalHold))
|
||||||
|
|
||||||
|
// Das Löschen eines einzelnen Backups.
|
||||||
|
requestMultiplexer.Handle("DELETE "+apiBasePath+"/backups/{id}", protected("backups.delete", retentionHandlerInstance.handleDeleteBackup))
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerMetricsRoutes bindet die Kennzahlen und Diagramme ein (Phase 13).
|
||||||
|
func registerMetricsRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
|
||||||
|
if routerDependencies.MetricsStore == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metricsHandlerInstance := &metricsHandler{
|
||||||
|
metricsStore: routerDependencies.MetricsStore,
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "metrics"),
|
||||||
|
}
|
||||||
|
|
||||||
|
protected := protectedHandler(routerDependencies)
|
||||||
|
|
||||||
|
// Die Kennzahlen fassen zusammen, was ohnehin lesbar ist, und hängen deshalb
|
||||||
|
// am Überwachungsrecht statt an einem eigenen.
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/metrics", protected("monitoring.read", metricsHandlerInstance.handleListCharts))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/metrics/{metric}", protected("monitoring.read", metricsHandlerInstance.handleGetChart))
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerAlertRoutes bindet Meldungen und Benachrichtigungen ein (Phase 14).
|
||||||
|
func registerAlertRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
|
||||||
|
if routerDependencies.AlertStore == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
alertHandlerInstance := &alertHandler{
|
||||||
|
alertStore: routerDependencies.AlertStore,
|
||||||
|
secretStore: routerDependencies.SecretStore,
|
||||||
|
auditRecorder: routerDependencies.AuditRecorder,
|
||||||
|
addressGuard: netguard.NewGuard(routerDependencies.Config.Hardening.AllowInternalNotificationTargets),
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "alerts"),
|
||||||
|
}
|
||||||
|
|
||||||
|
protected := protectedHandler(routerDependencies)
|
||||||
|
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/alerts", protected("alerts.read", alertHandlerInstance.handleListAlerts))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/alerts/summary", protected("alerts.read", alertHandlerInstance.handleAlertSummary))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/alerts/{id}", protected("alerts.read", alertHandlerInstance.handleGetAlert))
|
||||||
|
|
||||||
|
// Bestätigen und Auflösen greifen in den Betrieb ein, ändern aber keine
|
||||||
|
// Konfiguration — beides hängt an alerts.write.
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/alerts/{id}/acknowledge", protected("alerts.write", alertHandlerInstance.handleAcknowledgeAlert))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/alerts/{id}/resolve", protected("alerts.write", alertHandlerInstance.handleResolveAlert))
|
||||||
|
|
||||||
|
// Die Kanäle sind Konfiguration und hängen deshalb am Einstellungsrecht: Wer
|
||||||
|
// Benachrichtigungen umleitet, kann erreichen, dass niemand mehr von einem
|
||||||
|
// Ausfall erfährt.
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/notification-channels", protected("settings.read", alertHandlerInstance.handleListChannels))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/notification-channels", protected("settings.write", alertHandlerInstance.handleCreateChannel))
|
||||||
|
requestMultiplexer.Handle("DELETE "+apiBasePath+"/notification-channels/{id}", protected("settings.write", alertHandlerInstance.handleDeleteChannel))
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerSecurityRoutes bindet das Security Center ein (Phase 15).
|
||||||
|
func registerSecurityRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
|
||||||
|
if routerDependencies.SecurityInspector == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
securityHandlerInstance := &securityHandler{
|
||||||
|
inspector: routerDependencies.SecurityInspector,
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "security"),
|
||||||
|
}
|
||||||
|
|
||||||
|
protected := protectedHandler(routerDependencies)
|
||||||
|
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/security", protected("security.read", securityHandlerInstance.handleGetAssessment))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/security/findings", protected("security.read", securityHandlerInstance.handleListFindings))
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerAgentRoutes bindet die Agent-Endpunkte ein (SYNCOVA_API.md §6).
|
||||||
|
func registerAgentRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
|
||||||
|
agentHandlerInstance := &agentHandler{
|
||||||
|
agentService: routerDependencies.AgentService,
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "agents"),
|
||||||
|
}
|
||||||
|
|
||||||
|
protected := protectedHandler(routerDependencies)
|
||||||
|
|
||||||
|
// Verwaltung durch Benutzer.
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/agents", protected("agents.read", agentHandlerInstance.handleListAgents))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/agents/{id}", protected("agents.read", agentHandlerInstance.handleGetAgent))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/agents/{id}/health", protected("agents.read", agentHandlerInstance.handleAgentHealth))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/agents/{id}/revoke", protected("agents.write", agentHandlerInstance.handleRevokeAgent))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/agents/{id}/rotate-credentials", protected("agents.write", agentHandlerInstance.handleRotateCredentials))
|
||||||
|
|
||||||
|
// Die Ausstellung eines Aufnahme-Tokens nimmt ein neues System in die
|
||||||
|
// Infrastruktur auf und ist deshalb an ein eigenes Recht gebunden.
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/agents/enrollment-tokens",
|
||||||
|
protected("agents.enroll", agentHandlerInstance.handleIssueEnrollmentToken))
|
||||||
|
|
||||||
|
// Die Registrierung ist ohne Benutzeranmeldung erreichbar: ein sich
|
||||||
|
// aufnehmender Agent besitzt noch kein Betriebstoken. Sein Nachweis ist
|
||||||
|
// das Aufnahme-Token, das die Domänenlogik prüft.
|
||||||
|
requestMultiplexer.HandleFunc("POST "+apiBasePath+"/agents/register", agentHandlerInstance.handleRegisterAgent)
|
||||||
|
|
||||||
|
// Die Lebendmeldung verlangt das Betriebstoken des Agents, nicht die
|
||||||
|
// Sitzung eines Benutzers.
|
||||||
|
agentAuthenticated := AgentAuthenticationMiddleware(routerDependencies.AgentService, routerDependencies.Logger)
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/agents/heartbeat",
|
||||||
|
agentAuthenticated(http.HandlerFunc(agentHandlerInstance.handleHeartbeat)))
|
||||||
|
|
||||||
|
// Die Auftragsübermittlung hängt ebenfalls am Betriebstoken des Agenten.
|
||||||
|
//
|
||||||
|
// Der Agent **holt** ab; der Server drückt nicht. Ein Agent steht hinter
|
||||||
|
// einer Firewall, oft hinter NAT — die Verbindung geht immer von ihm aus.
|
||||||
|
if routerDependencies.AgentTaskStore != nil {
|
||||||
|
agentTaskHandlerInstance := &agentTaskHandler{
|
||||||
|
taskStore: routerDependencies.AgentTaskStore,
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "agent-tasks"),
|
||||||
|
}
|
||||||
|
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/agents/tasks/claim",
|
||||||
|
agentAuthenticated(http.HandlerFunc(agentTaskHandlerInstance.handleClaimTask)))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/agents/tasks/{id}/progress",
|
||||||
|
agentAuthenticated(http.HandlerFunc(agentTaskHandlerInstance.handleReportProgress)))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/agents/tasks/{id}/result",
|
||||||
|
agentAuthenticated(http.HandlerFunc(agentTaskHandlerInstance.handleReportResult)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticatedHandler baut einen Handler, der eine gültige Sitzung verlangt.
|
||||||
|
func authenticatedHandler(routerDependencies RouterDependencies) func(http.HandlerFunc) http.Handler {
|
||||||
|
return func(handlerFunction http.HandlerFunc) http.Handler {
|
||||||
|
return AuthenticationMiddleware(routerDependencies.AuthService, routerDependencies.Logger)(handlerFunction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// protectedHandler baut einen Handler, der Sitzung und Berechtigung verlangt.
|
||||||
|
//
|
||||||
|
// Beide Prüfungen sind hier untrennbar verbunden: ein Endpunkt lässt sich damit
|
||||||
|
// nicht versehentlich ohne Berechtigungsprüfung einbinden (PROMPT.md §42).
|
||||||
|
func protectedHandler(routerDependencies RouterDependencies) func(string, http.HandlerFunc) http.Handler {
|
||||||
|
return func(requiredPermission string, handlerFunction http.HandlerFunc) http.Handler {
|
||||||
|
permissionCheckedHandler := RequirePermission(
|
||||||
|
requiredPermission,
|
||||||
|
routerDependencies.AuthService,
|
||||||
|
routerDependencies.AuditRecorder,
|
||||||
|
routerDependencies.Logger,
|
||||||
|
handlerFunction,
|
||||||
|
)
|
||||||
|
|
||||||
|
return AuthenticationMiddleware(routerDependencies.AuthService, routerDependencies.Logger)(permissionCheckedHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startRateLimiterCleanup entfernt regelmäßig abgelaufene Einträge.
|
||||||
|
//
|
||||||
|
// Ohne Bereinigung wüchse die Zählerkarte mit jeder neuen Absenderadresse.
|
||||||
|
func startRateLimiterCleanup(limiter *RateLimiter) {
|
||||||
|
cleanupTicker := time.NewTicker(rateLimiterCleanupInterval)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// Die Aufräumroutine läuft für die Lebensdauer des Dienstes.
|
||||||
|
for range cleanupTicker.C {
|
||||||
|
limiter.Cleanup()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerRansomwareRoutes bindet die Auffaelligkeitsbewertung ein.
|
||||||
|
//
|
||||||
|
// Sie haengt an backups.read, nicht an einem eigenen Sicherheitsrecht: Wer die
|
||||||
|
// Backups sehen darf, muss auch erfahren duerfen, ob einer davon aus dem
|
||||||
|
// Rahmen faellt — die Bewertung ist eine Eigenschaft des Laufs, kein Geheimnis.
|
||||||
|
func registerRansomwareRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
|
||||||
|
if routerDependencies.RansomwareDetector == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ransomwareHandlerInstance := &ransomwareHandler{
|
||||||
|
detector: routerDependencies.RansomwareDetector,
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "ransomware"),
|
||||||
|
}
|
||||||
|
|
||||||
|
protected := protectedHandler(routerDependencies)
|
||||||
|
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/backups/{id}/ransomware-assessment",
|
||||||
|
protected("backups.read", ransomwareHandlerInstance.handleGetAssessment))
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerReportRoutes bindet die Berichte ein.
|
||||||
|
//
|
||||||
|
// Beide Routen haengen an reports.read. Die Berechtigung gibt es seit Phase 1;
|
||||||
|
// Viewer und Auditor tragen sie bereits — der Auditor ist genau die Rolle, fuer
|
||||||
|
// die der Bericht fuer Pruefungen geschrieben wurde.
|
||||||
|
func registerReportRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
|
||||||
|
if routerDependencies.ReportGenerator == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reportHandlerInstance := &reportHandler{
|
||||||
|
reportGenerator: routerDependencies.ReportGenerator,
|
||||||
|
auditRecorder: routerDependencies.AuditRecorder,
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "reports"),
|
||||||
|
}
|
||||||
|
|
||||||
|
protected := protectedHandler(routerDependencies)
|
||||||
|
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/reports",
|
||||||
|
protected("reports.read", reportHandlerInstance.handleListReports))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/reports/generate",
|
||||||
|
protected("reports.read", reportHandlerInstance.handleGenerateReport))
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerHypervisorRoutes bindet die Endpunkte der Virtualisierungsumgebungen ein.
|
||||||
|
//
|
||||||
|
// Die Rechte sind die bereits vorhandenen providers.read und providers.write —
|
||||||
|
// ein neues Recht hätte jede bestehende Rollenzuweisung stillschweigend
|
||||||
|
// verändert.
|
||||||
|
func registerHypervisorRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
|
||||||
|
if routerDependencies.HypervisorStore == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hypervisorHandlerInstance := &hypervisorHandler{
|
||||||
|
clusterStore: routerDependencies.HypervisorStore,
|
||||||
|
auditRecorder: routerDependencies.AuditRecorder,
|
||||||
|
logger: logging.WithComponent(routerDependencies.Logger, "hypervisor"),
|
||||||
|
}
|
||||||
|
|
||||||
|
protected := protectedHandler(routerDependencies)
|
||||||
|
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/proxmox/clusters",
|
||||||
|
protected("providers.read", hypervisorHandlerInstance.handleListClusters))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/proxmox/clusters/{id}",
|
||||||
|
protected("providers.read", hypervisorHandlerInstance.handleGetCluster))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/proxmox/clusters",
|
||||||
|
protected("providers.write", hypervisorHandlerInstance.handleCreateCluster))
|
||||||
|
requestMultiplexer.Handle("DELETE "+apiBasePath+"/proxmox/clusters/{id}",
|
||||||
|
protected("providers.write", hypervisorHandlerInstance.handleDeleteCluster))
|
||||||
|
|
||||||
|
// Prüfung und Bestandsaufnahme lesen nur — aber sie bauen eine Verbindung
|
||||||
|
// nach außen auf und erzeugen Last auf dem Verbund. Deshalb hängen sie am
|
||||||
|
// Schreibrecht, nicht am Leserecht.
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/proxmox/clusters/{id}/test",
|
||||||
|
protected("providers.write", hypervisorHandlerInstance.handleTestConnection))
|
||||||
|
requestMultiplexer.Handle("POST "+apiBasePath+"/proxmox/clusters/{id}/discover",
|
||||||
|
protected("providers.write", hypervisorHandlerInstance.handleDiscover))
|
||||||
|
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/proxmox/clusters/{id}/hosts",
|
||||||
|
protected("providers.read", hypervisorHandlerInstance.handleListHosts))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/proxmox/clusters/{id}/vms",
|
||||||
|
protected("providers.read", hypervisorHandlerInstance.handleListClusterMachines))
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/proxmox/vms/{id}",
|
||||||
|
protected("providers.read", hypervisorHandlerInstance.handleGetVirtualMachine))
|
||||||
|
|
||||||
|
// Zusätzlich zur Vertragsliste: der Bestand über alle Verbünde hinweg. Eine
|
||||||
|
// Übersicht, die zuerst nach dem Verbund fragt, ist keine Übersicht.
|
||||||
|
requestMultiplexer.Handle("GET "+apiBasePath+"/virtual-machines",
|
||||||
|
protected("providers.read", hypervisorHandlerInstance.handleListVirtualMachines))
|
||||||
|
}
|
||||||
62
apps/api/internal/httpapi/security_handler.go
Normal file
62
apps/api/internal/httpapi/security_handler.go
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
"github.com/syncova/syncova/packages/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
// securityHandler bedient das Security Center (Phase 15).
|
||||||
|
type securityHandler struct {
|
||||||
|
// inspector prüft die Sicherheitslage.
|
||||||
|
inspector *security.Inspector
|
||||||
|
// logger protokolliert technische Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetAssessment bedient GET /security.
|
||||||
|
//
|
||||||
|
// Die Lage wird bei jedem Aufruf neu erhoben, nicht gelesen: Sie hängt am
|
||||||
|
// Zustand der Anlage und veraltet von selbst. Ein gespeicherter Wert wäre nach
|
||||||
|
// jeder Änderung falsch — dieselbe Überlegung wie bei der Recovery Assurance.
|
||||||
|
func (handler *securityHandler) handleGetAssessment(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
assessment, inspectError := handler.inspector.Inspect(request.Context())
|
||||||
|
if inspectError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(inspectError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"assessment": assessment,
|
||||||
|
"is_trustworthy": assessment.IsTrustworthy(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListFindings bedient GET /security/findings.
|
||||||
|
//
|
||||||
|
// Getrennt von der Gesamtlage, weil die Befunde die eigentliche Arbeitsliste
|
||||||
|
// sind: Sie stehen nach Schweregrad geordnet, damit oben steht, was zuerst zu
|
||||||
|
// tun ist.
|
||||||
|
func (handler *securityHandler) handleListFindings(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
assessment, inspectError := handler.inspector.Inspect(request.Context())
|
||||||
|
if inspectError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(inspectError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allFindings := assessment.AllFindings()
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"findings": allFindings,
|
||||||
|
"finding_count": len(allFindings),
|
||||||
|
"critical_count": assessment.CriticalFindingCount,
|
||||||
|
"high_count": assessment.HighFindingCount,
|
||||||
|
"unchecked_areas": assessment.UncheckedAreas,
|
||||||
|
})
|
||||||
|
}
|
||||||
142
apps/api/internal/httpapi/server.go
Normal file
142
apps/api/internal/httpapi/server.go
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/syncova/syncova/packages/platform/config"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server kapselt den HTTP-Server des API-Dienstes samt geordnetem Herunterfahren.
|
||||||
|
type Server struct {
|
||||||
|
// httpServer ist der zugrunde liegende Standard-Server.
|
||||||
|
httpServer *http.Server
|
||||||
|
// listener ist der gebundene Netzwerk-Listener.
|
||||||
|
listener net.Listener
|
||||||
|
// logger protokolliert Start und Stopp des Servers.
|
||||||
|
logger *slog.Logger
|
||||||
|
// shutdownTimeout ist die Frist für laufende Requests beim Herunterfahren.
|
||||||
|
shutdownTimeout config.HTTPConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer bindet die konfigurierte Adresse und bereitet den Server vor.
|
||||||
|
//
|
||||||
|
// Das Binden erfolgt bereits hier, damit ein belegter Port sofort als Fehler
|
||||||
|
// sichtbar wird und nicht erst im Hintergrund auftaucht.
|
||||||
|
func NewServer(httpConfig config.HTTPConfig, requestHandler http.Handler, baseLogger *slog.Logger) (*Server, error) {
|
||||||
|
networkListener, listenError := net.Listen("tcp", httpConfig.ListenAddress)
|
||||||
|
if listenError != nil {
|
||||||
|
return nil, fmt.Errorf("HTTP-Adresse %q konnte nicht gebunden werden: %w", httpConfig.ListenAddress, listenError)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Handler: requestHandler,
|
||||||
|
ReadHeaderTimeout: httpConfig.ReadHeaderTimeout,
|
||||||
|
ReadTimeout: httpConfig.ReadTimeout,
|
||||||
|
WriteTimeout: httpConfig.WriteTimeout,
|
||||||
|
IdleTimeout: httpConfig.IdleTimeout,
|
||||||
|
// Fehler des Servers laufen über das strukturierte Logging statt über
|
||||||
|
// die Standardausgabe, damit sie auswertbar bleiben.
|
||||||
|
ErrorLog: slog.NewLogLogger(logging.WithComponent(baseLogger, "http").Handler(), slog.LevelError),
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLS wird eingerichtet, sobald Zertifikat und Schlüssel vorliegen.
|
||||||
|
//
|
||||||
|
// Die Mindestversion ist 1.2, und Renegotiation bleibt aus: Beides sind
|
||||||
|
// die Vorgaben von Go, hier ausdrücklich festgeschrieben, damit eine
|
||||||
|
// künftige Lockerung der Vorgaben diese Anlage nicht mitzieht.
|
||||||
|
if httpConfig.TLSCertificateFile != "" {
|
||||||
|
serverCertificate, certificateError := tls.LoadX509KeyPair(
|
||||||
|
httpConfig.TLSCertificateFile, httpConfig.TLSPrivateKeyFile)
|
||||||
|
if certificateError != nil {
|
||||||
|
_ = networkListener.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("das TLS-Zertifikat konnte nicht geladen werden: %w", certificateError)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpServer.TLSConfig = &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{serverCertificate},
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
httpServer: httpServer,
|
||||||
|
listener: networkListener,
|
||||||
|
logger: logging.WithComponent(baseLogger, "http"),
|
||||||
|
shutdownTimeout: httpConfig,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveConnections bedient Verbindungen, verschlüsselt oder im Klartext.
|
||||||
|
func (server *Server) serveConnections() error {
|
||||||
|
if server.httpServer.TLSConfig != nil {
|
||||||
|
return server.httpServer.ServeTLS(server.listener, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return server.httpServer.Serve(server.listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsesTLS meldet einen verschlüsselten Dienst.
|
||||||
|
func (server *Server) UsesTLS() bool {
|
||||||
|
return server.httpServer.TLSConfig != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address liefert die tatsächlich gebundene Adresse.
|
||||||
|
//
|
||||||
|
// Bei Port 0 weist das Betriebssystem einen freien Port zu; Tests brauchen
|
||||||
|
// deshalb die effektive Adresse statt der konfigurierten.
|
||||||
|
func (server *Server) Address() string {
|
||||||
|
return server.listener.Addr().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve bedient Requests, bis der Context abgebrochen wird.
|
||||||
|
//
|
||||||
|
// Danach läuft ein geordnetes Herunterfahren: laufende Requests dürfen innerhalb
|
||||||
|
// der konfigurierten Frist zu Ende laufen.
|
||||||
|
func (server *Server) Serve(runContext context.Context) error {
|
||||||
|
// serveErrorChannel ist gepuffert, damit die Goroutine auch dann endet,
|
||||||
|
// wenn zuerst der Context abgebrochen wird.
|
||||||
|
serveErrorChannel := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
server.logger.Info("http server gestartet", slog.String("address", server.Address()))
|
||||||
|
|
||||||
|
// ServeTLS verschlüsselt, sobald eine TLS-Konfiguration vorliegt. Die
|
||||||
|
// Dateinamen sind leer, weil das Zertifikat bereits geladen ist.
|
||||||
|
serveError := server.serveConnections()
|
||||||
|
// ErrServerClosed ist die erwartete Folge eines geordneten Shutdowns.
|
||||||
|
if errors.Is(serveError, http.ErrServerClosed) {
|
||||||
|
serveErrorChannel <- nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serveErrorChannel <- serveError
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case serveError := <-serveErrorChannel:
|
||||||
|
return serveError
|
||||||
|
|
||||||
|
case <-runContext.Done():
|
||||||
|
server.logger.Info("http server wird beendet")
|
||||||
|
|
||||||
|
// Der Shutdown-Context hängt bewusst nicht am abgebrochenen runContext,
|
||||||
|
// sonst bliebe für laufende Requests keine Zeit mehr.
|
||||||
|
shutdownContext, cancelShutdownContext := context.WithTimeout(context.Background(), server.shutdownTimeout.ShutdownTimeout)
|
||||||
|
defer cancelShutdownContext()
|
||||||
|
|
||||||
|
if shutdownError := server.httpServer.Shutdown(shutdownContext); shutdownError != nil {
|
||||||
|
return fmt.Errorf("http server konnte nicht geordnet beendet werden: %w", shutdownError)
|
||||||
|
}
|
||||||
|
|
||||||
|
server.logger.Info("http server beendet")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
239
apps/api/internal/httpapi/user_handler.go
Normal file
239
apps/api/internal/httpapi/user_handler.go
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/syncova/syncova/packages/auth"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Grenzen der Seitengröße.
|
||||||
|
//
|
||||||
|
// Eine Obergrenze verhindert, dass ein einzelner Aufruf die Datenbank und den
|
||||||
|
// Speicher des Dienstes belastet.
|
||||||
|
const (
|
||||||
|
// defaultPageSize ist die Seitengröße ohne ausdrückliche Angabe.
|
||||||
|
defaultPageSize = 50
|
||||||
|
// maxPageSize ist die größte erlaubte Seitengröße.
|
||||||
|
maxPageSize = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
// userHandler bedient die Benutzerverwaltung (SYNCOVA_API.md §4).
|
||||||
|
type userHandler struct {
|
||||||
|
// authService ist die Domänenlogik der Identitätsverwaltung.
|
||||||
|
authService *auth.Service
|
||||||
|
// logger protokolliert technische Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// createUserRequest ist der Rumpf von POST /users.
|
||||||
|
type createUserRequest struct {
|
||||||
|
// Username ist der gewünschte Anmeldename.
|
||||||
|
Username string `json:"username"`
|
||||||
|
// Email ist die optionale Mailadresse.
|
||||||
|
Email string `json:"email"`
|
||||||
|
// Password ist das Anfangspasswort.
|
||||||
|
Password string `json:"password"`
|
||||||
|
// Roles sind die zuzuweisenden Rollennamen.
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateUserRequest ist der Rumpf von PATCH /users/{id}.
|
||||||
|
//
|
||||||
|
// Alle Felder sind Zeiger: nur ausdrücklich gesendete Werte werden geändert.
|
||||||
|
type updateUserRequest struct {
|
||||||
|
// Email ist die neue Mailadresse.
|
||||||
|
Email *string `json:"email"`
|
||||||
|
// Status ist der neue Kontozustand.
|
||||||
|
Status *string `json:"status"`
|
||||||
|
// Password ist das neue Passwort.
|
||||||
|
Password *string `json:"password"`
|
||||||
|
// Roles sind die neuen Rollen; sie ersetzen die bisherigen vollständig.
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListUsers bedient GET /users.
|
||||||
|
func (handler *userHandler) handleListUsers(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
page, pageSize := parsePagination(request)
|
||||||
|
searchTerm := request.URL.Query().Get("search")
|
||||||
|
|
||||||
|
loadedUsers, totalCount, listError := handler.authService.ListUsers(request.Context(), searchTerm, page, pageSize)
|
||||||
|
if listError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WritePaginatedSuccess(responseWriter, request, loadedUsers, PaginationMeta{
|
||||||
|
Page: page, PageSize: pageSize, Total: totalCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateUser bedient POST /users.
|
||||||
|
func (handler *userHandler) handleCreateUser(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
var createPayload createUserRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &createPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createdUser, createError := handler.authService.CreateUser(request.Context(), auth.CreateUserRequest{
|
||||||
|
Username: createPayload.Username,
|
||||||
|
Email: createPayload.Email,
|
||||||
|
Password: createPayload.Password,
|
||||||
|
RoleNames: createPayload.Roles,
|
||||||
|
}, actingUser, RequestContextFrom(request))
|
||||||
|
|
||||||
|
if createError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAuthError(createError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusCreated, createdUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetUser bedient GET /users/{id}.
|
||||||
|
func (handler *userHandler) handleGetUser(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
userID, parseError := parsePathUUID(request, "id")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedUser, loadError := handler.authService.GetUser(request.Context(), userID)
|
||||||
|
if loadError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAuthError(loadError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, loadedUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateUser bedient PATCH /users/{id}.
|
||||||
|
func (handler *userHandler) handleUpdateUser(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
userID, parseError := parsePathUUID(request, "id")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updatePayload updateUserRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &updatePayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Zustand wird gegen die erlaubten Werte geprüft, bevor er die Domäne erreicht.
|
||||||
|
var newStatus *auth.UserStatus
|
||||||
|
if updatePayload.Status != nil {
|
||||||
|
parsedStatus := auth.UserStatus(*updatePayload.Status)
|
||||||
|
switch parsedStatus {
|
||||||
|
case auth.UserStatusActive, auth.UserStatusDisabled, auth.UserStatusLocked:
|
||||||
|
newStatus = &parsedStatus
|
||||||
|
default:
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewValidationError("Der Status muss active, disabled oder locked sein."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedUser, updateError := handler.authService.UpdateUser(request.Context(), userID, auth.UpdateUserRequest{
|
||||||
|
Email: updatePayload.Email,
|
||||||
|
Status: newStatus,
|
||||||
|
Password: updatePayload.Password,
|
||||||
|
RoleNames: updatePayload.Roles,
|
||||||
|
}, actingUser, RequestContextFrom(request))
|
||||||
|
|
||||||
|
if updateError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAuthError(updateError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, updatedUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteUser bedient DELETE /users/{id}.
|
||||||
|
func (handler *userHandler) handleDeleteUser(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
userID, parseError := parsePathUUID(request, "id")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if deleteError := handler.authService.DeleteUser(request.Context(), userID, actingUser, RequestContextFrom(request)); deleteError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAuthError(deleteError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusNoContent, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDisableUserMFA bedient POST /users/{id}/mfa/disable.
|
||||||
|
//
|
||||||
|
// Der Weg dient dem Fall, dass ein Benutzer sein Gerät verloren hat.
|
||||||
|
func (handler *userHandler) handleDisableUserMFA(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
userID, parseError := parsePathUUID(request, "id")
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if disableError := handler.authService.DisableMFA(request.Context(), userID, actingUser, RequestContextFrom(request)); disableError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateAuthError(disableError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{"mfa_enabled": false})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePagination liest Seitennummer und Seitengröße aus der Anfrage.
|
||||||
|
func parsePagination(request *http.Request) (page int, pageSize int) {
|
||||||
|
page = 1
|
||||||
|
pageSize = defaultPageSize
|
||||||
|
|
||||||
|
if rawPage := request.URL.Query().Get("page"); rawPage != "" {
|
||||||
|
if parsedPage, parseError := strconv.Atoi(rawPage); parseError == nil && parsedPage > 0 {
|
||||||
|
page = parsedPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rawPageSize := request.URL.Query().Get("page_size"); rawPageSize != "" {
|
||||||
|
if parsedPageSize, parseError := strconv.Atoi(rawPageSize); parseError == nil && parsedPageSize > 0 {
|
||||||
|
// Eine übergroße Angabe wird gekappt statt abgelehnt: der Aufrufer
|
||||||
|
// erhält Daten, und der Dienst bleibt geschützt.
|
||||||
|
pageSize = min(parsedPageSize, maxPageSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return page, pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePathUUID liest eine UUID aus dem Anfragepfad.
|
||||||
|
func parsePathUUID(request *http.Request, parameterName string) (uuid.UUID, *APIError) {
|
||||||
|
rawValue := request.PathValue(parameterName)
|
||||||
|
|
||||||
|
parsedUUID, parseError := uuid.Parse(rawValue)
|
||||||
|
if parseError != nil {
|
||||||
|
return uuid.Nil, NewBadRequestError("Die angegebene Kennung ist keine gültige UUID.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedUUID, nil
|
||||||
|
}
|
||||||
459
apps/api/internal/httpapi/verification_handler.go
Normal file
459
apps/api/internal/httpapi/verification_handler.go
Normal file
@ -0,0 +1,459 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/syncova/syncova/packages/audit"
|
||||||
|
"github.com/syncova/syncova/packages/auth"
|
||||||
|
"github.com/syncova/syncova/packages/jobs"
|
||||||
|
"github.com/syncova/syncova/packages/platform/logging"
|
||||||
|
"github.com/syncova/syncova/packages/verification"
|
||||||
|
)
|
||||||
|
|
||||||
|
// verificationHandler bedient die Pruefung (SYNCOVA_API.md §14).
|
||||||
|
type verificationHandler struct {
|
||||||
|
// verificationStore ist die Datenzugriffsschicht der Pruefauftraege.
|
||||||
|
verificationStore *verification.Store
|
||||||
|
// jobStore liefert Backups und Repositories.
|
||||||
|
jobStore *jobs.PostgresStore
|
||||||
|
// auditRecorder protokolliert ausgeloeste Pruefungen.
|
||||||
|
auditRecorder audit.Recorder
|
||||||
|
// logger protokolliert technische Fehler.
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// verificationRequest ist der Rumpf von POST /verification.
|
||||||
|
type verificationRequest struct {
|
||||||
|
// BackupID ist das zu pruefende Backup.
|
||||||
|
BackupID uuid.UUID `json:"backup_id"`
|
||||||
|
// VerificationType ist die Art der Pruefung.
|
||||||
|
VerificationType string `json:"verification_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// verificationResponse ist die Darstellung eines Pruefauftrags.
|
||||||
|
type verificationResponse struct {
|
||||||
|
// ID ist der oeffentliche Bezeichner.
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
// BackupID ist das gepruefte Backup.
|
||||||
|
BackupID uuid.UUID `json:"backup_id"`
|
||||||
|
// VerificationType ist die Art der Pruefung.
|
||||||
|
VerificationType string `json:"verification_type"`
|
||||||
|
// Status ist der Zustand.
|
||||||
|
Status string `json:"status"`
|
||||||
|
// Result ist das Ergebnis; leer solange nicht abgeschlossen.
|
||||||
|
Result string `json:"result,omitempty"`
|
||||||
|
// ChunksChecked ist die Zahl gepruefter Bloecke.
|
||||||
|
ChunksChecked int64 `json:"chunks_checked"`
|
||||||
|
// ChunksMissing ist die Zahl fehlender Bloecke.
|
||||||
|
ChunksMissing int64 `json:"chunks_missing"`
|
||||||
|
// ChunksCorrupted ist die Zahl beschaedigter Bloecke.
|
||||||
|
ChunksCorrupted int64 `json:"chunks_corrupted"`
|
||||||
|
// BytesRead ist die gelesene Datenmenge.
|
||||||
|
BytesRead int64 `json:"bytes_read"`
|
||||||
|
// StartedAt ist der Beginn in UTC.
|
||||||
|
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||||
|
// CompletedAt ist das Ende in UTC.
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
// DurationSeconds ist die Dauer in Sekunden.
|
||||||
|
DurationSeconds float64 `json:"duration_seconds,omitempty"`
|
||||||
|
// ErrorMessage ist die verstaendliche Fehlermeldung.
|
||||||
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
|
// Summary fasst das Ergebnis in einem Satz zusammen.
|
||||||
|
//
|
||||||
|
// Die Zusammenfassung sagt ausdruecklich, was **nicht** geprueft wurde: Eine
|
||||||
|
// Manifestpruefung ohne diesen Zusatz liesse sich fuer einen Nachweis der
|
||||||
|
// Wiederherstellbarkeit halten, der sie nicht ist.
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
// CorrelationID verbindet den Auftrag mit seinen Protokollzeilen.
|
||||||
|
CorrelationID uuid.UUID `json:"correlation_id"`
|
||||||
|
// CreatedAt ist der Anlagezeitpunkt in UTC.
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// assuranceResponse ist die Bewertung eines Backups.
|
||||||
|
type assuranceResponse struct {
|
||||||
|
// BackupID ist das bewertete Backup.
|
||||||
|
BackupID uuid.UUID `json:"backup_id"`
|
||||||
|
// Classification ist die objektive Einstufung.
|
||||||
|
Classification string `json:"classification"`
|
||||||
|
// ClassificationDescription erklaert die Einstufung.
|
||||||
|
ClassificationDescription string `json:"classification_description"`
|
||||||
|
// Percentage ist die Bewertung in Prozent.
|
||||||
|
Percentage int `json:"percentage"`
|
||||||
|
// UnknownInputCount ist die Zahl ungemessener Eingangsgroessen.
|
||||||
|
UnknownInputCount int `json:"unknown_input_count"`
|
||||||
|
// IsTrustworthy meldet eine belastbare Bewertung.
|
||||||
|
IsTrustworthy bool `json:"is_trustworthy"`
|
||||||
|
// Summary fasst die Bewertung in einem Satz zusammen.
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
// MissingMeasurements nennt die fehlenden Messungen als Handlungsanweisung.
|
||||||
|
MissingMeasurements []string `json:"missing_measurements"`
|
||||||
|
// Inputs sind die einzelnen Eingangsgroessen.
|
||||||
|
Inputs []verification.ScoreInput `json:"inputs"`
|
||||||
|
// LastVerifiedAt ist die letzte Integritaetspruefung in UTC.
|
||||||
|
LastVerifiedAt *time.Time `json:"last_verified_at,omitempty"`
|
||||||
|
// LastRestoreTestAt ist der letzte Wiederherstellungstest in UTC.
|
||||||
|
LastRestoreTestAt *time.Time `json:"last_restore_test_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateVerification bedient POST /verification.
|
||||||
|
//
|
||||||
|
// Die Pruefung wird eingereiht, nicht ausgefuehrt: Die Pruefschleife holt sie im
|
||||||
|
// naechsten Durchgang. Deshalb 202 statt 201 — das Ergebnis steht noch aus.
|
||||||
|
func (handler *verificationHandler) handleCreateVerification(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
var verificationPayload verificationRequest
|
||||||
|
if decodeError := decodeJSONBody(request, &verificationPayload); decodeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, decodeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedType, typeError := parseVerificationType(verificationPayload.VerificationType)
|
||||||
|
if typeError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, typeError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Wiederherstellungstest liest das gesamte Backup und schreibt es
|
||||||
|
// versuchsweise zurueck. Er belastet Datentraeger und Leitung erheblich und
|
||||||
|
// haengt deshalb an einem eigenen Recht.
|
||||||
|
if requestedType == verification.TypeRestoreTest && !userHasPermission(actingUser, "verification.restore_test") {
|
||||||
|
permissionError := NewValidationError(
|
||||||
|
"Fuer einen Wiederherstellungstest fehlt die Berechtigung verification.restore_test.")
|
||||||
|
permissionError.Code = ErrorCodePermissionDenied
|
||||||
|
permissionError.StatusCode = http.StatusForbidden
|
||||||
|
|
||||||
|
WriteError(responseWriter, request, requestLogger, permissionError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Das Backup wird vor dem Einreihen aufgeloest: Eine Pruefung eines nicht
|
||||||
|
// vorhandenen Backups liefe erst in der Schleife auf, wo niemand die Antwort
|
||||||
|
// sieht.
|
||||||
|
backupRecord, backupError := handler.jobStore.GetBackup(request.Context(), verificationPayload.BackupID)
|
||||||
|
if backupError != nil {
|
||||||
|
if errors.Is(backupError, jobs.ErrBackupNotFound) {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewNotFoundError("Das Backup wurde nicht gefunden."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(backupError))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createdJob, createError := handler.verificationStore.CreateJob(request.Context(),
|
||||||
|
verificationPayload.BackupID, requestedType, &actingUser.ID)
|
||||||
|
if createError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateVerificationError(createError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionVerificationRequested, verificationPayload.BackupID,
|
||||||
|
map[string]any{
|
||||||
|
"verification_id": createdJob.ID.String(),
|
||||||
|
"verification_type": string(requestedType),
|
||||||
|
"backup_in_repository": backupRecord.BackupIDInRepository,
|
||||||
|
})
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusAccepted, buildVerificationResponse(createdJob))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListVerifications bedient GET /verification.
|
||||||
|
func (handler *verificationHandler) handleListVerifications(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
requestedPage := parsePositiveInteger(request.URL.Query().Get("page"), 1)
|
||||||
|
requestedPageSize := parsePositiveInteger(request.URL.Query().Get("page_size"), 50)
|
||||||
|
|
||||||
|
loadedJobs, totalCount, listError := handler.verificationStore.ListJobs(
|
||||||
|
request.Context(), requestedPage, requestedPageSize)
|
||||||
|
if listError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jobResponses := make([]verificationResponse, 0, len(loadedJobs))
|
||||||
|
for jobIndex := range loadedJobs {
|
||||||
|
jobResponses = append(jobResponses, buildVerificationResponse(&loadedJobs[jobIndex]))
|
||||||
|
}
|
||||||
|
|
||||||
|
WritePaginatedSuccess(responseWriter, request, jobResponses, PaginationMeta{
|
||||||
|
Page: requestedPage,
|
||||||
|
PageSize: requestedPageSize,
|
||||||
|
Total: int64(totalCount),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetVerification bedient GET /verification/{id}.
|
||||||
|
func (handler *verificationHandler) handleGetVerification(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
jobIdentifier, parseError := parseVerificationIdentifier(request)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedJob, readError := handler.verificationStore.GetJob(request.Context(), jobIdentifier)
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateVerificationError(readError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, buildVerificationResponse(loadedJob))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetVerificationResults bedient GET /verification/{id}/results.
|
||||||
|
//
|
||||||
|
// Getrennt vom Auftrag, weil der vollstaendige Bericht bei einem grossen Backup
|
||||||
|
// viele Befunde traegt. Eine Liste von Auftraegen bliebe damit nicht mehr
|
||||||
|
// ueberschaubar.
|
||||||
|
func (handler *verificationHandler) handleGetVerificationResults(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
jobIdentifier, parseError := parseVerificationIdentifier(request)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadedJob, readError := handler.verificationStore.GetJob(request.Context(), jobIdentifier)
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateVerificationError(readError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if loadedJob.Report == nil {
|
||||||
|
// Kein Bericht heisst: Die Pruefung ist nicht so weit gekommen. Ein leeres
|
||||||
|
// Ergebnis auszugeben liesse das wie ein sauberes Ergebnis aussehen.
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewNotFoundError(
|
||||||
|
"Fuer diese Pruefung liegt kein Bericht vor. Sie laeuft noch oder konnte nicht durchgefuehrt werden."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resultPayload := map[string]any{
|
||||||
|
"verification_id": loadedJob.ID,
|
||||||
|
"backup_id": loadedJob.BackupID,
|
||||||
|
"verification_type": string(loadedJob.VerificationType),
|
||||||
|
"result": string(loadedJob.Result),
|
||||||
|
"summary": loadedJob.Report.Summary(),
|
||||||
|
"report": loadedJob.Report,
|
||||||
|
}
|
||||||
|
|
||||||
|
if loadedJob.RestoreTestReport != nil {
|
||||||
|
resultPayload["restore_test"] = loadedJob.RestoreTestReport
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, resultPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCancelVerification bedient POST /verification/{id}/cancel.
|
||||||
|
func (handler *verificationHandler) handleCancelVerification(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
actingUser, _ := AuthenticatedUserFromContext(request.Context())
|
||||||
|
|
||||||
|
jobIdentifier, parseError := parseVerificationIdentifier(request)
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, parseError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existingJob, readError := handler.verificationStore.GetJob(request.Context(), jobIdentifier)
|
||||||
|
if readError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateVerificationError(readError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if cancelError := handler.verificationStore.CancelJob(request.Context(), jobIdentifier); cancelError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, translateVerificationError(cancelError))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.recordAudit(request, actingUser, audit.ActionVerificationCancelled, existingJob.BackupID,
|
||||||
|
map[string]any{
|
||||||
|
"verification_id": jobIdentifier.String(),
|
||||||
|
"verification_type": string(existingJob.VerificationType),
|
||||||
|
})
|
||||||
|
|
||||||
|
cancelledJob, _ := handler.verificationStore.GetJob(request.Context(), jobIdentifier)
|
||||||
|
|
||||||
|
// Die abgebrochene Pruefung sagt nichts ueber das Backup. Das wird gesagt,
|
||||||
|
// damit niemand den Abbruch fuer ein Ergebnis haelt.
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
|
||||||
|
"verification": buildVerificationResponse(cancelledJob),
|
||||||
|
"message": "Die Pruefung wurde abgebrochen. Sie sagt damit nichts ueber den Zustand des Backups; " +
|
||||||
|
"die bisherige Einstufung bleibt unveraendert.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetAssurance bedient GET /backups/{id}/assurance.
|
||||||
|
//
|
||||||
|
// Die Bewertung wird bei jedem Aufruf neu berechnet, nicht aus der Datenbank
|
||||||
|
// gelesen: Sie haengt am Alter der Messungen und veraltet damit von selbst. Ein
|
||||||
|
// gespeicherter Wert wuerde mit jedem Tag falscher, ohne dass sich etwas
|
||||||
|
// aendert — genau die stille Beschoenigung, die es hier nicht geben darf.
|
||||||
|
func (handler *verificationHandler) handleGetAssurance(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
|
requestLogger := logging.WithContext(request.Context(), handler.logger)
|
||||||
|
|
||||||
|
backupIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger,
|
||||||
|
NewBadRequestError("Die Backupkennung ist keine gueltige UUID."))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
backupFacts, factsError := handler.verificationStore.BackupAssuranceFacts(request.Context(), backupIdentifier)
|
||||||
|
if factsError != nil {
|
||||||
|
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Das Backup wurde nicht gefunden."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
classification := verification.Classify(backupFacts)
|
||||||
|
assuranceScore := verification.CalculateScore(backupFacts, time.Now())
|
||||||
|
|
||||||
|
// Die berechnete Bewertung wird fortgeschrieben, damit eine Uebersicht sie
|
||||||
|
// anzeigen kann, ohne sie fuer jede Zeile neu zu berechnen. Massgeblich
|
||||||
|
// bleibt die Berechnung — der gespeicherte Wert ist nur ihr Abbild.
|
||||||
|
if saveError := handler.verificationStore.SaveAssuranceScore(request.Context(),
|
||||||
|
backupIdentifier, assuranceScore); saveError != nil {
|
||||||
|
requestLogger.Warn("die bewertung konnte nicht gespeichert werden",
|
||||||
|
slog.String("backup", backupIdentifier.String()),
|
||||||
|
slog.String("grund", saveError.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(responseWriter, request, http.StatusOK, assuranceResponse{
|
||||||
|
BackupID: backupIdentifier,
|
||||||
|
Classification: string(classification),
|
||||||
|
ClassificationDescription: classification.Describe(),
|
||||||
|
Percentage: assuranceScore.Percentage,
|
||||||
|
UnknownInputCount: assuranceScore.UnknownInputCount,
|
||||||
|
IsTrustworthy: assuranceScore.IsTrustworthy(),
|
||||||
|
Summary: assuranceScore.Summary(),
|
||||||
|
MissingMeasurements: assuranceScore.MissingMeasurements(),
|
||||||
|
Inputs: assuranceScore.Inputs,
|
||||||
|
LastVerifiedAt: backupFacts.LastVerifiedAt,
|
||||||
|
LastRestoreTestAt: backupFacts.LastRestoreTestAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordAudit schreibt ein Auditereignis.
|
||||||
|
func (handler *verificationHandler) recordAudit(request *http.Request, actingUser auth.User, auditAction audit.Action, backupIdentifier uuid.UUID, auditDetails map[string]any) {
|
||||||
|
if handler.auditRecorder == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
correlationID, _ := logging.CorrelationIDFromContext(request.Context())
|
||||||
|
|
||||||
|
recordError := handler.auditRecorder.Record(request.Context(), audit.Event{
|
||||||
|
UserID: &actingUser.ID,
|
||||||
|
ActorUsername: actingUser.Username,
|
||||||
|
Action: auditAction,
|
||||||
|
EntityType: "backup",
|
||||||
|
EntityID: &backupIdentifier,
|
||||||
|
Result: audit.ResultSuccess,
|
||||||
|
IPAddress: clientIPAddress(request),
|
||||||
|
UserAgent: request.UserAgent(),
|
||||||
|
CorrelationID: correlationID,
|
||||||
|
Details: auditDetails,
|
||||||
|
})
|
||||||
|
|
||||||
|
if recordError != nil {
|
||||||
|
logging.WithContext(request.Context(), handler.logger).Error(
|
||||||
|
"das auditereignis konnte nicht geschrieben werden",
|
||||||
|
slog.String("aktion", string(auditAction)),
|
||||||
|
slog.String("grund", recordError.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildVerificationResponse wandelt einen Auftrag in seine Darstellung.
|
||||||
|
func buildVerificationResponse(sourceJob *verification.Job) verificationResponse {
|
||||||
|
builtResponse := verificationResponse{
|
||||||
|
ID: sourceJob.ID,
|
||||||
|
BackupID: sourceJob.BackupID,
|
||||||
|
VerificationType: string(sourceJob.VerificationType),
|
||||||
|
Status: string(sourceJob.Status),
|
||||||
|
Result: string(sourceJob.Result),
|
||||||
|
ChunksChecked: sourceJob.ChunksChecked,
|
||||||
|
ChunksMissing: sourceJob.ChunksMissing,
|
||||||
|
ChunksCorrupted: sourceJob.ChunksCorrupted,
|
||||||
|
BytesRead: sourceJob.BytesRead,
|
||||||
|
StartedAt: sourceJob.StartedAt,
|
||||||
|
CompletedAt: sourceJob.CompletedAt,
|
||||||
|
ErrorMessage: sourceJob.ErrorMessage,
|
||||||
|
CorrelationID: sourceJob.CorrelationID,
|
||||||
|
CreatedAt: sourceJob.CreatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if sourceJob.Report != nil {
|
||||||
|
builtResponse.Summary = sourceJob.Report.Summary()
|
||||||
|
}
|
||||||
|
|
||||||
|
if sourceJob.StartedAt != nil && sourceJob.CompletedAt != nil {
|
||||||
|
builtResponse.DurationSeconds = sourceJob.CompletedAt.Sub(*sourceJob.StartedAt).Seconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
return builtResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVerificationType prueft die angeforderte Pruefart.
|
||||||
|
func parseVerificationType(requestedType string) (verification.VerificationType, *APIError) {
|
||||||
|
switch verification.VerificationType(requestedType) {
|
||||||
|
case verification.TypeManifest, verification.TypeChunkPresence,
|
||||||
|
verification.TypeChunkIntegrity, verification.TypeChain, verification.TypeRestoreTest:
|
||||||
|
return verification.VerificationType(requestedType), nil
|
||||||
|
|
||||||
|
case "":
|
||||||
|
// Ohne Angabe wird die Blockpruefung gewaehlt: Sie ist die schwaechste
|
||||||
|
// Pruefung, die ueberhaupt etwas ueber die Daten aussagt. Die
|
||||||
|
// Manifestpruefung als Vorgabe waere bequem und wertlos.
|
||||||
|
return verification.TypeChunkIntegrity, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "", NewValidationError(
|
||||||
|
"Die Pruefart ist unbekannt. Zulaessig sind manifest, chunk_presence, " +
|
||||||
|
"chunk_integrity, chain und restore_test.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVerificationIdentifier liest die Auftragskennung aus dem Pfad.
|
||||||
|
func parseVerificationIdentifier(request *http.Request) (uuid.UUID, *APIError) {
|
||||||
|
jobIdentifier, parseError := uuid.Parse(request.PathValue("id"))
|
||||||
|
if parseError != nil {
|
||||||
|
return uuid.Nil, NewBadRequestError("Die Pruefkennung ist keine gueltige UUID.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return jobIdentifier, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// translateVerificationError bildet Fehler der Fachschicht auf API-Fehler ab.
|
||||||
|
func translateVerificationError(occurredError error) *APIError {
|
||||||
|
switch {
|
||||||
|
case errors.Is(occurredError, verification.ErrJobNotFound):
|
||||||
|
return NewNotFoundError("Der Pruefauftrag wurde nicht gefunden.")
|
||||||
|
|
||||||
|
case errors.Is(occurredError, verification.ErrBackupBusy):
|
||||||
|
// 409 und nicht 500: Der Aufrufer hat nichts falsch gemacht, das Backup
|
||||||
|
// wird nur bereits geprueft.
|
||||||
|
conflictError := NewValidationError(
|
||||||
|
"Dieses Backup wird bereits geprueft. Warten Sie das Ergebnis ab.")
|
||||||
|
conflictError.Code = ErrorCodeConflict
|
||||||
|
conflictError.StatusCode = http.StatusConflict
|
||||||
|
|
||||||
|
return conflictError
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NewInternalError(occurredError)
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/web/eslint.config.js
Normal file
46
apps/web/eslint.config.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// ESLint-Konfiguration der Syncova-Weboberflaeche.
|
||||||
|
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import typescriptEslint from 'typescript-eslint';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
|
||||||
|
export default typescriptEslint.config(
|
||||||
|
// Gebaute Artefakte werden nicht geprueft.
|
||||||
|
{ ignores: ['dist', 'node_modules', 'coverage'] },
|
||||||
|
|
||||||
|
js.configs.recommended,
|
||||||
|
...typescriptEslint.configs.recommended,
|
||||||
|
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
globals: { ...globals.browser, ...globals.es2022 },
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
|
||||||
|
// Unbenutzte Bezeichner deuten auf unfertigen Code hin.
|
||||||
|
// Ein fuehrender Unterstrich kennzeichnet eine bewusst ignorierte Variable.
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
|
||||||
|
// console.log gehoert nicht in ausgelieferten Code; Warnungen und Fehler schon.
|
||||||
|
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||||
|
|
||||||
|
// Ein stillschweigend verschluckter Fehler widerspricht PROMPT.md §140.
|
||||||
|
'no-empty': ['error', { allowEmptyCatch: false }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
// In Tests sind Vitest-Globals verfuegbar.
|
||||||
|
files: ['**/*.test.{ts,tsx}', 'src/test/**/*.ts'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser, ...globals.node },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
14
apps/web/index.html
Normal file
14
apps/web/index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<!-- Die Oberflaeche darf nicht von Suchmaschinen erfasst werden. -->
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<title>Syncova</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4603
apps/web/package-lock.json
generated
Normal file
4603
apps/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
apps/web/package.json
Normal file
36
apps/web/package.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "@syncova/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Syncova Web UI - Verwaltungsoberflaeche der Backup- und Recovery-Plattform",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"lint": "eslint . --max-warnings 0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.0",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^14.6.3",
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"eslint": "^9.39.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^28.0.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.2",
|
||||||
|
"vite": "^7.1.12",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1117
apps/web/src/App.css
Normal file
1117
apps/web/src/App.css
Normal file
File diff suppressed because it is too large
Load Diff
176
apps/web/src/App.tsx
Normal file
176
apps/web/src/App.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Wurzelkomponente der Syncova-Oberflaeche.
|
||||||
|
*
|
||||||
|
* Die Anwendung entscheidet zwischen Anmeldemaske und angemeldeter Ansicht und
|
||||||
|
* verteilt die angemeldete Ansicht auf die Bereiche aus PROMPT.md §28.
|
||||||
|
*
|
||||||
|
* Der Umgang mit unfertigen Bereichen ist die eine Entscheidung, die diese Datei
|
||||||
|
* traegt: Sie erscheinen im Menue, aber deaktiviert und mit der Angabe, was
|
||||||
|
* fehlt. Ein Menue nur aus fertigen Bereichen verschweigt den Ausbaustand; eines
|
||||||
|
* mit leeren Masken taeuscht ihn vor (PROMPT.md §139).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { setAccessTokenProvider } from './api/client';
|
||||||
|
import { SystemHealthPanel } from './features/health/SystemHealthPanel';
|
||||||
|
import { JobsPanel } from './features/jobs/JobsPanel';
|
||||||
|
import { LoginPage } from './features/auth/LoginPage';
|
||||||
|
import { getAccessToken, logout } from './features/auth/authApi';
|
||||||
|
import { DashboardPage } from './features/dashboard/DashboardPage';
|
||||||
|
import { RecoveryPointsPage } from './features/dashboard/RecoveryPointsPage';
|
||||||
|
import {
|
||||||
|
AgentsPage,
|
||||||
|
EventsPage,
|
||||||
|
RepositoriesPage,
|
||||||
|
RestoresPage,
|
||||||
|
} from './features/inventory/InventoryPages';
|
||||||
|
import { RolesPage, UsersPage } from './features/identity/IdentityPages';
|
||||||
|
import { MetricsPage } from './features/metrics/MetricsPage';
|
||||||
|
import { AlertsPage } from './features/alerts/AlertsPage';
|
||||||
|
import { ReportsPage } from './features/reports/ReportsPage';
|
||||||
|
import { SecurityPage } from './features/security/SecurityPage';
|
||||||
|
import { NavigationSidebar } from './navigation/NavigationSidebar';
|
||||||
|
import { UnavailablePage } from './navigation/UnavailablePage';
|
||||||
|
import { DEFAULT_PAGE_ID, findPage, mayViewPage } from './navigation/pages';
|
||||||
|
import { useCurrentPage } from './navigation/useCurrentPage';
|
||||||
|
import type { CurrentUser } from './types/auth';
|
||||||
|
import './App.css';
|
||||||
|
|
||||||
|
// Der API-Client erhaelt seinen Tokenzugriff einmalig beim Laden des Moduls.
|
||||||
|
setAccessTokenProvider(getAccessToken);
|
||||||
|
|
||||||
|
/** Baut das Grundlayout der Anwendung. */
|
||||||
|
export function App(): React.JSX.Element {
|
||||||
|
const [authenticatedUser, setAuthenticatedUser] = useState<CurrentUser | null>(null);
|
||||||
|
const { currentPageId, navigateToPage } = useCurrentPage();
|
||||||
|
|
||||||
|
if (authenticatedUser === null) {
|
||||||
|
return <LoginPage onAuthenticated={setAuthenticatedUser} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meldet den Benutzer ab und kehrt zur Anmeldemaske zurueck. */
|
||||||
|
async function handleLogout(): Promise<void> {
|
||||||
|
await logout();
|
||||||
|
setAuthenticatedUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grantedPermissions = authenticatedUser.permissions ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<header className="app-shell__header">
|
||||||
|
<span className="app-shell__brand">Syncova</span>
|
||||||
|
|
||||||
|
<div className="app-shell__account">
|
||||||
|
<span className="app-shell__username">{authenticatedUser.username}</span>
|
||||||
|
|
||||||
|
{/* Ein fehlender zweiter Faktor ist ein Sicherheitsbefund und wird
|
||||||
|
benannt, statt ihn zu verschweigen (PROMPT.md §90). */}
|
||||||
|
{!authenticatedUser.mfa_enabled && (
|
||||||
|
<span
|
||||||
|
className="app-shell__warning"
|
||||||
|
title="Fuer dieses Konto ist kein zweiter Faktor eingerichtet."
|
||||||
|
>
|
||||||
|
MFA fehlt
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="app-shell__logout" type="button" onClick={() => void handleLogout()}>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="app-shell__body">
|
||||||
|
<NavigationSidebar
|
||||||
|
currentPageId={currentPageId}
|
||||||
|
grantedPermissions={grantedPermissions}
|
||||||
|
onNavigate={navigateToPage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="app-shell__main">
|
||||||
|
<CurrentPageContent currentPageId={currentPageId} grantedPermissions={grantedPermissions} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften des Seiteninhalts. */
|
||||||
|
interface CurrentPageContentProperties {
|
||||||
|
/** Bezeichner der angezeigten Seite. */
|
||||||
|
readonly currentPageId: string;
|
||||||
|
/** Berechtigungen des angemeldeten Benutzers. */
|
||||||
|
readonly grantedPermissions: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt den Inhalt der gewaehlten Seite. */
|
||||||
|
function CurrentPageContent({
|
||||||
|
currentPageId,
|
||||||
|
grantedPermissions,
|
||||||
|
}: CurrentPageContentProperties): React.JSX.Element {
|
||||||
|
const pageDefinition = findPage(currentPageId) ?? findPage(DEFAULT_PAGE_ID);
|
||||||
|
|
||||||
|
if (pageDefinition === undefined) {
|
||||||
|
return <DashboardPage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Anzeigepruefung ersetzt die serverseitige nicht, sie ergaenzt sie: Ohne
|
||||||
|
// sie liefe der Aufruf in eine Fehlermeldung statt in eine verstaendliche
|
||||||
|
// Auskunft (PROMPT.md §42).
|
||||||
|
if (!mayViewPage(pageDefinition, grantedPermissions)) {
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page__header">
|
||||||
|
<h1 className="page__title">{pageDefinition.label}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="notice notice--warning">
|
||||||
|
<p className="notice__text">
|
||||||
|
Fuer diesen Bereich fehlt die Berechtigung {pageDefinition.requiredPermission}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pageDefinition.available) {
|
||||||
|
return <UnavailablePage pageDefinition={pageDefinition} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (pageDefinition.id) {
|
||||||
|
case 'dashboard':
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DashboardPage />
|
||||||
|
<SystemHealthPanel />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'jobs':
|
||||||
|
return <JobsPanel />;
|
||||||
|
case 'metrics':
|
||||||
|
return <MetricsPage />;
|
||||||
|
case 'alerts':
|
||||||
|
return <AlertsPage />;
|
||||||
|
case 'security':
|
||||||
|
return <SecurityPage />;
|
||||||
|
case 'reports':
|
||||||
|
return <ReportsPage />;
|
||||||
|
case 'recovery-points':
|
||||||
|
return <RecoveryPointsPage />;
|
||||||
|
case 'restores':
|
||||||
|
return <RestoresPage />;
|
||||||
|
case 'repositories':
|
||||||
|
return <RepositoriesPage />;
|
||||||
|
case 'agents':
|
||||||
|
return <AgentsPage />;
|
||||||
|
case 'events':
|
||||||
|
return <EventsPage />;
|
||||||
|
case 'users':
|
||||||
|
return <UsersPage />;
|
||||||
|
case 'roles':
|
||||||
|
return <RolesPage />;
|
||||||
|
default:
|
||||||
|
return <DashboardPage />;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
apps/web/src/api/client.test.ts
Normal file
104
apps/web/src/api/client.test.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ApiError, MALFORMED_RESPONSE_CODE, NETWORK_ERROR_CODE, requestApi } from './client';
|
||||||
|
|
||||||
|
/** Baut eine Antwort, wie sie das Backend liefern wuerde. */
|
||||||
|
function buildJsonResponse(responseBody: unknown, statusCode: number): Response {
|
||||||
|
return new Response(JSON.stringify(responseBody), {
|
||||||
|
status: statusCode,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requestApi', () => {
|
||||||
|
it('gibt die Nutzlast aus der Standard-Huelle zurueck', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
buildJsonResponse({ data: { status: 'healthy' }, meta: { request_id: 'abc' } }, 200),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadedPayload = await requestApi<{ status: string }>('/health');
|
||||||
|
|
||||||
|
expect(loadedPayload).toEqual({ status: 'healthy' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendet eine Correlation ID mit', async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(buildJsonResponse({ data: {}, meta: { request_id: 'abc' } }, 200));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await requestApi('/health');
|
||||||
|
|
||||||
|
// Ohne Correlation ID liesse sich eine Operation nicht Ende-zu-Ende verfolgen.
|
||||||
|
const firstCall = fetchMock.mock.calls[0];
|
||||||
|
expect(firstCall).toBeDefined();
|
||||||
|
|
||||||
|
const sentHeaders = (firstCall?.[1] as RequestInit).headers as Record<string, string>;
|
||||||
|
expect(sentHeaders['X-Correlation-ID']).toMatch(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wandelt eine Fehlerantwort in einen ApiError um', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
buildJsonResponse(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
code: 'REPOSITORY_UNAVAILABLE',
|
||||||
|
message: 'Das Repository ist nicht erreichbar.',
|
||||||
|
request_id: 'req-42',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
503,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(requestApi('/repositories')).rejects.toMatchObject({
|
||||||
|
code: 'REPOSITORY_UNAVAILABLE',
|
||||||
|
statusCode: 503,
|
||||||
|
requestId: 'req-42',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet eine nicht erreichbare API verstaendlich', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
|
||||||
|
|
||||||
|
// Ein Netzwerkfehler darf nicht als leere Antwort durchgehen.
|
||||||
|
await expect(requestApi('/health')).rejects.toMatchObject({ code: NETWORK_ERROR_CODE });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt eine Erfolgsantwort ohne data-Feld ab', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ meta: {} }, 200)));
|
||||||
|
|
||||||
|
// Eine Antwort ausserhalb des Vertrags darf nicht stillschweigend
|
||||||
|
// als leeres Ergebnis interpretiert werden (PROMPT.md §140).
|
||||||
|
await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt eine Fehlerantwort ohne Fehlerkoerper ab', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(buildJsonResponse({ unerwartet: true }, 500)));
|
||||||
|
|
||||||
|
await expect(requestApi('/health')).rejects.toMatchObject({ code: MALFORMED_RESPONSE_CODE });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reicht einen Abbruch unveraendert durch', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError')),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ein Abbruch ist Folge des Aufraeumens und kein Fehlerfall.
|
||||||
|
await expect(requestApi('/health')).rejects.toSatisfy(
|
||||||
|
(thrownError: unknown) => thrownError instanceof DOMException && !(thrownError instanceof ApiError),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
299
apps/web/src/api/client.ts
Normal file
299
apps/web/src/api/client.ts
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* HTTP-Client fuer die Syncova-API.
|
||||||
|
*
|
||||||
|
* Der Client kapselt die Antworthuelle des Backends und liefert Fehler stets als
|
||||||
|
* ApiError. Aufrufer muessen sich damit nicht mit HTTP-Details befassen und es
|
||||||
|
* kann keine Fehlerantwort versehentlich als Nutzlast interpretiert werden
|
||||||
|
* (PROMPT.md §140: keine stillen Fehler).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ErrorResponse, SuccessResponse } from '../types/api';
|
||||||
|
|
||||||
|
/** Basis-Pfad aller fachlichen Endpunkte (SYNCOVA_API.md). */
|
||||||
|
const API_BASE_PATH = '/api/v1';
|
||||||
|
|
||||||
|
/** Header, ueber den eine Operation Ende-zu-Ende verfolgt wird (PROMPT.md §50). */
|
||||||
|
const CORRELATION_ID_HEADER = 'X-Correlation-ID';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fehler einer API-Anfrage.
|
||||||
|
*
|
||||||
|
* Er traegt den maschinenlesbaren Code und die Request-ID, damit ein Anwender
|
||||||
|
* einen Vorfall gegenueber dem Betreiber eindeutig benennen kann.
|
||||||
|
*/
|
||||||
|
export class ApiError extends Error {
|
||||||
|
/** Stabiler maschinenlesbarer Fehlercode. */
|
||||||
|
public readonly code: string;
|
||||||
|
/** HTTP-Statuscode der Antwort. */
|
||||||
|
public readonly statusCode: number;
|
||||||
|
/** Kennung des Requests zur Zuordnung im Serverlog. */
|
||||||
|
public readonly requestId: string;
|
||||||
|
/** Optionale unbedenkliche Zusatzinformationen. */
|
||||||
|
public readonly details: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
constructor(parameters: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
statusCode: number;
|
||||||
|
requestId: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
super(parameters.message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.code = parameters.code;
|
||||||
|
this.statusCode = parameters.statusCode;
|
||||||
|
this.requestId = parameters.requestId;
|
||||||
|
this.details = parameters.details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert das aktuelle Zugriffstoken, sofern eine Sitzung besteht.
|
||||||
|
*
|
||||||
|
* Der Client kennt die Anmeldelogik bewusst nicht, sondern erhaelt sie ueber
|
||||||
|
* diese Funktion. Andernfalls entstuende ein Zirkelbezug zwischen dem Client und
|
||||||
|
* dem Anmeldemodul, das seinerseits den Client verwendet.
|
||||||
|
*/
|
||||||
|
let accessTokenProvider: () => string | null = () => null;
|
||||||
|
|
||||||
|
/** Hinterlegt die Funktion, die das aktuelle Zugriffstoken liefert. */
|
||||||
|
export function setAccessTokenProvider(tokenProvider: () => string | null): void {
|
||||||
|
accessTokenProvider = tokenProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fehlercode fuer eine nicht erreichbare API. */
|
||||||
|
export const NETWORK_ERROR_CODE = 'NETWORK_UNREACHABLE';
|
||||||
|
|
||||||
|
/** Fehlercode fuer eine unverstaendliche Antwort. */
|
||||||
|
export const MALFORMED_RESPONSE_CODE = 'MALFORMED_RESPONSE';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt eine Correlation ID fuer einen Request.
|
||||||
|
*
|
||||||
|
* crypto.randomUUID ist in allen unterstuetzten Browsern verfuegbar; der
|
||||||
|
* Rueckfall deckt aeltere Testumgebungen ab.
|
||||||
|
*/
|
||||||
|
function createCorrelationId(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rueckfall ohne kryptografische Garantie - die Correlation ID dient allein
|
||||||
|
// der Nachvollziehbarkeit, nicht der Sicherheit.
|
||||||
|
return `00000000-0000-4000-8000-${Date.now().toString(16).padStart(12, '0').slice(-12)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optionen einer API-Anfrage. */
|
||||||
|
export interface RequestOptions {
|
||||||
|
/** HTTP-Methode; Standard ist GET. */
|
||||||
|
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||||
|
/** Optionaler Anfragekoerper, der als JSON gesendet wird. */
|
||||||
|
body?: unknown;
|
||||||
|
/** Signal zum Abbrechen der Anfrage. */
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuehrt eine Anfrage gegen die Syncova-API aus.
|
||||||
|
*
|
||||||
|
* @param endpointPath Pfad unterhalb von /api/v1, z. B. "/health".
|
||||||
|
* @returns Die Nutzlast der Antwort.
|
||||||
|
* @throws ApiError bei jedem Fehlerfall.
|
||||||
|
*/
|
||||||
|
export async function requestApi<TPayload>(
|
||||||
|
endpointPath: string,
|
||||||
|
requestOptions: RequestOptions = {},
|
||||||
|
): Promise<TPayload> {
|
||||||
|
const correlationId = createCorrelationId();
|
||||||
|
const requestMethod = requestOptions.method ?? 'GET';
|
||||||
|
|
||||||
|
const requestHeaders: Record<string, string> = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
[CORRELATION_ID_HEADER]: correlationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (requestOptions.body !== undefined) {
|
||||||
|
requestHeaders['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Besteht eine Sitzung, wird sie mitgesendet. Ohne Token laufen die Anfragen
|
||||||
|
// unauthentifiziert - der Server entscheidet dann ueber den Zugriff.
|
||||||
|
const accessToken = accessTokenProvider();
|
||||||
|
if (accessToken !== null) {
|
||||||
|
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let httpResponse: Response;
|
||||||
|
try {
|
||||||
|
httpResponse = await fetch(`${API_BASE_PATH}${endpointPath}`, {
|
||||||
|
method: requestMethod,
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: requestOptions.body === undefined ? null : JSON.stringify(requestOptions.body),
|
||||||
|
// Die Sitzung laeuft ueber ein Cookie bzw. einen Token desselben Ursprungs.
|
||||||
|
credentials: 'same-origin',
|
||||||
|
...(requestOptions.signal ? { signal: requestOptions.signal } : {}),
|
||||||
|
});
|
||||||
|
} catch (networkError) {
|
||||||
|
// Ein Abbruch durch den Aufrufer ist kein Fehlerfall und wird durchgereicht.
|
||||||
|
if (networkError instanceof DOMException && networkError.name === 'AbortError') {
|
||||||
|
throw networkError;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError({
|
||||||
|
code: NETWORK_ERROR_CODE,
|
||||||
|
message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.',
|
||||||
|
statusCode: 0,
|
||||||
|
requestId: correlationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 204 traegt per Definition keinen Koerper.
|
||||||
|
if (httpResponse.status === 204) {
|
||||||
|
return undefined as TPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedBody: unknown;
|
||||||
|
try {
|
||||||
|
parsedBody = await httpResponse.json();
|
||||||
|
} catch {
|
||||||
|
throw new ApiError({
|
||||||
|
code: MALFORMED_RESPONSE_CODE,
|
||||||
|
message: 'Die Antwort des Servers war unverstaendlich.',
|
||||||
|
statusCode: httpResponse.status,
|
||||||
|
requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Massgeblich ist die Antworthuelle, nicht allein der HTTP-Status.
|
||||||
|
//
|
||||||
|
// Beide Angaben tragen unterschiedliche Aussagen: der Status beschreibt den
|
||||||
|
// Betriebszustand, die Huelle den Inhalt. GET /api/v1/health nutzt genau diese
|
||||||
|
// Trennung und meldet einen kritischen Systemzustand mit 503, liefert dabei
|
||||||
|
// aber einen vollstaendigen Bericht als Nutzlast. Wuerde der Client jeden
|
||||||
|
// Status ausserhalb von 2xx als inhaltsleeren Fehler behandeln, ginge
|
||||||
|
// ausgerechnet die Diagnose verloren, die der Anwender jetzt braucht.
|
||||||
|
const errorResponse = parsedBody as Partial<ErrorResponse>;
|
||||||
|
if (errorResponse.error) {
|
||||||
|
throw new ApiError({
|
||||||
|
code: errorResponse.error.code,
|
||||||
|
message: errorResponse.error.message,
|
||||||
|
statusCode: httpResponse.status,
|
||||||
|
requestId: errorResponse.error.request_id,
|
||||||
|
...(errorResponse.error.details ? { details: errorResponse.error.details } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const successResponse = parsedBody as Partial<SuccessResponse<TPayload>>;
|
||||||
|
if (successResponse.data === undefined) {
|
||||||
|
throw new ApiError({
|
||||||
|
code: MALFORMED_RESPONSE_CODE,
|
||||||
|
message: httpResponse.ok
|
||||||
|
? 'Die Antwort des Servers enthielt keine Daten.'
|
||||||
|
: 'Der Server meldete einen Fehler ohne verwertbare Beschreibung.',
|
||||||
|
statusCode: httpResponse.status,
|
||||||
|
requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return successResponse.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laedt eine Datei von der API herunter.
|
||||||
|
*
|
||||||
|
* Sie steht neben requestApi und nicht darin: Eine Datei traegt **keine**
|
||||||
|
* Antworthuelle, sondern ist der Inhalt selbst. Wuerde man sie durch requestApi
|
||||||
|
* schicken, versuchte dieser, ein PDF als JSON zu lesen, und meldete eine
|
||||||
|
* unverstaendliche Antwort — obwohl alles in Ordnung ist.
|
||||||
|
*
|
||||||
|
* Der Fehlerfall geht dagegen sehr wohl durch die Huelle: Scheitert die Anfrage,
|
||||||
|
* antwortet der Server mit JSON. Deshalb wird der Inhaltstyp geprueft, bevor die
|
||||||
|
* Antwort als Datei behandelt wird — sonst landete eine Fehlermeldung als
|
||||||
|
* „bericht.pdf" im Download-Ordner, und der Anwender saehe statt einer Meldung
|
||||||
|
* eine kaputte Datei.
|
||||||
|
*/
|
||||||
|
export async function downloadApiFile(
|
||||||
|
endpointPath: string,
|
||||||
|
requestOptions: RequestOptions = {},
|
||||||
|
): Promise<{ blob: Blob; fileName: string }> {
|
||||||
|
const correlationId = createCorrelationId();
|
||||||
|
|
||||||
|
const requestHeaders: Record<string, string> = {
|
||||||
|
[CORRELATION_ID_HEADER]: correlationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (requestOptions.body !== undefined) {
|
||||||
|
requestHeaders['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = accessTokenProvider();
|
||||||
|
if (accessToken !== null) {
|
||||||
|
requestHeaders['Authorization'] = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let httpResponse: Response;
|
||||||
|
try {
|
||||||
|
httpResponse = await fetch(`${API_BASE_PATH}${endpointPath}`, {
|
||||||
|
method: requestOptions.method ?? 'POST',
|
||||||
|
headers: requestHeaders,
|
||||||
|
body: requestOptions.body === undefined ? null : JSON.stringify(requestOptions.body),
|
||||||
|
credentials: 'same-origin',
|
||||||
|
...(requestOptions.signal ? { signal: requestOptions.signal } : {}),
|
||||||
|
});
|
||||||
|
} catch (networkError) {
|
||||||
|
if (networkError instanceof DOMException && networkError.name === 'AbortError') {
|
||||||
|
throw networkError;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError({
|
||||||
|
code: NETWORK_ERROR_CODE,
|
||||||
|
message: 'Syncova ist derzeit nicht erreichbar. Bitte Netzwerkverbindung und Dienststatus pruefen.',
|
||||||
|
statusCode: 0,
|
||||||
|
requestId: correlationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseContentType = httpResponse.headers.get('Content-Type') ?? '';
|
||||||
|
|
||||||
|
// Eine JSON-Antwort auf eine Dateianfrage ist immer ein Fehler.
|
||||||
|
if (responseContentType.includes('application/json')) {
|
||||||
|
const parsedBody = (await httpResponse.json()) as Partial<ErrorResponse>;
|
||||||
|
|
||||||
|
throw new ApiError({
|
||||||
|
code: parsedBody.error?.code ?? MALFORMED_RESPONSE_CODE,
|
||||||
|
message: parsedBody.error?.message ?? 'Die Datei konnte nicht erzeugt werden.',
|
||||||
|
statusCode: httpResponse.status,
|
||||||
|
requestId: parsedBody.error?.request_id ?? correlationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!httpResponse.ok) {
|
||||||
|
throw new ApiError({
|
||||||
|
code: MALFORMED_RESPONSE_CODE,
|
||||||
|
message: 'Die Datei konnte nicht erzeugt werden.',
|
||||||
|
statusCode: httpResponse.status,
|
||||||
|
requestId: httpResponse.headers.get('X-Request-ID') ?? correlationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blob: await httpResponse.blob(),
|
||||||
|
fileName: parseFileNameFromDisposition(httpResponse.headers.get('Content-Disposition')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest den Dateinamen aus dem Content-Disposition-Kopf.
|
||||||
|
*
|
||||||
|
* Ohne verwertbaren Kopf bleibt der Name leer und der Aufrufer waehlt einen —
|
||||||
|
* ein erfundener Name aus dem Kopf zu lesen waere schlimmer als keiner.
|
||||||
|
*/
|
||||||
|
function parseFileNameFromDisposition(dispositionHeader: string | null): string {
|
||||||
|
if (dispositionHeader === null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileNameMatch = /filename="([^"]+)"/.exec(dispositionHeader);
|
||||||
|
|
||||||
|
return fileNameMatch === null ? '' : (fileNameMatch[1] ?? '');
|
||||||
|
}
|
||||||
121
apps/web/src/api/useApiResource.ts
Normal file
121
apps/web/src/api/useApiResource.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Allgemeiner Lade-Hook fuer API-Ressourcen.
|
||||||
|
*
|
||||||
|
* Er folgt demselben Muster wie useSystemHealth: Solange kein echtes Ergebnis
|
||||||
|
* vorliegt, bleibt der Zustand ausdruecklich „laedt" oder „Fehler" — niemals ein
|
||||||
|
* leeres Ergebnis, das sich von einem echten leeren nicht unterscheiden liesse
|
||||||
|
* (PROMPT.md §139).
|
||||||
|
*
|
||||||
|
* Der Hook ersetzt die Wiederholung derselben dreissig Zeilen in jeder Seite.
|
||||||
|
* Genau deshalb steht er hier und nicht in einer der Seiten: Ein zweiter Ort mit
|
||||||
|
* eigener Fehlerbehandlung waere ein zweiter Ort, an dem sie fehlen kann.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ApiError } from './client';
|
||||||
|
|
||||||
|
/** Ladezustand einer Ressource. */
|
||||||
|
export type ResourceLoadState = 'loading' | 'loaded' | 'failed';
|
||||||
|
|
||||||
|
/** Ergebnis des Lade-Hooks. */
|
||||||
|
export interface UseApiResourceResult<TPayload> {
|
||||||
|
/** Aktueller Ladezustand. */
|
||||||
|
readonly loadState: ResourceLoadState;
|
||||||
|
/** Geladene Daten; null, solange keine vorliegen. */
|
||||||
|
readonly data: TPayload | null;
|
||||||
|
/** Aufgetretener Fehler; null, wenn keiner vorliegt. */
|
||||||
|
readonly loadError: ApiError | null;
|
||||||
|
/** Laedt die Ressource erneut. */
|
||||||
|
readonly reload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laedt eine Ressource und haelt ihren Zustand.
|
||||||
|
*
|
||||||
|
* @param loadResource Ladefunktion; sie erhaelt ein Abbruchsignal.
|
||||||
|
* @param dependencyKey Aendert sich dieser Wert, wird neu geladen. Ein einzelner
|
||||||
|
* Schluessel statt eines Abhaengigkeitsarrays: Ein Array mit wechselnder Laenge
|
||||||
|
* ist in React ein Fehler, und ein Objekt als Abhaengigkeit laedt bei jedem
|
||||||
|
* Rendern neu.
|
||||||
|
*/
|
||||||
|
export function useApiResource<TPayload>(
|
||||||
|
loadResource: (abortSignal: AbortSignal) => Promise<TPayload>,
|
||||||
|
dependencyKey = '',
|
||||||
|
): UseApiResourceResult<TPayload> {
|
||||||
|
// reloadCounter erzwingt einen erneuten Lauf des Effekts bei manuellem Neuladen.
|
||||||
|
const [reloadCounter, setReloadCounter] = useState(0);
|
||||||
|
|
||||||
|
// Das Ergebnis traegt den Schluessel, unter dem es entstanden ist. Daraus
|
||||||
|
// laesst sich der Ladezustand **ableiten**, statt ihn im Effekt zu setzen:
|
||||||
|
// Passt der Schluessel nicht zum aktuellen, laeuft die Anfrage noch. Ein
|
||||||
|
// setState im Effektkoerper loeste dagegen eine zweite Renderrunde aus,
|
||||||
|
// bevor ueberhaupt etwas geladen wurde.
|
||||||
|
const [loadResult, setLoadResult] = useState<{
|
||||||
|
key: string;
|
||||||
|
data: TPayload | null;
|
||||||
|
error: ApiError | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const effectiveKey = `${dependencyKey}#${String(reloadCounter)}`;
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
setReloadCounter((previousCounter) => previousCounter + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
async function loadFromApi(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const loadedPayload = await loadResource(abortController.signal);
|
||||||
|
|
||||||
|
setLoadResult({ key: effectiveKey, data: loadedPayload, error: null });
|
||||||
|
} catch (caughtError) {
|
||||||
|
// Ein Abbruch ist kein Fehler, sondern Folge des Aufraeumens.
|
||||||
|
if (caughtError instanceof DOMException && caughtError.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadResult({
|
||||||
|
key: effectiveKey,
|
||||||
|
data: null,
|
||||||
|
error:
|
||||||
|
caughtError instanceof ApiError
|
||||||
|
? caughtError
|
||||||
|
: new ApiError({
|
||||||
|
code: 'UNEXPECTED_ERROR',
|
||||||
|
message: 'Die Anfrage ist unerwartet fehlgeschlagen.',
|
||||||
|
statusCode: 0,
|
||||||
|
requestId: '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadFromApi();
|
||||||
|
|
||||||
|
return () => abortController.abort();
|
||||||
|
// loadResource bewusst nicht in den Abhaengigkeiten: Eine bei jedem Rendern
|
||||||
|
// neu gebildete Funktion loeste sonst eine Endlosschleife aus. Der
|
||||||
|
// effectiveKey steuert das Neuladen ausdruecklich.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [effectiveKey]);
|
||||||
|
|
||||||
|
// Solange kein Ergebnis zum aktuellen Schluessel vorliegt, wird geladen. Die
|
||||||
|
// vorherigen Daten bleiben dabei sichtbar — ein Filterwechsel laesst die
|
||||||
|
// Tabelle also nicht aufblitzen.
|
||||||
|
if (loadResult === null || loadResult.key !== effectiveKey) {
|
||||||
|
return {
|
||||||
|
loadState: 'loading',
|
||||||
|
data: loadResult?.data ?? null,
|
||||||
|
loadError: null,
|
||||||
|
reload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadResult.error !== null) {
|
||||||
|
return { loadState: 'failed', data: null, loadError: loadResult.error, reload };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { loadState: 'loaded', data: loadResult.data, loadError: null, reload };
|
||||||
|
}
|
||||||
64
apps/web/src/components/PageState.tsx
Normal file
64
apps/web/src/components/PageState.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Gemeinsame Zustandsanzeigen der Seiten.
|
||||||
|
*
|
||||||
|
* Laden, Fehler und Leere sehen ueberall gleich aus — und vor allem: Leere wird
|
||||||
|
* ausdruecklich als Leere benannt. Eine Tabelle ohne Zeilen und ohne Hinweis
|
||||||
|
* laesst offen, ob es nichts gibt oder ob etwas schiefging.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ApiError } from '../api/client';
|
||||||
|
|
||||||
|
/** Eigenschaften der Ladeanzeige. */
|
||||||
|
interface LoadingStateProperties {
|
||||||
|
/** Was gerade geladen wird. */
|
||||||
|
readonly what: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt an, dass Daten geladen werden. */
|
||||||
|
export function LoadingState({ what }: LoadingStateProperties): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<p className="page__state" role="status">
|
||||||
|
{what} werden geladen…
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Fehleranzeige. */
|
||||||
|
interface ErrorStateProperties {
|
||||||
|
/** Der aufgetretene Fehler. */
|
||||||
|
readonly error: ApiError;
|
||||||
|
/** Laedt erneut. */
|
||||||
|
readonly onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt einen Fehler samt Request-ID. */
|
||||||
|
export function ErrorState({ error, onRetry }: ErrorStateProperties): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="notice notice--critical" role="alert">
|
||||||
|
<p className="notice__text">{error.message}</p>
|
||||||
|
|
||||||
|
{/* Die Request-ID gehoert sichtbar in die Oberflaeche: Mit ihr laesst sich
|
||||||
|
ein Vorfall im Serverlog eindeutig wiederfinden (PROMPT.md §50). */}
|
||||||
|
{error.requestId !== '' && (
|
||||||
|
<p className="notice__meta">
|
||||||
|
Fehlercode {error.code} · Vorgang {error.requestId}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="button button--secondary" type="button" onClick={onRetry}>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Leeranzeige. */
|
||||||
|
interface EmptyStateProperties {
|
||||||
|
/** Der erklaerende Text. */
|
||||||
|
readonly message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt an, dass es nichts anzuzeigen gibt. */
|
||||||
|
export function EmptyState({ message }: EmptyStateProperties): React.JSX.Element {
|
||||||
|
return <p className="page__state page__state--empty">{message}</p>;
|
||||||
|
}
|
||||||
20
apps/web/src/components/StatusIndicator.css
Normal file
20
apps/web/src/components/StatusIndicator.css
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/* Darstellung der Statusanzeige. */
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator__dot {
|
||||||
|
/* Feste Groesse, damit der Punkt in Tabellen nicht springt. */
|
||||||
|
width: 0.625rem;
|
||||||
|
height: 0.625rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator__label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
44
apps/web/src/components/StatusIndicator.tsx
Normal file
44
apps/web/src/components/StatusIndicator.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Statusanzeige des Syncova-Designsystems (PROMPT.md §105/§106).
|
||||||
|
*
|
||||||
|
* Der Zustand wird nicht allein ueber Farbe vermittelt, sondern immer zusaetzlich
|
||||||
|
* ueber Text. Farbe allein waere fuer farbfehlsichtige Anwender unzugaenglich
|
||||||
|
* (PROMPT.md §107).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { HealthStatus } from '../types/api';
|
||||||
|
import './StatusIndicator.css';
|
||||||
|
|
||||||
|
/** Zuordnung von Zustand zu Anzeigetext und Farbvariable. */
|
||||||
|
const STATUS_PRESENTATION: Record<HealthStatus, { label: string; colorVariable: string }> = {
|
||||||
|
healthy: { label: 'Fehlerfrei', colorVariable: 'var(--color-status-healthy)' },
|
||||||
|
degraded: { label: 'Eingeschraenkt', colorVariable: 'var(--color-status-warning)' },
|
||||||
|
warning: { label: 'Warnung', colorVariable: 'var(--color-status-warning)' },
|
||||||
|
critical: { label: 'Kritisch', colorVariable: 'var(--color-status-critical)' },
|
||||||
|
offline: { label: 'Nicht erreichbar', colorVariable: 'var(--color-status-high)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Eigenschaften der Statusanzeige. */
|
||||||
|
export interface StatusIndicatorProps {
|
||||||
|
/** Anzuzeigender Zustand. */
|
||||||
|
status: HealthStatus;
|
||||||
|
/** Optionale abweichende Beschriftung. */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt einen Zustand als farbigen Punkt mit Beschriftung. */
|
||||||
|
export function StatusIndicator({ status, label }: StatusIndicatorProps): React.JSX.Element {
|
||||||
|
const presentation = STATUS_PRESENTATION[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="status-indicator">
|
||||||
|
{/* Der Punkt ist rein dekorativ; die Information steht im Text daneben. */}
|
||||||
|
<span
|
||||||
|
className="status-indicator__dot"
|
||||||
|
style={{ backgroundColor: presentation.colorVariable }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="status-indicator__label">{label ?? presentation.label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
307
apps/web/src/features/alerts/AlertsPage.tsx
Normal file
307
apps/web/src/features/alerts/AlertsPage.tsx
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
/**
|
||||||
|
* Meldungen.
|
||||||
|
*
|
||||||
|
* Die Seite, die in Phase 12 noch als „noch nicht verfuegbar" stand — mit der
|
||||||
|
* Begruendung, eine leere Liste hiesse „keine Probleme" und wuerde bedeuten „es
|
||||||
|
* wird nicht geprueft". Jetzt gibt es die Pruefung, und die Seite sagt, worauf
|
||||||
|
* geachtet wird.
|
||||||
|
*
|
||||||
|
* Das Regelwerk steht deshalb mit auf der Seite: Eine leere Meldungsliste ist
|
||||||
|
* erst dann eine gute Nachricht, wenn man weiss, was ueberhaupt geprueft wird.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useApiResource } from '../../api/useApiResource';
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from '../../components/PageState';
|
||||||
|
import {
|
||||||
|
acknowledgeAlert,
|
||||||
|
fetchAlertOverview,
|
||||||
|
fetchAlerts,
|
||||||
|
resolveAlert,
|
||||||
|
} from './alertsApi';
|
||||||
|
import type { Alert, AlertOverview, AlertSeverity } from './alertsApi';
|
||||||
|
|
||||||
|
/** Zeigt die Meldungen. */
|
||||||
|
export function AlertsPage(): React.JSX.Element {
|
||||||
|
const [onlyActive, setOnlyActive] = useState(true);
|
||||||
|
const [actionCounter, setActionCounter] = useState(0);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadAlerts = useCallback(
|
||||||
|
(abortSignal: AbortSignal) => fetchAlerts(onlyActive, abortSignal),
|
||||||
|
[onlyActive],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<readonly Alert[]>(
|
||||||
|
loadAlerts,
|
||||||
|
`${String(onlyActive)}|${String(actionCounter)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadOverview = useCallback(
|
||||||
|
(abortSignal: AbortSignal) => fetchAlertOverview(abortSignal),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const overviewResource = useApiResource<AlertOverview>(loadOverview, String(actionCounter));
|
||||||
|
|
||||||
|
/** Fuehrt eine Handlung an einer Meldung aus und laedt neu. */
|
||||||
|
async function runAlertAction(action: () => Promise<void>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
setActionError(null);
|
||||||
|
setActionCounter((previousCounter) => previousCounter + 1);
|
||||||
|
} catch (caughtError) {
|
||||||
|
setActionError(caughtError instanceof Error ? caughtError.message : 'Unbekannter Fehler');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page__header">
|
||||||
|
<h1 className="page__title">Meldungen</h1>
|
||||||
|
|
||||||
|
{overviewResource.data !== null && (
|
||||||
|
<span className="page__meta">
|
||||||
|
{overviewResource.data.available_rule_count} von {overviewResource.data.rules.length}{' '}
|
||||||
|
Regeln werden geprueft
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{overviewResource.data !== null && <SummaryBar overview={overviewResource.data} />}
|
||||||
|
|
||||||
|
<div className="filter-bar">
|
||||||
|
<label className="filter-bar__checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlyActive}
|
||||||
|
onChange={(changeEvent) => setOnlyActive(changeEvent.target.checked)}
|
||||||
|
/>
|
||||||
|
Nur unerledigte
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionError !== null && (
|
||||||
|
<div className="notice notice--critical" role="alert">
|
||||||
|
<p className="notice__text">{actionError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadState === 'loading' && <LoadingState what="Die Meldungen" />}
|
||||||
|
{loadState === 'failed' && loadError !== null && (
|
||||||
|
<ErrorState error={loadError} onRetry={reload} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && data !== null && data.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
message={
|
||||||
|
onlyActive
|
||||||
|
? 'Keine unerledigten Meldungen. Die Regeln unten sagen, worauf geachtet wird.'
|
||||||
|
: 'Es gibt keine Meldungen.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && data !== null && data.length > 0 && (
|
||||||
|
<ul className="alert-list">
|
||||||
|
{data.map((alert) => (
|
||||||
|
<AlertCard
|
||||||
|
key={alert.id}
|
||||||
|
alert={alert}
|
||||||
|
onAcknowledge={() => void runAlertAction(() => acknowledgeAlert(alert.id, ''))}
|
||||||
|
onResolve={() => void runAlertAction(() => resolveAlert(alert.id, 'Von Hand geschlossen.'))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{overviewResource.data !== null && <RuleList overview={overviewResource.data} />}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Zusammenfassung. */
|
||||||
|
interface SummaryBarProperties {
|
||||||
|
/** Die Meldungslage samt Regelwerk. */
|
||||||
|
readonly overview: AlertOverview;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Meldungslage in Zahlen. */
|
||||||
|
function SummaryBar({ overview }: SummaryBarProperties): React.JSX.Element {
|
||||||
|
const { summary } = overview;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="summary-bar">
|
||||||
|
<SummaryFigure
|
||||||
|
label="Kritisch"
|
||||||
|
value={summary.critical_count}
|
||||||
|
severity={summary.critical_count > 0 ? 'critical' : undefined}
|
||||||
|
/>
|
||||||
|
<SummaryFigure
|
||||||
|
label="Ernst"
|
||||||
|
value={summary.high_count}
|
||||||
|
severity={summary.high_count > 0 ? 'high' : undefined}
|
||||||
|
/>
|
||||||
|
<SummaryFigure label="Unbearbeitet" value={summary.open_count} />
|
||||||
|
<SummaryFigure label="Zur Kenntnis genommen" value={summary.acknowledged_count} />
|
||||||
|
<SummaryFigure label="Erledigt (24 h)" value={summary.resolved_last_day} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften einer Kennzahl der Zusammenfassung. */
|
||||||
|
interface SummaryFigureProperties {
|
||||||
|
/** Beschriftung. */
|
||||||
|
readonly label: string;
|
||||||
|
/** Der Wert. */
|
||||||
|
readonly value: number;
|
||||||
|
/** Statusfarbe, sofern der Wert eine ist. */
|
||||||
|
readonly severity?: AlertSeverity | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt eine einzelne Kennzahl. */
|
||||||
|
function SummaryFigure({ label, value, severity }: SummaryFigureProperties): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className={severity === undefined ? 'summary-figure' : `summary-figure summary-figure--${severity}`}>
|
||||||
|
<span className="summary-figure__value">{value}</span>
|
||||||
|
<span className="summary-figure__label">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften einer Meldungskarte. */
|
||||||
|
interface AlertCardProperties {
|
||||||
|
/** Die dargestellte Meldung. */
|
||||||
|
readonly alert: Alert;
|
||||||
|
/** Nimmt die Meldung zur Kenntnis. */
|
||||||
|
readonly onAcknowledge: () => void;
|
||||||
|
/** Schliesst die Meldung. */
|
||||||
|
readonly onResolve: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt eine einzelne Meldung. */
|
||||||
|
function AlertCard({ alert, onAcknowledge, onResolve }: AlertCardProperties): React.JSX.Element {
|
||||||
|
const isResolved = alert.status === 'resolved';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={`alert-card alert-card--${alert.severity}${isResolved ? ' alert-card--resolved' : ''}`}>
|
||||||
|
<div className="alert-card__head">
|
||||||
|
<span className={`badge badge--${severityClassOf(alert.severity)}`}>{alert.severity}</span>
|
||||||
|
|
||||||
|
<h2 className="alert-card__title">{alert.title}</h2>
|
||||||
|
|
||||||
|
{/* Die Wiederholungszahl steht oben: Einmal ist ein Zwischenfall,
|
||||||
|
zwanzigmal ein Zustand. */}
|
||||||
|
{alert.occurrence_count > 1 && (
|
||||||
|
<span className="alert-card__count">{alert.occurrence_count}× aufgetreten</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="alert-card__message">{alert.message}</p>
|
||||||
|
|
||||||
|
<p className="alert-card__meta">
|
||||||
|
Seit {formatTimestamp(alert.first_seen_at)} · zuletzt {formatTimestamp(alert.last_seen_at)} ·
|
||||||
|
Regel {alert.rule_name}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{alert.status === 'acknowledged' && (
|
||||||
|
<p className="alert-card__meta">
|
||||||
|
Zur Kenntnis genommen am {formatTimestamp(alert.acknowledged_at)}. Die Meldung bleibt
|
||||||
|
offen, bis ihre Ursache verschwindet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isResolved && (
|
||||||
|
<p className="alert-card__meta">
|
||||||
|
Erledigt am {formatTimestamp(alert.resolved_at)}
|
||||||
|
{alert.resolution_note !== undefined && alert.resolution_note !== ''
|
||||||
|
? ` — ${alert.resolution_note}`
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isResolved && (
|
||||||
|
<div className="alert-card__actions">
|
||||||
|
{alert.status === 'open' && (
|
||||||
|
<button className="button button--secondary" type="button" onClick={onAcknowledge}>
|
||||||
|
Zur Kenntnis nehmen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="button button--secondary" type="button" onClick={onResolve}>
|
||||||
|
Schliessen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Regelliste. */
|
||||||
|
interface RuleListProperties {
|
||||||
|
/** Die Meldungslage samt Regelwerk. */
|
||||||
|
readonly overview: AlertOverview;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt, worauf geachtet wird.
|
||||||
|
*
|
||||||
|
* Ohne diese Liste bliebe offen, ob eine leere Meldungsliste bedeutet „alles in
|
||||||
|
* Ordnung" oder „es wird nichts geprueft".
|
||||||
|
*/
|
||||||
|
function RuleList({ overview }: RuleListProperties): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<section className="rule-list">
|
||||||
|
<h2 className="rule-list__title">Worauf geachtet wird</h2>
|
||||||
|
|
||||||
|
<ul className="rule-list__items">
|
||||||
|
{overview.rules.map((rule) => (
|
||||||
|
<li
|
||||||
|
className={rule.available ? 'rule-list__item' : 'rule-list__item rule-list__item--unavailable'}
|
||||||
|
key={rule.name}
|
||||||
|
>
|
||||||
|
<div className="rule-list__head">
|
||||||
|
<span className="rule-list__name">{rule.title}</span>
|
||||||
|
|
||||||
|
{rule.available ? (
|
||||||
|
<span className={`badge badge--${severityClassOf(rule.severity)}`}>{rule.severity}</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge--neutral">wird nicht geprueft</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="rule-list__description">
|
||||||
|
{rule.available ? rule.description : rule.unavailable_reason}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bildet einen Schweregrad auf eine Statusfarbe ab. */
|
||||||
|
function severityClassOf(severity: AlertSeverity): string {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical':
|
||||||
|
return 'critical';
|
||||||
|
case 'high':
|
||||||
|
return 'high';
|
||||||
|
case 'warning':
|
||||||
|
return 'warning';
|
||||||
|
default:
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt einen Zeitstempel in deutscher Schreibweise. */
|
||||||
|
function formatTimestamp(isoTimestamp: string | undefined): string {
|
||||||
|
if (isoTimestamp === undefined) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(isoTimestamp).toLocaleString('de-DE', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
}
|
||||||
128
apps/web/src/features/alerts/alertsApi.ts
Normal file
128
apps/web/src/features/alerts/alertsApi.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Zugriff auf Meldungen und Benachrichtigungskanaele.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { requestApi } from '../../api/client';
|
||||||
|
|
||||||
|
/** Schweregrad einer Meldung. */
|
||||||
|
export type AlertSeverity = 'information' | 'warning' | 'high' | 'critical';
|
||||||
|
|
||||||
|
/** Bearbeitungszustand einer Meldung. */
|
||||||
|
export type AlertStatus = 'open' | 'acknowledged' | 'resolved';
|
||||||
|
|
||||||
|
/** Eine Meldung. */
|
||||||
|
export interface Alert {
|
||||||
|
/** Oeffentlicher Bezeichner. */
|
||||||
|
readonly id: string;
|
||||||
|
/** Ausloesende Regel. */
|
||||||
|
readonly rule_name: string;
|
||||||
|
/** Schweregrad. */
|
||||||
|
readonly severity: AlertSeverity;
|
||||||
|
/** Bearbeitungszustand. */
|
||||||
|
readonly status: AlertStatus;
|
||||||
|
/** Ueberschrift. */
|
||||||
|
readonly title: string;
|
||||||
|
/** Befund und naechste Handlung. */
|
||||||
|
readonly message: string;
|
||||||
|
/** Art des betroffenen Gegenstands. */
|
||||||
|
readonly entity_type?: string;
|
||||||
|
/** Sprechender Name des Gegenstands. */
|
||||||
|
readonly entity_name?: string;
|
||||||
|
/**
|
||||||
|
* Zahl der Wiederholungen.
|
||||||
|
*
|
||||||
|
* Die eigentliche Auskunft: Einmal ist ein Zwischenfall, zwanzigmal ein
|
||||||
|
* Zustand.
|
||||||
|
*/
|
||||||
|
readonly occurrence_count: number;
|
||||||
|
/** Erstes Auftreten in UTC. */
|
||||||
|
readonly first_seen_at: string;
|
||||||
|
/** Letztes Auftreten in UTC. */
|
||||||
|
readonly last_seen_at: string;
|
||||||
|
/** Zeitpunkt der Kenntnisnahme in UTC. */
|
||||||
|
readonly acknowledged_at?: string;
|
||||||
|
/** Bemerkung des Bestaetigenden. */
|
||||||
|
readonly acknowledgement_note?: string;
|
||||||
|
/** Zeitpunkt der Aufloesung in UTC. */
|
||||||
|
readonly resolved_at?: string;
|
||||||
|
/** Begruendung der Aufloesung. */
|
||||||
|
readonly resolution_note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Regel des Meldungswesens. */
|
||||||
|
export interface AlertRule {
|
||||||
|
/** Maschinenlesbarer Bezeichner. */
|
||||||
|
readonly name: string;
|
||||||
|
/** Bezeichnung. */
|
||||||
|
readonly title: string;
|
||||||
|
/** Erklaerung. */
|
||||||
|
readonly description: string;
|
||||||
|
/** Schweregrad ausgeloester Meldungen. */
|
||||||
|
readonly severity: AlertSeverity;
|
||||||
|
/** Meldet, ob die Regel ausloesen kann. */
|
||||||
|
readonly available: boolean;
|
||||||
|
/** Erklaert eine Regel ohne Datengrundlage. */
|
||||||
|
readonly unavailable_reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Meldungslage. */
|
||||||
|
export interface AlertSummary {
|
||||||
|
/** Zahl unbearbeiteter Meldungen. */
|
||||||
|
readonly open_count: number;
|
||||||
|
/** Zahl zur Kenntnis genommener Meldungen. */
|
||||||
|
readonly acknowledged_count: number;
|
||||||
|
/** Zahl unerledigter kritischer Meldungen. */
|
||||||
|
readonly critical_count: number;
|
||||||
|
/** Zahl unerledigter ernster Meldungen. */
|
||||||
|
readonly high_count: number;
|
||||||
|
/** Zahl der in 24 Stunden erledigten Meldungen. */
|
||||||
|
readonly resolved_last_day: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Antwort der Meldungsuebersicht. */
|
||||||
|
export interface AlertOverview {
|
||||||
|
/** Die Meldungslage. */
|
||||||
|
readonly summary: AlertSummary;
|
||||||
|
/** Alle Regeln. */
|
||||||
|
readonly rules: readonly AlertRule[];
|
||||||
|
/** Zahl der ausloesbaren Regeln. */
|
||||||
|
readonly available_rule_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt die Meldungen. */
|
||||||
|
export async function fetchAlerts(
|
||||||
|
onlyActive: boolean,
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
): Promise<readonly Alert[]> {
|
||||||
|
const queryParameters = new URLSearchParams({ page_size: '50' });
|
||||||
|
|
||||||
|
if (onlyActive) {
|
||||||
|
queryParameters.set('only_active', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestApi<readonly Alert[]>(
|
||||||
|
`/alerts?${queryParameters.toString()}`,
|
||||||
|
abortSignal ? { signal: abortSignal } : {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt die Meldungslage samt Regelwerk. */
|
||||||
|
export async function fetchAlertOverview(abortSignal?: AbortSignal): Promise<AlertOverview> {
|
||||||
|
return requestApi<AlertOverview>('/alerts/summary', abortSignal ? { signal: abortSignal } : {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nimmt eine Meldung zur Kenntnis. */
|
||||||
|
export async function acknowledgeAlert(alertIdentifier: string, note: string): Promise<void> {
|
||||||
|
await requestApi<unknown>(`/alerts/${encodeURIComponent(alertIdentifier)}/acknowledge`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { note },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schliesst eine Meldung von Hand. */
|
||||||
|
export async function resolveAlert(alertIdentifier: string, note: string): Promise<void> {
|
||||||
|
await requestApi<unknown>(`/alerts/${encodeURIComponent(alertIdentifier)}/resolve`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { note },
|
||||||
|
});
|
||||||
|
}
|
||||||
102
apps/web/src/features/auth/LoginPage.css
Normal file
102
apps/web/src/features/auth/LoginPage.css
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
/* Darstellung der Anmeldemaske. */
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 22rem;
|
||||||
|
background-color: var(--color-surface-raised);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--space-8) var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__title {
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card__subtitle {
|
||||||
|
margin: 0 0 var(--space-6);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__input {
|
||||||
|
font: inherit;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--color-surface-page);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Einmalcode wird in Ziffernbreite dargestellt, damit er gut lesbar ist. */
|
||||||
|
.login-form__input--code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__hint {
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__error {
|
||||||
|
/* Der farbige Rand betont den Fehler, ohne die Flaeche einzufaerben. */
|
||||||
|
border-left: 3px solid var(--color-status-critical);
|
||||||
|
padding-left: var(--space-3);
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__button {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: var(--border-width) solid var(--color-text-primary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--color-text-primary);
|
||||||
|
color: var(--color-surface-raised);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form__secondary {
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
padding: var(--space-2);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
215
apps/web/src/features/auth/LoginPage.test.tsx
Normal file
215
apps/web/src/features/auth/LoginPage.test.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { LoginPage } from './LoginPage';
|
||||||
|
import { clearTokens } from './authApi';
|
||||||
|
|
||||||
|
/** Baut eine Antwort, wie sie die API liefert. */
|
||||||
|
function buildResponse(responseBody: unknown, statusCode = 200): Response {
|
||||||
|
return new Response(JSON.stringify(responseBody), {
|
||||||
|
status: statusCode,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein vollstaendiger Benutzer, wie ihn die API zurueckgibt. */
|
||||||
|
const testUser = {
|
||||||
|
id: '11111111-1111-4111-8111-111111111111',
|
||||||
|
username: 'admin',
|
||||||
|
status: 'active',
|
||||||
|
mfa_enabled: false,
|
||||||
|
roles: ['super_administrator'],
|
||||||
|
permissions: ['users.read'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ein Tokenpaar, wie es die API zurueckgibt. */
|
||||||
|
const testTokens = {
|
||||||
|
access_token: 'zugriffstoken', // secretscan:erlaubt: erfundener Testwert
|
||||||
|
refresh_token: 'erneuerungstoken', // secretscan:erlaubt: erfundener Testwert
|
||||||
|
access_expires_at: '2026-08-10T16:00:00Z',
|
||||||
|
refresh_expires_at: '2026-08-11T04:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
clearTokens();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LoginPage', () => {
|
||||||
|
it('meldet einen Benutzer ohne zweiten Faktor direkt an', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
buildResponse({
|
||||||
|
data: { mfa_required: false, tokens: testTokens, user: testUser },
|
||||||
|
meta: { request_id: 'req-1' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAuthenticated = vi.fn();
|
||||||
|
render(<LoginPage onAuthenticated={handleAuthenticated} />);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Benutzername'), 'admin');
|
||||||
|
await userEvent.type(screen.getByLabelText('Passwort'), 'geheim');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(handleAuthenticated).toHaveBeenCalledWith(testUser);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fordert bei aktivem zweiten Faktor den Code an', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
buildResponse({
|
||||||
|
data: { mfa_required: true, challenge_id: '22222222-2222-4222-8222-222222222222' },
|
||||||
|
meta: { request_id: 'req-2' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleAuthenticated = vi.fn();
|
||||||
|
render(<LoginPage onAuthenticated={handleAuthenticated} />);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Benutzername'), 'admin');
|
||||||
|
await userEvent.type(screen.getByLabelText('Passwort'), 'geheim');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
|
||||||
|
|
||||||
|
// Der zweite Schritt erscheint, und noch ist niemand angemeldet.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText('Code')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handleAuthenticated).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schliesst die Anmeldung nach gueltigem Code ab', async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildResponse({
|
||||||
|
data: { mfa_required: true, challenge_id: '22222222-2222-4222-8222-222222222222' },
|
||||||
|
meta: { request_id: 'req-3' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildResponse({
|
||||||
|
data: { mfa_required: false, tokens: testTokens, user: { ...testUser, mfa_enabled: true } },
|
||||||
|
meta: { request_id: 'req-4' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const handleAuthenticated = vi.fn();
|
||||||
|
render(<LoginPage onAuthenticated={handleAuthenticated} />);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Benutzername'), 'admin');
|
||||||
|
await userEvent.type(screen.getByLabelText('Passwort'), 'geheim');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument());
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Code'), '123456');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Bestaetigen' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(handleAuthenticated).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt die Fehlermeldung des Servers verstaendlich an', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
buildResponse(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
code: 'UNAUTHENTICATED',
|
||||||
|
message: 'Benutzername oder Passwort ist falsch.',
|
||||||
|
request_id: 'req-5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<LoginPage onAuthenticated={vi.fn()} />);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Benutzername'), 'admin');
|
||||||
|
await userEvent.type(screen.getByLabelText('Passwort'), 'falsch');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('Benutzername oder Passwort ist falsch.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leert das Passwortfeld nach einem Fehlversuch', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
buildResponse(
|
||||||
|
{ error: { code: 'UNAUTHENTICATED', message: 'Falsch.', request_id: 'req-6' } },
|
||||||
|
401,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<LoginPage onAuthenticated={vi.fn()} />);
|
||||||
|
|
||||||
|
const passwordField = screen.getByLabelText('Passwort') as HTMLInputElement;
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Benutzername'), 'admin');
|
||||||
|
await userEvent.type(passwordField, 'falsch');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
|
||||||
|
|
||||||
|
// Ein stehengebliebenes Passwort waere auf einem gemeinsam genutzten
|
||||||
|
// Bildschirm sichtbar.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(passwordField.value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kehrt bei abgelaufener Herausforderung zur Anmeldung zurueck', async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildResponse({
|
||||||
|
data: { mfa_required: true, challenge_id: '22222222-2222-4222-8222-222222222222' },
|
||||||
|
meta: { request_id: 'req-7' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
buildResponse(
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
code: 'MFA_CHALLENGE_EXPIRED',
|
||||||
|
message: 'Die Anmeldung ist abgelaufen. Bitte erneut anmelden.',
|
||||||
|
request_id: 'req-8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
render(<LoginPage onAuthenticated={vi.fn()} />);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Benutzername'), 'admin');
|
||||||
|
await userEvent.type(screen.getByLabelText('Passwort'), 'geheim');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Anmelden' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('Code')).toBeInTheDocument());
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Code'), '123456');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Bestaetigen' }));
|
||||||
|
|
||||||
|
// Ohne Rueckkehr sässe der Benutzer in einem Schritt fest, der nicht mehr gilt.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText('Benutzername')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
206
apps/web/src/features/auth/LoginPage.tsx
Normal file
206
apps/web/src/features/auth/LoginPage.tsx
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Anmeldemaske mit zweistufigem Ablauf.
|
||||||
|
*
|
||||||
|
* Schritt 1 fragt Name und Passwort ab. Verlangt das Konto einen zweiten Faktor,
|
||||||
|
* folgt Schritt 2 mit der Codeeingabe (SYNCOVA_API.md §2).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { ApiError } from '../../api/client';
|
||||||
|
import { login, verifyMFA } from './authApi';
|
||||||
|
import type { CurrentUser } from '../../types/auth';
|
||||||
|
import './LoginPage.css';
|
||||||
|
|
||||||
|
/** Eigenschaften der Anmeldemaske. */
|
||||||
|
export interface LoginPageProps {
|
||||||
|
/** Wird nach erfolgreicher Anmeldung mit dem Benutzer aufgerufen. */
|
||||||
|
onAuthenticated: (authenticatedUser: CurrentUser) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schritt des Anmeldevorgangs. */
|
||||||
|
type LoginStep = 'credentials' | 'mfa';
|
||||||
|
|
||||||
|
/** Zeigt die Anmeldemaske. */
|
||||||
|
export function LoginPage({ onAuthenticated }: LoginPageProps): React.JSX.Element {
|
||||||
|
const [loginStep, setLoginStep] = useState<LoginStep>('credentials');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [mfaCode, setMfaCode] = useState('');
|
||||||
|
const [challengeId, setChallengeId] = useState<string | null>(null);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
/** Wandelt einen Fehler in eine verstaendliche Meldung (PROMPT.md §124). */
|
||||||
|
function describeError(caughtError: unknown): string {
|
||||||
|
if (caughtError instanceof ApiError) {
|
||||||
|
return caughtError.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Die Anmeldung ist unerwartet fehlgeschlagen. Bitte erneut versuchen.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verarbeitet Schritt 1: Name und Passwort. */
|
||||||
|
async function handleCredentialsSubmit(formEvent: React.FormEvent): Promise<void> {
|
||||||
|
formEvent.preventDefault();
|
||||||
|
setErrorMessage(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loginResult = await login(username, password);
|
||||||
|
|
||||||
|
if (loginResult.mfa_required && loginResult.challenge_id) {
|
||||||
|
setChallengeId(loginResult.challenge_id);
|
||||||
|
setLoginStep('mfa');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loginResult.user) {
|
||||||
|
onAuthenticated(loginResult.user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weder Tokens noch Herausforderung: die Antwort passt nicht zum Vertrag.
|
||||||
|
setErrorMessage('Die Antwort des Servers war unvollstaendig. Bitte erneut versuchen.');
|
||||||
|
} catch (caughtError) {
|
||||||
|
setErrorMessage(describeError(caughtError));
|
||||||
|
// Das Passwort wird nach einem Fehlversuch geleert.
|
||||||
|
setPassword('');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verarbeitet Schritt 2: den zweiten Faktor. */
|
||||||
|
async function handleMFASubmit(formEvent: React.FormEvent): Promise<void> {
|
||||||
|
formEvent.preventDefault();
|
||||||
|
|
||||||
|
if (challengeId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMessage(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loginResult = await verifyMFA(challengeId, mfaCode);
|
||||||
|
|
||||||
|
if (loginResult.user) {
|
||||||
|
onAuthenticated(loginResult.user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMessage('Die Antwort des Servers war unvollstaendig. Bitte erneut versuchen.');
|
||||||
|
} catch (caughtError) {
|
||||||
|
setErrorMessage(describeError(caughtError));
|
||||||
|
setMfaCode('');
|
||||||
|
|
||||||
|
// Eine abgelaufene Herausforderung erfordert eine neue Anmeldung.
|
||||||
|
if (caughtError instanceof ApiError && caughtError.code === 'MFA_CHALLENGE_EXPIRED') {
|
||||||
|
setLoginStep('credentials');
|
||||||
|
setChallengeId(null);
|
||||||
|
setPassword('');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<main className="login-card">
|
||||||
|
<h1 className="login-card__title">Syncova</h1>
|
||||||
|
|
||||||
|
{loginStep === 'credentials' ? (
|
||||||
|
<form className="login-form" onSubmit={handleCredentialsSubmit}>
|
||||||
|
<p className="login-card__subtitle">Bitte anmelden</p>
|
||||||
|
|
||||||
|
<label className="login-form__label" htmlFor="username">
|
||||||
|
Benutzername
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
className="login-form__input"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(changeEvent) => setUsername(changeEvent.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="login-form__label" htmlFor="password">
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
className="login-form__input"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(changeEvent) => setPassword(changeEvent.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<p className="login-form__error" role="alert">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="login-form__button" type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? 'Anmeldung laeuft …' : 'Anmelden'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form className="login-form" onSubmit={handleMFASubmit}>
|
||||||
|
<p className="login-card__subtitle">Zweiter Faktor</p>
|
||||||
|
<p className="login-form__hint">
|
||||||
|
Bitte den Code aus der Authenticator-App eingeben. Alternativ ist ein
|
||||||
|
Wiederherstellungscode moeglich.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="login-form__label" htmlFor="mfa-code">
|
||||||
|
Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="mfa-code"
|
||||||
|
className="login-form__input login-form__input--code"
|
||||||
|
type="text"
|
||||||
|
value={mfaCode}
|
||||||
|
onChange={(changeEvent) => setMfaCode(changeEvent.target.value)}
|
||||||
|
// one-time-code laesst Mobilgeraete den Code aus der SMS/App vorschlagen.
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
inputMode="text"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<p className="login-form__error" role="alert">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button className="login-form__button" type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? 'Pruefung laeuft …' : 'Bestaetigen'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="login-form__secondary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setLoginStep('credentials');
|
||||||
|
setChallengeId(null);
|
||||||
|
setMfaCode('');
|
||||||
|
setPassword('');
|
||||||
|
setErrorMessage(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
apps/web/src/features/auth/authApi.ts
Normal file
103
apps/web/src/features/auth/authApi.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Anmeldefunktionen gegen die Syncova-API.
|
||||||
|
*
|
||||||
|
* Die Tokens werden ausschliesslich im Arbeitsspeicher gehalten und nicht in
|
||||||
|
* localStorage abgelegt: dort waeren sie fuer jedes Skript der Seite lesbar und
|
||||||
|
* ueberstuenden das Schliessen des Browsers (PROMPT.md §45).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { requestApi } from '../../api/client';
|
||||||
|
import type { CurrentUser, LoginResult, TokenPair } from '../../types/auth';
|
||||||
|
|
||||||
|
/** Im Arbeitsspeicher gehaltenes Zugriffstoken. */
|
||||||
|
let currentAccessToken: string | null = null;
|
||||||
|
|
||||||
|
/** Im Arbeitsspeicher gehaltenes Erneuerungstoken. */
|
||||||
|
let currentRefreshToken: string | null = null;
|
||||||
|
|
||||||
|
/** Liefert das aktuelle Zugriffstoken. */
|
||||||
|
export function getAccessToken(): string | null {
|
||||||
|
return currentAccessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hinterlegt ein Tokenpaar nach erfolgreicher Anmeldung. */
|
||||||
|
export function storeTokens(tokenPair: TokenPair): void {
|
||||||
|
currentAccessToken = tokenPair.access_token;
|
||||||
|
currentRefreshToken = tokenPair.refresh_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verwirft die hinterlegten Tokens. */
|
||||||
|
export function clearTokens(): void {
|
||||||
|
currentAccessToken = null;
|
||||||
|
currentRefreshToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meldet einen Benutzer mit Name und Passwort an.
|
||||||
|
*
|
||||||
|
* Ist ein zweiter Faktor eingerichtet, enthaelt das Ergebnis eine
|
||||||
|
* Herausforderung statt der Tokens.
|
||||||
|
*/
|
||||||
|
export async function login(username: string, password: string): Promise<LoginResult> {
|
||||||
|
const loginResult = await requestApi<LoginResult>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { username, password },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loginResult.tokens) {
|
||||||
|
storeTokens(loginResult.tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
return loginResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schliesst eine Anmeldung mit dem zweiten Faktor ab. */
|
||||||
|
export async function verifyMFA(challengeId: string, code: string): Promise<LoginResult> {
|
||||||
|
const loginResult = await requestApi<LoginResult>('/auth/mfa/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { challenge_id: challengeId, code },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loginResult.tokens) {
|
||||||
|
storeTokens(loginResult.tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
return loginResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meldet den Benutzer ab und verwirft die Tokens. */
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await requestApi('/auth/logout', { method: 'POST' });
|
||||||
|
} finally {
|
||||||
|
// Die Tokens werden auch dann verworfen, wenn der Server nicht erreichbar
|
||||||
|
// war: lokal abgemeldet zu sein ist besser als angemeldet zu bleiben.
|
||||||
|
clearTokens();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt den aktuell angemeldeten Benutzer. */
|
||||||
|
export async function fetchCurrentUser(): Promise<CurrentUser> {
|
||||||
|
return requestApi<CurrentUser>('/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erneuert die Sitzung mit dem Erneuerungstoken. */
|
||||||
|
export async function refreshSession(): Promise<boolean> {
|
||||||
|
if (currentRefreshToken === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenPair = await requestApi<TokenPair>('/auth/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { refresh_token: currentRefreshToken },
|
||||||
|
});
|
||||||
|
|
||||||
|
storeTokens(tokenPair);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// Ein fehlgeschlagener Erneuerungsversuch bedeutet: die Sitzung ist vorbei.
|
||||||
|
clearTokens();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
135
apps/web/src/features/dashboard/DashboardPage.test.tsx
Normal file
135
apps/web/src/features/dashboard/DashboardPage.test.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* Tests der Uebersicht.
|
||||||
|
*
|
||||||
|
* Der wichtigste Test ist der letzte: Eine Kennzahl ohne Datengrundlage darf
|
||||||
|
* niemals als Zahl erscheinen. Genau dort entstehen die erfundenen Statistiken,
|
||||||
|
* die PROMPT.md §139 verbietet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { DashboardPage } from './DashboardPage';
|
||||||
|
import type { Dashboard } from './dashboardApi';
|
||||||
|
|
||||||
|
/** Baut eine Antwort mit den uebergebenen Kennzahlen. */
|
||||||
|
function buildDashboardResponse(dashboard: Dashboard): Response {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
headers: new Headers(),
|
||||||
|
json: () => Promise.resolve({ data: dashboard, meta: { request_id: 'test' } }),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Uebersicht', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt eine Kennzahl samt Erlaeuterung', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue(
|
||||||
|
buildDashboardResponse({
|
||||||
|
available_count: 1,
|
||||||
|
generated_at: '2026-08-12T06:00:00Z',
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
key: 'backup_success_rate',
|
||||||
|
title: 'Erfolgsquote (7 Tage)',
|
||||||
|
available: true,
|
||||||
|
value: 87.5,
|
||||||
|
unit: '%',
|
||||||
|
detail: '7 von 8 Laeufen vollstaendig erfolgreich.',
|
||||||
|
severity: 'high',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Erfolgsquote (7 Tage)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('87,5')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('7 von 8 Laeufen vollstaendig erfolgreich.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nennt bei fehlender Datengrundlage den Grund statt einer Zahl', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue(
|
||||||
|
buildDashboardResponse({
|
||||||
|
available_count: 0,
|
||||||
|
generated_at: '2026-08-12T06:00:00Z',
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
key: 'critical_alerts',
|
||||||
|
title: 'Kritische Meldungen',
|
||||||
|
available: false,
|
||||||
|
unavailable_reason: 'Es gibt noch kein Meldungswesen (Phase 14).',
|
||||||
|
severity: 'information',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Kritische Meldungen')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Es gibt noch kein Meldungswesen (Phase 14).')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Der entscheidende Teil: keine Null. Eine Null hiesse „keine Probleme" und
|
||||||
|
// wuerde bedeuten „es wird nicht geprueft".
|
||||||
|
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('noch nicht verfuegbar')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet eine nicht bezifferbare Kennzahl als solche', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue(
|
||||||
|
buildDashboardResponse({
|
||||||
|
available_count: 1,
|
||||||
|
generated_at: '2026-08-12T06:00:00Z',
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
key: 'storage',
|
||||||
|
title: 'Speicherbelegung',
|
||||||
|
available: true,
|
||||||
|
detail: 'Die Gesamtkapazitaet ist nicht hinterlegt.',
|
||||||
|
severity: 'information',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
// Verfuegbar, aber ohne Wert: Auch hier darf keine Null stehen.
|
||||||
|
expect(await screen.findByText('nicht bezifferbar')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt einen Fehler mit Vorgangsnummer statt einer leeren Seite', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue({
|
||||||
|
status: 500,
|
||||||
|
ok: false,
|
||||||
|
headers: new Headers(),
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
error: {
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
message: 'Bei der Verarbeitung ist ein Fehler aufgetreten.',
|
||||||
|
request_id: 'abc-123',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as unknown as Response);
|
||||||
|
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Bei der Verarbeitung ist ein Fehler aufgetreten.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/abc-123/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
124
apps/web/src/features/dashboard/DashboardPage.tsx
Normal file
124
apps/web/src/features/dashboard/DashboardPage.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Uebersicht der Anlage.
|
||||||
|
*
|
||||||
|
* Der Plan (§14) nennt zehn Kennzahlen. Sieben haben eine Datengrundlage, drei
|
||||||
|
* nicht — und die drei erscheinen trotzdem, mit der Angabe, was fehlt. Ein
|
||||||
|
* weggelassenes Feld sieht aus wie ein vergessenes; ein mit einer Null
|
||||||
|
* gefuelltes waere eine erfundene Statistik (PROMPT.md §139).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useApiResource } from '../../api/useApiResource';
|
||||||
|
import { ErrorState, LoadingState } from '../../components/PageState';
|
||||||
|
import { fetchDashboard } from './dashboardApi';
|
||||||
|
import type { Dashboard, DashboardWidget } from './dashboardApi';
|
||||||
|
|
||||||
|
/** Zeigt die Uebersicht. */
|
||||||
|
export function DashboardPage(): React.JSX.Element {
|
||||||
|
const loadDashboard = useCallback((abortSignal: AbortSignal) => fetchDashboard(abortSignal), []);
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<Dashboard>(loadDashboard);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page__header">
|
||||||
|
<h1 className="page__title">Uebersicht</h1>
|
||||||
|
|
||||||
|
{data !== null && (
|
||||||
|
<span className="page__meta">
|
||||||
|
{data.available_count} von {data.widgets.length} Kennzahlen haben eine Datengrundlage
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loadState === 'loading' && <LoadingState what="Die Kennzahlen" />}
|
||||||
|
{loadState === 'failed' && loadError !== null && (
|
||||||
|
<ErrorState error={loadError} onRetry={reload} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && data !== null && (
|
||||||
|
<div className="widget-grid">
|
||||||
|
{data.widgets.map((dashboardWidget) => (
|
||||||
|
<WidgetCard key={dashboardWidget.key} widget={dashboardWidget} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften einer Kennzahlkachel. */
|
||||||
|
interface WidgetCardProperties {
|
||||||
|
/** Die dargestellte Kennzahl. */
|
||||||
|
readonly widget: DashboardWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt eine einzelne Kennzahl. */
|
||||||
|
function WidgetCard({ widget }: WidgetCardProperties): React.JSX.Element {
|
||||||
|
if (!widget.available) {
|
||||||
|
return (
|
||||||
|
<article className="widget widget--unavailable">
|
||||||
|
<h2 className="widget__title">{widget.title}</h2>
|
||||||
|
<p className="widget__unavailable">noch nicht verfuegbar</p>
|
||||||
|
<p className="widget__detail">{widget.unavailable_reason}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={`widget widget--${widget.severity}`}>
|
||||||
|
<h2 className="widget__title">{widget.title}</h2>
|
||||||
|
|
||||||
|
<p className="widget__value">
|
||||||
|
{widget.value === undefined ? (
|
||||||
|
// Kein Wert heisst nicht null. Eine Kennzahl, die sich nicht bilden
|
||||||
|
// laesst, sagt das — der Grund steht im Detailtext darunter.
|
||||||
|
<span className="widget__value-missing">nicht bezifferbar</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{formatWidgetValue(widget.value)}
|
||||||
|
{widget.unit !== undefined && <span className="widget__unit"> {widget.unit}</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{widget.detail !== undefined && <p className="widget__detail">{widget.detail}</p>}
|
||||||
|
|
||||||
|
{widget.breakdown !== undefined && (
|
||||||
|
<dl className="widget__breakdown">
|
||||||
|
{Object.entries(widget.breakdown).map(([breakdownKey, breakdownValue]) => (
|
||||||
|
<div className="widget__breakdown-item" key={breakdownKey}>
|
||||||
|
<dt>{formatBreakdownLabel(breakdownKey)}</dt>
|
||||||
|
<dd>{breakdownValue.toLocaleString('de-DE')}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formatiert die Hauptzahl einer Kennzahl. */
|
||||||
|
function formatWidgetValue(widgetValue: number): string {
|
||||||
|
// Ein kleiner Wert wird nicht auf null gerundet: „0 %" belegter Speicher liest
|
||||||
|
// sich wie „nichts abgelegt", obwohl Daten da sind. Dieselbe Ueberlegung wie
|
||||||
|
// bei der Recovery Assurance — eine Zahl darf nicht mehr behaupten, als sie
|
||||||
|
// weiss, und auch nicht weniger.
|
||||||
|
if (widgetValue > 0 && widgetValue < 0.1) {
|
||||||
|
return '< 0,1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ganze Zahlen ohne Nachkommastellen, gebrochene mit einer: „99,7 %" sagt
|
||||||
|
// mehr als „100 %", wenn drei von tausend Laeufen scheiterten.
|
||||||
|
if (Number.isInteger(widgetValue)) {
|
||||||
|
return widgetValue.toLocaleString('de-DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
return widgetValue.toLocaleString('de-DE', { maximumFractionDigits: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Macht aus einem Schluessel der Aufschluesselung eine Beschriftung. */
|
||||||
|
function formatBreakdownLabel(breakdownKey: string): string {
|
||||||
|
const readableLabel = breakdownKey.replace(/_/g, ' ');
|
||||||
|
|
||||||
|
return readableLabel.charAt(0).toUpperCase() + readableLabel.slice(1);
|
||||||
|
}
|
||||||
240
apps/web/src/features/dashboard/RecoveryPointsPage.tsx
Normal file
240
apps/web/src/features/dashboard/RecoveryPointsPage.tsx
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* Wiederherstellungspunkte.
|
||||||
|
*
|
||||||
|
* Die zentrale Auskunft der Anlage. Sie beantwortet nicht „welche Backups gibt
|
||||||
|
* es", sondern „auf welche kann ich mich verlassen" — deshalb steht die
|
||||||
|
* Einstufung in jeder Zeile und nicht in einem Detailfenster.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useApiResource } from '../../api/useApiResource';
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from '../../components/PageState';
|
||||||
|
import { fetchRecoveryPoints } from './dashboardApi';
|
||||||
|
import type { RecoveryPoint } from './dashboardApi';
|
||||||
|
|
||||||
|
/** Auswahlmoeglichkeiten des Einstufungsfilters. */
|
||||||
|
const CLASSIFICATION_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [
|
||||||
|
{ value: '', label: 'Alle Einstufungen' },
|
||||||
|
{ value: 'recoverable', label: 'Nachweislich wiederherstellbar' },
|
||||||
|
{ value: 'verified', label: 'Geprueft' },
|
||||||
|
{ value: 'successful', label: 'Ungeprueft' },
|
||||||
|
{ value: 'corrupted', label: 'Beschaedigt' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Zeigt die Wiederherstellungspunkte. */
|
||||||
|
export function RecoveryPointsPage(): React.JSX.Element {
|
||||||
|
const [classificationFilter, setClassificationFilter] = useState('');
|
||||||
|
const [includeDeleted, setIncludeDeleted] = useState(false);
|
||||||
|
const [onlyProtected, setOnlyProtected] = useState(false);
|
||||||
|
|
||||||
|
// Der Schluessel bildet die Filter ab: Aendert er sich, wird neu geladen.
|
||||||
|
const filterKey = `${classificationFilter}|${String(includeDeleted)}|${String(onlyProtected)}`;
|
||||||
|
|
||||||
|
const loadRecoveryPoints = useCallback(
|
||||||
|
(abortSignal: AbortSignal) =>
|
||||||
|
fetchRecoveryPoints({ classification: classificationFilter, includeDeleted, onlyProtected }, abortSignal),
|
||||||
|
[classificationFilter, includeDeleted, onlyProtected],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<readonly RecoveryPoint[]>(
|
||||||
|
loadRecoveryPoints,
|
||||||
|
filterKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page__header">
|
||||||
|
<h1 className="page__title">Wiederherstellungspunkte</h1>
|
||||||
|
|
||||||
|
{data !== null && <span className="page__meta">{data.length} Punkte</span>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="filter-bar">
|
||||||
|
<label className="filter-bar__field">
|
||||||
|
<span className="filter-bar__label">Einstufung</span>
|
||||||
|
<select
|
||||||
|
className="filter-bar__select"
|
||||||
|
value={classificationFilter}
|
||||||
|
onChange={(changeEvent) => setClassificationFilter(changeEvent.target.value)}
|
||||||
|
>
|
||||||
|
{CLASSIFICATION_OPTIONS.map((filterOption) => (
|
||||||
|
<option key={filterOption.value} value={filterOption.value}>
|
||||||
|
{filterOption.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="filter-bar__checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlyProtected}
|
||||||
|
onChange={(changeEvent) => setOnlyProtected(changeEvent.target.checked)}
|
||||||
|
/>
|
||||||
|
Nur geschuetzte
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Geloeschte Punkte erscheinen nur auf Nachfrage — die Frage „warum ist
|
||||||
|
das Backup von vorletzter Woche weg?" muss aber beantwortbar sein. */}
|
||||||
|
<label className="filter-bar__checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeDeleted}
|
||||||
|
onChange={(changeEvent) => setIncludeDeleted(changeEvent.target.checked)}
|
||||||
|
/>
|
||||||
|
Geloeschte einbeziehen
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadState === 'loading' && <LoadingState what="Die Wiederherstellungspunkte" />}
|
||||||
|
{loadState === 'failed' && loadError !== null && (
|
||||||
|
<ErrorState error={loadError} onRetry={reload} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && data !== null && data.length === 0 && (
|
||||||
|
<EmptyState message="Zu diesen Filtern gibt es keinen Wiederherstellungspunkt." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && data !== null && data.length > 0 && (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Zeitpunkt</th>
|
||||||
|
<th scope="col">Auftrag</th>
|
||||||
|
<th scope="col">Art</th>
|
||||||
|
<th scope="col">Groesse</th>
|
||||||
|
<th scope="col">Einstufung</th>
|
||||||
|
<th scope="col">Bewertung</th>
|
||||||
|
<th scope="col">Schutz</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{data.map((recoveryPoint) => (
|
||||||
|
<RecoveryPointRow key={recoveryPoint.id} recoveryPoint={recoveryPoint} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften einer Tabellenzeile. */
|
||||||
|
interface RecoveryPointRowProperties {
|
||||||
|
/** Der dargestellte Punkt. */
|
||||||
|
readonly recoveryPoint: RecoveryPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt einen Wiederherstellungspunkt als Tabellenzeile. */
|
||||||
|
function RecoveryPointRow({ recoveryPoint }: RecoveryPointRowProperties): React.JSX.Element {
|
||||||
|
const isDeleted = recoveryPoint.deleted_at !== undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className={isDeleted ? 'data-table__row--deleted' : undefined}>
|
||||||
|
<td>
|
||||||
|
{formatTimestamp(recoveryPoint.completed_at)}
|
||||||
|
<span className="data-table__secondary">{recoveryPoint.repository_name}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>{recoveryPoint.job_name ?? '—'}</td>
|
||||||
|
|
||||||
|
<td>{recoveryPoint.backup_type === 'full' ? 'Voll' : 'Zusatz'}</td>
|
||||||
|
|
||||||
|
<td className="data-table__number">{formatBytes(recoveryPoint.logical_bytes)}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<ClassificationBadge classification={recoveryPoint.classification} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="data-table__number">
|
||||||
|
{/* Keine Bewertung heisst nicht null Prozent. Die Unterscheidung ist der
|
||||||
|
Kern der Recovery Assurance (Phase 10). */}
|
||||||
|
{recoveryPoint.assurance_score === undefined ? (
|
||||||
|
<span className="data-table__unknown">nicht berechnet</span>
|
||||||
|
) : (
|
||||||
|
`${recoveryPoint.assurance_score} %`
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{isDeleted ? (
|
||||||
|
<span className="badge badge--neutral" title={recoveryPoint.deletion_reason}>
|
||||||
|
geloescht
|
||||||
|
</span>
|
||||||
|
) : recoveryPoint.legal_hold ? (
|
||||||
|
<span className="badge badge--high">Legal Hold</span>
|
||||||
|
) : recoveryPoint.is_protected ? (
|
||||||
|
<span className="badge badge--info" title={formatTimestamp(recoveryPoint.immutable_until)}>
|
||||||
|
geschuetzt
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="data-table__unknown">frei</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Einstufungsanzeige. */
|
||||||
|
interface ClassificationBadgeProperties {
|
||||||
|
/** Die Einstufung; fehlt bei ungeprueften Punkten. */
|
||||||
|
readonly classification: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Einstufung eines Punktes. */
|
||||||
|
function ClassificationBadge({ classification }: ClassificationBadgeProperties): React.JSX.Element {
|
||||||
|
switch (classification) {
|
||||||
|
case 'recoverable':
|
||||||
|
return <span className="badge badge--healthy">wiederherstellbar</span>;
|
||||||
|
case 'verified':
|
||||||
|
return <span className="badge badge--info">geprueft</span>;
|
||||||
|
case 'corrupted':
|
||||||
|
return <span className="badge badge--critical">beschaedigt</span>;
|
||||||
|
case 'failed':
|
||||||
|
return <span className="badge badge--critical">gescheitert</span>;
|
||||||
|
default:
|
||||||
|
// „Ungeprueft" ist eine Aussage, keine Luecke: Der Punkt wurde nie
|
||||||
|
// zurueckgeschrieben, und das soll man sehen.
|
||||||
|
return <span className="badge badge--warning">ungeprueft</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt einen Zeitstempel in deutscher Schreibweise. */
|
||||||
|
function formatTimestamp(isoTimestamp: string | undefined): string {
|
||||||
|
if (isoTimestamp === undefined) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(isoTimestamp).toLocaleString('de-DE', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Faktor zwischen zwei Groesseneinheiten. */
|
||||||
|
const BYTE_UNIT_STEP = 1024;
|
||||||
|
|
||||||
|
/** Schreibt eine Datenmenge lesbar. */
|
||||||
|
function formatBytes(byteCount: number): string {
|
||||||
|
if (byteCount < BYTE_UNIT_STEP) {
|
||||||
|
return `${byteCount} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitNames = ['KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
|
||||||
|
let remainingValue = byteCount;
|
||||||
|
let chosenUnit = unitNames[0];
|
||||||
|
|
||||||
|
for (const unitName of unitNames) {
|
||||||
|
remainingValue /= BYTE_UNIT_STEP;
|
||||||
|
chosenUnit = unitName;
|
||||||
|
|
||||||
|
if (remainingValue < BYTE_UNIT_STEP) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${remainingValue.toLocaleString('de-DE', { maximumFractionDigits: 1 })} ${chosenUnit}`;
|
||||||
|
}
|
||||||
137
apps/web/src/features/dashboard/dashboardApi.ts
Normal file
137
apps/web/src/features/dashboard/dashboardApi.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Zugriff auf die Uebersicht und die Wiederherstellungspunkte.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { requestApi } from '../../api/client';
|
||||||
|
|
||||||
|
/** Statusfarbe einer Kennzahl. */
|
||||||
|
export type WidgetSeverity = 'healthy' | 'warning' | 'high' | 'critical' | 'information';
|
||||||
|
|
||||||
|
/** Eine Kennzahl der Uebersicht. */
|
||||||
|
export interface DashboardWidget {
|
||||||
|
/** Maschinenlesbarer Bezeichner. */
|
||||||
|
readonly key: string;
|
||||||
|
/** Ueberschrift. */
|
||||||
|
readonly title: string;
|
||||||
|
/** Meldet, ob es eine Datengrundlage gibt. */
|
||||||
|
readonly available: boolean;
|
||||||
|
/** Erklaert eine fehlende Datengrundlage. */
|
||||||
|
readonly unavailable_reason?: string;
|
||||||
|
/** Hauptzahl; fehlt, wenn sie sich nicht bilden laesst. */
|
||||||
|
readonly value?: number;
|
||||||
|
/** Einheit der Hauptzahl. */
|
||||||
|
readonly unit?: string;
|
||||||
|
/** Erlaeuterung in einem Satz. */
|
||||||
|
readonly detail?: string;
|
||||||
|
/** Statusfarbe. */
|
||||||
|
readonly severity: WidgetSeverity;
|
||||||
|
/** Ergaenzende Einzelwerte. */
|
||||||
|
readonly breakdown?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Uebersicht der Anlage. */
|
||||||
|
export interface Dashboard {
|
||||||
|
/** Kennzahlen in Anzeigereihenfolge. */
|
||||||
|
readonly widgets: readonly DashboardWidget[];
|
||||||
|
/** Zahl der Kennzahlen mit Datengrundlage. */
|
||||||
|
readonly available_count: number;
|
||||||
|
/** Zeitpunkt der Erhebung in UTC. */
|
||||||
|
readonly generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Wiederherstellungspunkt in der Uebersicht. */
|
||||||
|
export interface RecoveryPoint {
|
||||||
|
/** Oeffentlicher Bezeichner. */
|
||||||
|
readonly id: string;
|
||||||
|
/** Repository des Punktes. */
|
||||||
|
readonly repository_id: string;
|
||||||
|
/** Name des Repositorys. */
|
||||||
|
readonly repository_name: string;
|
||||||
|
/** Erzeugender Auftrag. */
|
||||||
|
readonly job_id?: string;
|
||||||
|
/** Name des Auftrags. */
|
||||||
|
readonly job_name?: string;
|
||||||
|
/** Kennung innerhalb des Repositorys. */
|
||||||
|
readonly backup_id_in_repository: string;
|
||||||
|
/** Art des Backups. */
|
||||||
|
readonly backup_type: string;
|
||||||
|
/** Zustand. */
|
||||||
|
readonly status: string;
|
||||||
|
/** Erreichte Konsistenz. */
|
||||||
|
readonly consistency_level?: string;
|
||||||
|
/** Menge der Ursprungsdaten. */
|
||||||
|
readonly logical_bytes: number;
|
||||||
|
/** Abgelegte Datenmenge. */
|
||||||
|
readonly encrypted_bytes: number;
|
||||||
|
/** Ende in UTC. */
|
||||||
|
readonly completed_at?: string;
|
||||||
|
/** Objektive Einstufung. */
|
||||||
|
readonly classification?: string;
|
||||||
|
/** Letzte Integritaetspruefung in UTC. */
|
||||||
|
readonly last_verified_at?: string;
|
||||||
|
/** Letzter Wiederherstellungstest in UTC. */
|
||||||
|
readonly last_restore_test_at?: string;
|
||||||
|
/** Zuletzt berechnete Bewertung in Prozent. */
|
||||||
|
readonly assurance_score?: number;
|
||||||
|
/** Ende der Aufbewahrungspflicht in UTC. */
|
||||||
|
readonly immutable_until?: string;
|
||||||
|
/** Meldet einen unbefristeten Schutz. */
|
||||||
|
readonly legal_hold: boolean;
|
||||||
|
/** Meldet, ob eine Loeschung derzeit unzulaessig ist. */
|
||||||
|
readonly is_protected: boolean;
|
||||||
|
/** Zeitpunkt der Loeschung in UTC. */
|
||||||
|
readonly deleted_at?: string;
|
||||||
|
/** Begruendung der Loeschung. */
|
||||||
|
readonly deletion_reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter der Liste von Wiederherstellungspunkten. */
|
||||||
|
export interface RecoveryPointFilter {
|
||||||
|
/** Beschraenkung auf ein Repository. */
|
||||||
|
readonly repositoryId?: string;
|
||||||
|
/** Beschraenkung auf eine Einstufung. */
|
||||||
|
readonly classification?: string;
|
||||||
|
/** Nimmt geloeschte Punkte auf. */
|
||||||
|
readonly includeDeleted?: boolean;
|
||||||
|
/** Beschraenkt auf geschuetzte Punkte. */
|
||||||
|
readonly onlyProtected?: boolean;
|
||||||
|
/** Angeforderte Seite. */
|
||||||
|
readonly page?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt die Uebersicht. */
|
||||||
|
export async function fetchDashboard(abortSignal?: AbortSignal): Promise<Dashboard> {
|
||||||
|
return requestApi<Dashboard>('/dashboard', abortSignal ? { signal: abortSignal } : {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt eine Seite von Wiederherstellungspunkten. */
|
||||||
|
export async function fetchRecoveryPoints(
|
||||||
|
filter: RecoveryPointFilter = {},
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
): Promise<readonly RecoveryPoint[]> {
|
||||||
|
const queryParameters = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filter.repositoryId !== undefined && filter.repositoryId !== '') {
|
||||||
|
queryParameters.set('repository_id', filter.repositoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.classification !== undefined && filter.classification !== '') {
|
||||||
|
queryParameters.set('classification', filter.classification);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.includeDeleted === true) {
|
||||||
|
queryParameters.set('include_deleted', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.onlyProtected === true) {
|
||||||
|
queryParameters.set('only_protected', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
queryParameters.set('page', String(filter.page ?? 1));
|
||||||
|
queryParameters.set('page_size', '50');
|
||||||
|
|
||||||
|
return requestApi<readonly RecoveryPoint[]>(
|
||||||
|
`/backups?${queryParameters.toString()}`,
|
||||||
|
abortSignal ? { signal: abortSignal } : {},
|
||||||
|
);
|
||||||
|
}
|
||||||
111
apps/web/src/features/health/SystemHealthPanel.css
Normal file
111
apps/web/src/features/health/SystemHealthPanel.css
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
/* Darstellung der Systemzustandsanzeige. */
|
||||||
|
|
||||||
|
.health-panel {
|
||||||
|
background-color: var(--color-surface-raised);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__title {
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__overall {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__version {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__error {
|
||||||
|
/* Der farbige Rand links betont die Meldung, ohne die Flaeche einzufaerben. */
|
||||||
|
border-left: 3px solid var(--color-status-critical);
|
||||||
|
padding-left: var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__error-message {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__error-detail {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__error-detail code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__button {
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--color-surface-page);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__button:hover {
|
||||||
|
border-color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__caption {
|
||||||
|
/* Die Beschriftung dient Screenreadern und bleibt visuell verborgen. */
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__table th,
|
||||||
|
.health-panel__table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-bottom: var(--border-width) solid var(--color-border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__table thead th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__component-message {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-panel__latency {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
/* Rechtsbuendig, damit Zahlen vergleichbar untereinander stehen. */
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
109
apps/web/src/features/health/SystemHealthPanel.test.tsx
Normal file
109
apps/web/src/features/health/SystemHealthPanel.test.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { SystemHealthPanel } from './SystemHealthPanel';
|
||||||
|
|
||||||
|
/** Baut eine Antwort, wie sie GET /api/v1/health liefert. */
|
||||||
|
function buildHealthResponse(responseBody: unknown, statusCode = 200): Response {
|
||||||
|
return new Response(JSON.stringify(responseBody), {
|
||||||
|
status: statusCode,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SystemHealthPanel', () => {
|
||||||
|
it('zeigt den vom Backend gemeldeten Zustand', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
buildHealthResponse({
|
||||||
|
data: {
|
||||||
|
status: 'healthy',
|
||||||
|
components: {
|
||||||
|
database: { status: 'healthy', latency_ms: 1.2, checked_at: '2026-08-10T13:00:00Z' },
|
||||||
|
},
|
||||||
|
version: '0.1.0-test',
|
||||||
|
environment: 'test',
|
||||||
|
},
|
||||||
|
meta: { request_id: 'req-1' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<SystemHealthPanel />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('database')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Der Zustand wird zusaetzlich zur Farbe als Text ausgegeben (PROMPT.md §107).
|
||||||
|
expect(screen.getAllByText('Fehlerfrei').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText(/0\.1\.0-test/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt einen kritischen Zustand ehrlich an', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(
|
||||||
|
buildHealthResponse(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
status: 'offline',
|
||||||
|
components: {
|
||||||
|
database: {
|
||||||
|
status: 'offline',
|
||||||
|
message: 'Syncova kann die Control-Plane-Datenbank nicht erreichen.',
|
||||||
|
latency_ms: 5000,
|
||||||
|
checked_at: '2026-08-10T13:00:00Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
version: '0.1.0-test',
|
||||||
|
environment: 'test',
|
||||||
|
},
|
||||||
|
meta: { request_id: 'req-2' },
|
||||||
|
},
|
||||||
|
503,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<SystemHealthPanel />);
|
||||||
|
|
||||||
|
// Ein Problem darf niemals beschoenigt werden (PROMPT.md §140).
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText('Syncova kann die Control-Plane-Datenbank nicht erreichen.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getAllByText('Nicht erreichbar').length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt eine verstaendliche Meldung, wenn die API nicht erreichbar ist', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
|
||||||
|
|
||||||
|
render(<SystemHealthPanel />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Statt eines rohen Fehlercodes erscheint eine erklaerende Meldung (PROMPT.md §124).
|
||||||
|
expect(screen.getByText(/nicht erreichbar/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Erneut versuchen' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt keine Daten, solange der Zustand nicht geladen ist', () => {
|
||||||
|
// Eine Anfrage, die nie antwortet, haelt die Komponente im Ladezustand.
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockReturnValue(new Promise(() => {})));
|
||||||
|
|
||||||
|
render(<SystemHealthPanel />);
|
||||||
|
|
||||||
|
// Es darf kein Platzhalterwert erscheinen (PROMPT.md §138/§139).
|
||||||
|
expect(screen.getByText(/wird ermittelt/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Fehlerfrei')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
97
apps/web/src/features/health/SystemHealthPanel.tsx
Normal file
97
apps/web/src/features/health/SystemHealthPanel.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Anzeige des Systemzustands.
|
||||||
|
*
|
||||||
|
* Die Darstellung folgt PROMPT.md §48/§124: statt eines rohen Fehlercodes
|
||||||
|
* erhaelt der Anwender eine verstaendliche Erklaerung und eine Handlungsempfehlung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { StatusIndicator } from '../../components/StatusIndicator';
|
||||||
|
import { useSystemHealth } from './useSystemHealth';
|
||||||
|
import './SystemHealthPanel.css';
|
||||||
|
|
||||||
|
/** Abstand der automatischen Aktualisierung in Millisekunden. */
|
||||||
|
const REFRESH_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
|
/** Zeigt den Zustand des Backends und seiner Komponenten. */
|
||||||
|
export function SystemHealthPanel(): React.JSX.Element {
|
||||||
|
const { loadState, systemHealth, loadError, reload } = useSystemHealth(REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
if (loadState === 'loading') {
|
||||||
|
return (
|
||||||
|
<section className="health-panel" aria-busy="true">
|
||||||
|
<h2 className="health-panel__title">Systemzustand</h2>
|
||||||
|
<p className="health-panel__hint">Der Systemzustand wird ermittelt …</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadState === 'failed' || systemHealth === null) {
|
||||||
|
return (
|
||||||
|
<section className="health-panel">
|
||||||
|
<h2 className="health-panel__title">Systemzustand</h2>
|
||||||
|
|
||||||
|
{/* role="alert" sorgt dafuer, dass Screenreader den Fehler ansagen. */}
|
||||||
|
<div className="health-panel__error" role="alert">
|
||||||
|
<p className="health-panel__error-message">
|
||||||
|
{loadError?.message ?? 'Der Systemzustand konnte nicht ermittelt werden.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Die technischen Angaben helfen beim Melden eines Vorfalls. */}
|
||||||
|
{loadError && loadError.requestId !== '' && (
|
||||||
|
<p className="health-panel__error-detail">
|
||||||
|
Fehlercode: <code>{loadError.code}</code> · Request-ID: <code>{loadError.requestId}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" className="health-panel__button" onClick={reload}>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentEntries = Object.entries(systemHealth.components);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="health-panel">
|
||||||
|
<h2 className="health-panel__title">Systemzustand</h2>
|
||||||
|
|
||||||
|
<div className="health-panel__overall">
|
||||||
|
<StatusIndicator status={systemHealth.status} />
|
||||||
|
<span className="health-panel__version">
|
||||||
|
Version {systemHealth.version} · Umgebung {systemHealth.environment}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{componentEntries.length === 0 ? (
|
||||||
|
<p className="health-panel__hint">Es sind keine Komponenten registriert.</p>
|
||||||
|
) : (
|
||||||
|
<table className="health-panel__table">
|
||||||
|
<caption className="health-panel__caption">Zustand der einzelnen Komponenten</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Komponente</th>
|
||||||
|
<th scope="col">Zustand</th>
|
||||||
|
<th scope="col">Antwortzeit</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{componentEntries.map(([componentName, componentHealth]) => (
|
||||||
|
<tr key={componentName}>
|
||||||
|
<th scope="row">{componentName}</th>
|
||||||
|
<td>
|
||||||
|
<StatusIndicator status={componentHealth.status} />
|
||||||
|
{componentHealth.message && (
|
||||||
|
<span className="health-panel__component-message">{componentHealth.message}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="health-panel__latency">{componentHealth.latency_ms.toFixed(1)} ms</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
apps/web/src/features/health/useSystemHealth.ts
Normal file
99
apps/web/src/features/health/useSystemHealth.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Hook zum Laden des Systemzustands.
|
||||||
|
*
|
||||||
|
* Der Hook zeigt niemals Platzhalter- oder Demo-Werte: solange kein echtes
|
||||||
|
* Ergebnis vorliegt, bleibt der Zustand ausdruecklich "laedt" oder "Fehler"
|
||||||
|
* (PROMPT.md §138/§139).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ApiError, requestApi } from '../../api/client';
|
||||||
|
import type { SystemHealth } from '../../types/api';
|
||||||
|
|
||||||
|
/** Ladezustand der Systemzustandsabfrage. */
|
||||||
|
export type HealthLoadState = 'loading' | 'loaded' | 'failed';
|
||||||
|
|
||||||
|
/** Rueckgabewert des Hooks. */
|
||||||
|
export interface UseSystemHealthResult {
|
||||||
|
/** Aktueller Ladezustand. */
|
||||||
|
loadState: HealthLoadState;
|
||||||
|
/** Geladener Systemzustand; null, solange keiner vorliegt. */
|
||||||
|
systemHealth: SystemHealth | null;
|
||||||
|
/** Aufgetretener Fehler; null, wenn kein Fehler vorliegt. */
|
||||||
|
loadError: ApiError | null;
|
||||||
|
/** Laedt den Systemzustand erneut. */
|
||||||
|
reload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laedt den Systemzustand von GET /api/v1/health.
|
||||||
|
*
|
||||||
|
* @param refreshIntervalMs Abstand automatischer Aktualisierungen in Millisekunden.
|
||||||
|
* Ein Wert von 0 deaktiviert die automatische Aktualisierung.
|
||||||
|
*/
|
||||||
|
export function useSystemHealth(refreshIntervalMs = 0): UseSystemHealthResult {
|
||||||
|
const [loadState, setLoadState] = useState<HealthLoadState>('loading');
|
||||||
|
const [systemHealth, setSystemHealth] = useState<SystemHealth | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<ApiError | null>(null);
|
||||||
|
|
||||||
|
// reloadCounter erzwingt einen erneuten Lauf des Effekts bei manuellem Neuladen.
|
||||||
|
const [reloadCounter, setReloadCounter] = useState(0);
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
setReloadCounter((previousCounter) => previousCounter + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Der Controller bricht laufende Anfragen ab, wenn die Komponente verschwindet.
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
async function loadSystemHealth(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const loadedHealth = await requestApi<SystemHealth>('/health', {
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSystemHealth(loadedHealth);
|
||||||
|
setLoadError(null);
|
||||||
|
setLoadState('loaded');
|
||||||
|
} catch (caughtError) {
|
||||||
|
// Ein Abbruch ist kein Fehler, sondern Folge des Aufraeumens.
|
||||||
|
if (caughtError instanceof DOMException && caughtError.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der zuletzt bekannte Zustand wird verworfen: eine veraltete Anzeige
|
||||||
|
// als aktuellen Zustand auszugeben waere irrefuehrend.
|
||||||
|
setSystemHealth(null);
|
||||||
|
setLoadError(
|
||||||
|
caughtError instanceof ApiError
|
||||||
|
? caughtError
|
||||||
|
: new ApiError({
|
||||||
|
code: 'UNEXPECTED_ERROR',
|
||||||
|
message: 'Der Systemzustand konnte nicht ermittelt werden.',
|
||||||
|
statusCode: 0,
|
||||||
|
requestId: '',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setLoadState('failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadSystemHealth();
|
||||||
|
|
||||||
|
if (refreshIntervalMs <= 0) {
|
||||||
|
return () => abortController.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshTimer = window.setInterval(() => {
|
||||||
|
void loadSystemHealth();
|
||||||
|
}, refreshIntervalMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(refreshTimer);
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
}, [refreshIntervalMs, reloadCounter]);
|
||||||
|
|
||||||
|
return { loadState, systemHealth, loadError, reload };
|
||||||
|
}
|
||||||
164
apps/web/src/features/identity/IdentityPages.tsx
Normal file
164
apps/web/src/features/identity/IdentityPages.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* Benutzer und Rollen.
|
||||||
|
*
|
||||||
|
* Beide Seiten zeigen ausschliesslich an. Anlegen und Aendern gehen weiterhin
|
||||||
|
* ueber die API: Eine Maske, die Rollen bearbeitet, muss die Sonderfaelle
|
||||||
|
* beherrschen — den letzten Administrator, mitgelieferte unveraenderliche Rollen
|
||||||
|
* — und die gehoeren geprueft, nicht nebenbei gebaut.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { requestApi } from '../../api/client';
|
||||||
|
import { useApiResource } from '../../api/useApiResource';
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from '../../components/PageState';
|
||||||
|
|
||||||
|
/** Ein Benutzerkonto. */
|
||||||
|
interface UserAccount {
|
||||||
|
/** Oeffentlicher Bezeichner. */
|
||||||
|
readonly id: string;
|
||||||
|
/** Anmeldename. */
|
||||||
|
readonly username: string;
|
||||||
|
/** Postanschrift. */
|
||||||
|
readonly email?: string;
|
||||||
|
/** Zustand des Kontos. */
|
||||||
|
readonly status: string;
|
||||||
|
/** Meldet einen eingerichteten zweiten Faktor. */
|
||||||
|
readonly mfa_enabled: boolean;
|
||||||
|
/** Zugewiesene Rollen. */
|
||||||
|
readonly roles?: readonly string[];
|
||||||
|
/** Letzte Anmeldung in UTC. */
|
||||||
|
readonly last_login_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Rolle. */
|
||||||
|
interface RoleDefinition {
|
||||||
|
/** Oeffentlicher Bezeichner. */
|
||||||
|
readonly id: string;
|
||||||
|
/** Name der Rolle. */
|
||||||
|
readonly name: string;
|
||||||
|
/** Beschreibung. */
|
||||||
|
readonly description?: string;
|
||||||
|
/** Zugeordnete Berechtigungen. */
|
||||||
|
readonly permissions?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Benutzerkonten. */
|
||||||
|
export function UsersPage(): React.JSX.Element {
|
||||||
|
const loadUsers = useCallback(
|
||||||
|
(abortSignal: AbortSignal) => requestApi<readonly UserAccount[]>('/users', { signal: abortSignal }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<readonly UserAccount[]>(loadUsers);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page__header">
|
||||||
|
<h1 className="page__title">Benutzer</h1>
|
||||||
|
{data !== null && <span className="page__meta">{data.length} Konten</span>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loadState === 'loading' && <LoadingState what="Die Benutzerkonten" />}
|
||||||
|
{loadState === 'failed' && loadError !== null && <ErrorState error={loadError} onRetry={reload} />}
|
||||||
|
{loadState === 'loaded' && data !== null && data.length === 0 && (
|
||||||
|
<EmptyState message="Es ist kein Konto vorhanden." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && data !== null && data.length > 0 && (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Anmeldename</th>
|
||||||
|
<th scope="col">Rollen</th>
|
||||||
|
<th scope="col">Zustand</th>
|
||||||
|
<th scope="col">Zweiter Faktor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{data.map((userAccount) => (
|
||||||
|
<tr key={userAccount.id}>
|
||||||
|
<td>
|
||||||
|
{userAccount.username}
|
||||||
|
{userAccount.email !== undefined && (
|
||||||
|
<span className="data-table__secondary">{userAccount.email}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>{userAccount.roles?.join(', ') ?? '—'}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
userAccount.status === 'active' ? 'badge badge--healthy' : 'badge badge--neutral'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{userAccount.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{/* Ein fehlender zweiter Faktor ist ein Sicherheitsbefund und
|
||||||
|
wird benannt, statt ihn zu verschweigen (PROMPT.md §90). */}
|
||||||
|
{userAccount.mfa_enabled ? (
|
||||||
|
<span className="badge badge--healthy">eingerichtet</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge--warning">fehlt</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Rollen samt Berechtigungen. */
|
||||||
|
export function RolesPage(): React.JSX.Element {
|
||||||
|
const loadRoles = useCallback(
|
||||||
|
(abortSignal: AbortSignal) => requestApi<readonly RoleDefinition[]>('/roles', { signal: abortSignal }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<readonly RoleDefinition[]>(loadRoles);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page__header">
|
||||||
|
<h1 className="page__title">Rollen</h1>
|
||||||
|
{data !== null && <span className="page__meta">{data.length} Rollen</span>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loadState === 'loading' && <LoadingState what="Die Rollen" />}
|
||||||
|
{loadState === 'failed' && loadError !== null && <ErrorState error={loadError} onRetry={reload} />}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && data !== null && (
|
||||||
|
<ul className="card-list">
|
||||||
|
{data.map((roleDefinition) => (
|
||||||
|
<li className="card" key={roleDefinition.id}>
|
||||||
|
<h2 className="card__title">{roleDefinition.name}</h2>
|
||||||
|
|
||||||
|
{roleDefinition.description !== undefined && (
|
||||||
|
<p className="card__text">{roleDefinition.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Die Berechtigungen stehen ausgeschrieben da: „Backup Operator"
|
||||||
|
sagt einem Betreiber wenig, „backups.delete" alles. */}
|
||||||
|
<ul className="permission-list">
|
||||||
|
{(roleDefinition.permissions ?? []).map((permissionName) => (
|
||||||
|
<li className="permission-list__item" key={permissionName}>
|
||||||
|
{permissionName}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
357
apps/web/src/features/inventory/InventoryPages.tsx
Normal file
357
apps/web/src/features/inventory/InventoryPages.tsx
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
/**
|
||||||
|
* Bestandsseiten: Repositories, Agenten, Wiederherstellungen, Ereignisse.
|
||||||
|
*
|
||||||
|
* Vier Tabellen mit demselben Aufbau. Sie stehen zusammen, weil sie sich nur in
|
||||||
|
* Spalten und Ladefunktion unterscheiden — vier Dateien mit derselben Struktur
|
||||||
|
* waeren vier Orte, an denen die Fehlerbehandlung auseinanderlaufen kann.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useApiResource } from '../../api/useApiResource';
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from '../../components/PageState';
|
||||||
|
import {
|
||||||
|
fetchAgents,
|
||||||
|
fetchAuditEvents,
|
||||||
|
fetchRepositories,
|
||||||
|
fetchRestores,
|
||||||
|
} from './inventoryApi';
|
||||||
|
import type { Agent, AuditEvent, Repository, RestoreJob } from './inventoryApi';
|
||||||
|
|
||||||
|
/** Zeigt die Repositories. */
|
||||||
|
export function RepositoriesPage(): React.JSX.Element {
|
||||||
|
const loadRepositories = useCallback((abortSignal: AbortSignal) => fetchRepositories(abortSignal), []);
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<readonly Repository[]>(loadRepositories);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TablePage
|
||||||
|
title="Repositories"
|
||||||
|
what="Die Repositories"
|
||||||
|
emptyMessage="Es ist kein Repository eingerichtet. Ohne Repository kann nichts gesichert werden."
|
||||||
|
loadState={loadState}
|
||||||
|
loadError={loadError}
|
||||||
|
onRetry={reload}
|
||||||
|
itemCount={data?.length}
|
||||||
|
columns={['Name', 'Ablage', 'Zustand', 'Loeschschutz']}
|
||||||
|
>
|
||||||
|
{data?.map((repository) => (
|
||||||
|
<tr key={repository.id}>
|
||||||
|
<td>
|
||||||
|
{repository.name}
|
||||||
|
<span className="data-table__secondary">{repository.location}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>{repository.repository_type}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={repository.status} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<EnforcementCell repository={repository} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TablePage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Loeschschutzanzeige. */
|
||||||
|
interface EnforcementCellProperties {
|
||||||
|
/** Das dargestellte Repository. */
|
||||||
|
readonly repository: Repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt den gemessenen Loeschschutz eines Repositorys.
|
||||||
|
*
|
||||||
|
* Der Unterschied zwischen „nie gemessen" und „nur diese Software" ist der Kern
|
||||||
|
* von Phase 11: Beides bedeutet, dass sich niemand auf den Schutz verlassen
|
||||||
|
* sollte — aber aus verschiedenen Gruenden.
|
||||||
|
*/
|
||||||
|
function EnforcementCell({ repository }: EnforcementCellProperties): React.JSX.Element {
|
||||||
|
if (repository.enforcement_level === undefined || repository.enforcement_level === '') {
|
||||||
|
return (
|
||||||
|
<span className="data-table__unknown" title="Es wurde nie geprueft, was dieser Speicher verhindert.">
|
||||||
|
{repository.hardened ? 'gehaertet, ungemessen' : 'nicht gemessen'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (repository.enforcement_level) {
|
||||||
|
case 'filesystem':
|
||||||
|
return (
|
||||||
|
<span className="badge badge--healthy" title="Das Dateisystem verweigert die Loeschung.">
|
||||||
|
Dateisystem
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'storage':
|
||||||
|
return <span className="badge badge--healthy">Speicherebene</span>;
|
||||||
|
case 'advisory':
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="badge badge--warning"
|
||||||
|
title="Nur diese Software haelt sich daran. Wer Dateizugriff hat, kann loeschen."
|
||||||
|
>
|
||||||
|
nur Software
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <span className="badge badge--critical">kein Schutz</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Agenten. */
|
||||||
|
export function AgentsPage(): React.JSX.Element {
|
||||||
|
const loadAgents = useCallback((abortSignal: AbortSignal) => fetchAgents(abortSignal), []);
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<readonly Agent[]>(loadAgents);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TablePage
|
||||||
|
title="Agenten"
|
||||||
|
what="Die Agenten"
|
||||||
|
emptyMessage="Es ist kein Agent aufgenommen."
|
||||||
|
loadState={loadState}
|
||||||
|
loadError={loadError}
|
||||||
|
onRetry={reload}
|
||||||
|
itemCount={data?.length}
|
||||||
|
columns={['Name', 'System', 'Version', 'Zustand', 'Zuletzt gesehen']}
|
||||||
|
>
|
||||||
|
{data?.map((agent) => (
|
||||||
|
<tr key={agent.id}>
|
||||||
|
<td>{agent.name}</td>
|
||||||
|
<td>{agent.operating_system ?? '—'}</td>
|
||||||
|
<td>{agent.agent_version ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={agent.status} />
|
||||||
|
</td>
|
||||||
|
<td>{formatTimestamp(agent.last_seen_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TablePage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Wiederherstellungen. */
|
||||||
|
export function RestoresPage(): React.JSX.Element {
|
||||||
|
const loadRestores = useCallback((abortSignal: AbortSignal) => fetchRestores(abortSignal), []);
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<readonly RestoreJob[]>(loadRestores);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TablePage
|
||||||
|
title="Wiederherstellungen"
|
||||||
|
what="Die Wiederherstellungen"
|
||||||
|
emptyMessage="Es wurde noch keine Wiederherstellung angefordert."
|
||||||
|
loadState={loadState}
|
||||||
|
loadError={loadError}
|
||||||
|
onRetry={reload}
|
||||||
|
itemCount={data?.length}
|
||||||
|
columns={['Ziel', 'Zustand', 'Objekte', 'Datenmenge', 'Ende']}
|
||||||
|
>
|
||||||
|
{data?.map((restoreJob) => (
|
||||||
|
<tr key={restoreJob.id}>
|
||||||
|
<td>
|
||||||
|
{restoreJob.target_path}
|
||||||
|
{restoreJob.overwrite_existing && (
|
||||||
|
<span className="data-table__secondary">ueberschreibend</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={restoreJob.status} />
|
||||||
|
{restoreJob.error_message !== undefined && restoreJob.error_message !== '' && (
|
||||||
|
<span className="data-table__secondary">{restoreJob.error_message}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="data-table__number">
|
||||||
|
{restoreJob.files_restored.toLocaleString('de-DE')}
|
||||||
|
{/* Uebergangene Objekte machen einen Lauf zum Teilfehler. Sie
|
||||||
|
stehen deshalb neben der Zahl, nicht in einem Detailfenster. */}
|
||||||
|
{restoreJob.files_skipped > 0 && (
|
||||||
|
<span className="data-table__secondary data-table__secondary--warning">
|
||||||
|
{restoreJob.files_skipped} uebergangen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="data-table__number">{formatBytes(restoreJob.bytes_restored)}</td>
|
||||||
|
<td>{formatTimestamp(restoreJob.completed_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TablePage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt das Auditprotokoll. */
|
||||||
|
export function EventsPage(): React.JSX.Element {
|
||||||
|
const loadEvents = useCallback((abortSignal: AbortSignal) => fetchAuditEvents(abortSignal), []);
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<readonly AuditEvent[]>(loadEvents);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TablePage
|
||||||
|
title="Ereignisse"
|
||||||
|
what="Die Ereignisse"
|
||||||
|
emptyMessage="Das Auditprotokoll ist leer."
|
||||||
|
loadState={loadState}
|
||||||
|
loadError={loadError}
|
||||||
|
onRetry={reload}
|
||||||
|
itemCount={data?.length}
|
||||||
|
columns={['Zeitpunkt', 'Handlung', 'Benutzer', 'Gegenstand', 'Ausgang']}
|
||||||
|
>
|
||||||
|
{data?.map((auditEvent) => (
|
||||||
|
<tr key={auditEvent.id}>
|
||||||
|
<td>{formatTimestamp(auditEvent.created_at)}</td>
|
||||||
|
<td className="data-table__code">{auditEvent.action}</td>
|
||||||
|
<td>{auditEvent.actor_username ?? 'System'}</td>
|
||||||
|
<td>{auditEvent.entity_type ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={auditEvent.result} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</TablePage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften einer Tabellenseite. */
|
||||||
|
interface TablePageProperties {
|
||||||
|
/** Ueberschrift der Seite. */
|
||||||
|
readonly title: string;
|
||||||
|
/** Was geladen wird, fuer die Ladeanzeige. */
|
||||||
|
readonly what: string;
|
||||||
|
/** Text bei leerer Liste. */
|
||||||
|
readonly emptyMessage: string;
|
||||||
|
/** Ladezustand. */
|
||||||
|
readonly loadState: 'loading' | 'loaded' | 'failed';
|
||||||
|
/** Aufgetretener Fehler. */
|
||||||
|
readonly loadError: import('../../api/client').ApiError | null;
|
||||||
|
/** Laedt erneut. */
|
||||||
|
readonly onRetry: () => void;
|
||||||
|
/** Zahl der geladenen Eintraege. */
|
||||||
|
readonly itemCount: number | undefined;
|
||||||
|
/** Spaltenueberschriften. */
|
||||||
|
readonly columns: readonly string[];
|
||||||
|
/** Die Tabellenzeilen. */
|
||||||
|
readonly children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt eine Seite mit einer Tabelle. */
|
||||||
|
function TablePage({
|
||||||
|
title,
|
||||||
|
what,
|
||||||
|
emptyMessage,
|
||||||
|
loadState,
|
||||||
|
loadError,
|
||||||
|
onRetry,
|
||||||
|
itemCount,
|
||||||
|
columns,
|
||||||
|
children,
|
||||||
|
}: TablePageProperties): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page__header">
|
||||||
|
<h1 className="page__title">{title}</h1>
|
||||||
|
{itemCount !== undefined && <span className="page__meta">{itemCount} Eintraege</span>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loadState === 'loading' && <LoadingState what={what} />}
|
||||||
|
{loadState === 'failed' && loadError !== null && (
|
||||||
|
<ErrorState error={loadError} onRetry={onRetry} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && itemCount === 0 && <EmptyState message={emptyMessage} />}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && itemCount !== undefined && itemCount > 0 && (
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((columnLabel) => (
|
||||||
|
<th scope="col" key={columnLabel}>
|
||||||
|
{columnLabel}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>{children}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Statusanzeige. */
|
||||||
|
interface StatusBadgeProperties {
|
||||||
|
/** Der darzustellende Zustand. */
|
||||||
|
readonly status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt einen Zustand mit semantischer Farbe.
|
||||||
|
*
|
||||||
|
* Farbe ausschliesslich fuer Status, nie zur Dekoration (PROMPT.md §28). Ein
|
||||||
|
* unbekannter Zustand bekommt deshalb keine Farbe — und nicht etwa gruen.
|
||||||
|
*/
|
||||||
|
function StatusBadge({ status }: StatusBadgeProperties): React.JSX.Element {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
case 'succeeded':
|
||||||
|
case 'success':
|
||||||
|
case 'completed':
|
||||||
|
return <span className="badge badge--healthy">{status}</span>;
|
||||||
|
case 'degraded':
|
||||||
|
case 'partial_failure':
|
||||||
|
case 'paused':
|
||||||
|
return <span className="badge badge--high">{status}</span>;
|
||||||
|
case 'offline':
|
||||||
|
case 'failed':
|
||||||
|
case 'failure':
|
||||||
|
case 'denied':
|
||||||
|
case 'revoked':
|
||||||
|
return <span className="badge badge--critical">{status}</span>;
|
||||||
|
case 'running':
|
||||||
|
case 'queued':
|
||||||
|
return <span className="badge badge--info">{status}</span>;
|
||||||
|
default:
|
||||||
|
return <span className="badge badge--neutral">{status}</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt einen Zeitstempel in deutscher Schreibweise. */
|
||||||
|
function formatTimestamp(isoTimestamp: string | undefined): string {
|
||||||
|
if (isoTimestamp === undefined) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(isoTimestamp).toLocaleString('de-DE', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Faktor zwischen zwei Groesseneinheiten. */
|
||||||
|
const BYTE_UNIT_STEP = 1024;
|
||||||
|
|
||||||
|
/** Schreibt eine Datenmenge lesbar. */
|
||||||
|
function formatBytes(byteCount: number): string {
|
||||||
|
if (byteCount < BYTE_UNIT_STEP) {
|
||||||
|
return `${byteCount} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitNames = ['KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
|
||||||
|
let remainingValue = byteCount;
|
||||||
|
let chosenUnit = unitNames[0];
|
||||||
|
|
||||||
|
for (const unitName of unitNames) {
|
||||||
|
remainingValue /= BYTE_UNIT_STEP;
|
||||||
|
chosenUnit = unitName;
|
||||||
|
|
||||||
|
if (remainingValue < BYTE_UNIT_STEP) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${remainingValue.toLocaleString('de-DE', { maximumFractionDigits: 1 })} ${chosenUnit}`;
|
||||||
|
}
|
||||||
113
apps/web/src/features/inventory/inventoryApi.ts
Normal file
113
apps/web/src/features/inventory/inventoryApi.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Zugriff auf Repositories, Agenten, Wiederherstellungen und Ereignisse.
|
||||||
|
*
|
||||||
|
* Vier kleine Bestandslisten in einer Datei: Jede besteht aus einem Typ und
|
||||||
|
* einem Aufruf. Vier Dateien mit je zwanzig Zeilen brachten hier keine bessere
|
||||||
|
* Ordnung, nur mehr Wege.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { requestApi } from '../../api/client';
|
||||||
|
|
||||||
|
/** Ein Repository der Anlage. */
|
||||||
|
export interface Repository {
|
||||||
|
/** Oeffentlicher Bezeichner. */
|
||||||
|
readonly id: string;
|
||||||
|
/** Sprechende Bezeichnung. */
|
||||||
|
readonly name: string;
|
||||||
|
/** Ablageart. */
|
||||||
|
readonly repository_type: string;
|
||||||
|
/** Pfad oder Adresse. */
|
||||||
|
readonly location: string;
|
||||||
|
/** Betriebszustand. */
|
||||||
|
readonly status: string;
|
||||||
|
/** Meldet den gehaerteten Modus. */
|
||||||
|
readonly hardened: boolean;
|
||||||
|
/** Zuletzt gemessene Durchsetzungsstufe des Loeschschutzes. */
|
||||||
|
readonly enforcement_level?: string;
|
||||||
|
/** Zeitpunkt der Messung in UTC. */
|
||||||
|
readonly enforcement_measured_at?: string;
|
||||||
|
/** Meldet, ob das Repository Sicherungen annimmt. */
|
||||||
|
readonly accepts_backups?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein aufgenommener Agent. */
|
||||||
|
export interface Agent {
|
||||||
|
/** Oeffentlicher Bezeichner. */
|
||||||
|
readonly id: string;
|
||||||
|
/** Sprechender Name. */
|
||||||
|
readonly name: string;
|
||||||
|
/** Betriebssystem. */
|
||||||
|
readonly operating_system?: string;
|
||||||
|
/** Zustand. */
|
||||||
|
readonly status: string;
|
||||||
|
/** Programmversion. */
|
||||||
|
readonly agent_version?: string;
|
||||||
|
/** Letzte Lebendmeldung in UTC. */
|
||||||
|
readonly last_seen_at?: string;
|
||||||
|
/** Aufnahmezeitpunkt in UTC. */
|
||||||
|
readonly enrolled_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Wiederherstellungsauftrag. */
|
||||||
|
export interface RestoreJob {
|
||||||
|
/** Oeffentlicher Bezeichner. */
|
||||||
|
readonly id: string;
|
||||||
|
/** Wiederherzustellendes Backup. */
|
||||||
|
readonly backup_id: string;
|
||||||
|
/** Zielverzeichnis. */
|
||||||
|
readonly target_path: string;
|
||||||
|
/** Zustand. */
|
||||||
|
readonly status: string;
|
||||||
|
/** Meldet das Ueberschreiben vorhandener Daten. */
|
||||||
|
readonly overwrite_existing: boolean;
|
||||||
|
/** Beginn in UTC. */
|
||||||
|
readonly started_at?: string;
|
||||||
|
/** Ende in UTC. */
|
||||||
|
readonly completed_at?: string;
|
||||||
|
/** Zurueckgeschriebene Datenmenge. */
|
||||||
|
readonly bytes_restored: number;
|
||||||
|
/** Zahl zurueckgeschriebener Objekte. */
|
||||||
|
readonly files_restored: number;
|
||||||
|
/** Zahl uebergangener Objekte. */
|
||||||
|
readonly files_skipped: number;
|
||||||
|
/** Verstaendliche Fehlermeldung. */
|
||||||
|
readonly error_message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Eintrag des Auditprotokolls. */
|
||||||
|
export interface AuditEvent {
|
||||||
|
/** Oeffentlicher Bezeichner. */
|
||||||
|
readonly id: string;
|
||||||
|
/** Protokollierte Handlung. */
|
||||||
|
readonly action: string;
|
||||||
|
/** Anmeldename des Handelnden. */
|
||||||
|
readonly actor_username?: string;
|
||||||
|
/** Art des betroffenen Gegenstands. */
|
||||||
|
readonly entity_type?: string;
|
||||||
|
/** Ausgang der Handlung. */
|
||||||
|
readonly result: string;
|
||||||
|
/** Absenderadresse. */
|
||||||
|
readonly ip_address?: string;
|
||||||
|
/** Zeitpunkt in UTC. */
|
||||||
|
readonly created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt alle Repositories. */
|
||||||
|
export async function fetchRepositories(abortSignal?: AbortSignal): Promise<readonly Repository[]> {
|
||||||
|
return requestApi<readonly Repository[]>('/repositories', abortSignal ? { signal: abortSignal } : {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt alle Agenten. */
|
||||||
|
export async function fetchAgents(abortSignal?: AbortSignal): Promise<readonly Agent[]> {
|
||||||
|
return requestApi<readonly Agent[]>('/agents', abortSignal ? { signal: abortSignal } : {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt die Wiederherstellungsauftraege. */
|
||||||
|
export async function fetchRestores(abortSignal?: AbortSignal): Promise<readonly RestoreJob[]> {
|
||||||
|
return requestApi<readonly RestoreJob[]>('/restores?page_size=50', abortSignal ? { signal: abortSignal } : {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt die juengsten Auditereignisse. */
|
||||||
|
export async function fetchAuditEvents(abortSignal?: AbortSignal): Promise<readonly AuditEvent[]> {
|
||||||
|
return requestApi<readonly AuditEvent[]>('/audit-events?page_size=50', abortSignal ? { signal: abortSignal } : {});
|
||||||
|
}
|
||||||
324
apps/web/src/features/jobs/BackupWizard.css
Normal file
324
apps/web/src/features/jobs/BackupWizard.css
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* Darstellung des Backup-Assistenten.
|
||||||
|
*
|
||||||
|
* Semantische Farben stehen ausschliesslich fuer Zustand (PROMPT.md §28):
|
||||||
|
* Rot fuer Fehler, Gelb fuer nicht verfuegbare Faehigkeiten, Blau fuer
|
||||||
|
* Hinweise. Alles Uebrige bleibt neutral - Farbe als Schmuck macht die
|
||||||
|
* Statusfarben bedeutungslos.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.wizard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 14rem 1fr;
|
||||||
|
gap: var(--space-6);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auf schmalen Fenstern liegt die Schrittleiste ueber dem Inhalt statt daneben. */
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.wizard {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__steps {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__steps {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: var(--border-width) solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein noch nicht erreichter Schritt ist nicht anklickbar: Er wuerde eine
|
||||||
|
Prüfung überspringen, die der Assistent gerade führen soll. */
|
||||||
|
.wizard__step:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__step--current {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__step--done {
|
||||||
|
color: var(--color-status-healthy);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__step-number {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: var(--border-width) solid currentColor;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__panel {
|
||||||
|
padding: var(--space-6);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__heading {
|
||||||
|
margin: 0 0 var(--space-6);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__input {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__input--area {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__input--mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__hint {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__preview {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-left: 3px solid var(--color-status-info);
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__source {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__checkbox,
|
||||||
|
.wizard__radio {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__radio {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__radio-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__radio-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__radio-detail {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__radio-warning {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein nicht umgesetzter Schritt wird als solcher gekennzeichnet, statt Werte
|
||||||
|
abzufragen, die niemand auswertet (PROMPT.md §138). */
|
||||||
|
.wizard__unavailable {
|
||||||
|
padding: var(--space-4);
|
||||||
|
border-left: 3px solid var(--color-status-warning);
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__unavailable-badge {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-status-warning);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__unavailable-text {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__notice {
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-left: 3px solid var(--color-status-healthy);
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__problems {
|
||||||
|
margin: var(--space-4) 0 0;
|
||||||
|
padding: var(--space-3) var(--space-3) var(--space-3) var(--space-6);
|
||||||
|
border-left: 3px solid var(--color-status-critical);
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
color: var(--color-status-critical);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__error {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-left: 3px solid var(--color-status-critical);
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
color: var(--color-status-critical);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 12rem 1fr;
|
||||||
|
gap: var(--space-2) var(--space-4);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__summary dt {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__summary dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Was nicht eingerichtet ist, wird in der Uebersicht als solches benannt.
|
||||||
|
Eine leere Zeile liesse offen, ob nichts eingestellt oder nichts moeglich ist. */
|
||||||
|
.wizard__summary-unavailable {
|
||||||
|
color: var(--color-status-warning);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__summary-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__summary-detail {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-8);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: var(--border-width) solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__navigation {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__button {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: var(--border-width) solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__button--primary {
|
||||||
|
border-color: var(--color-text-primary);
|
||||||
|
background: var(--color-text-primary);
|
||||||
|
color: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__button--small {
|
||||||
|
align-self: flex-start;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard__button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
269
apps/web/src/features/jobs/BackupWizard.test.tsx
Normal file
269
apps/web/src/features/jobs/BackupWizard.test.tsx
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* Tests des Backup-Assistenten.
|
||||||
|
*
|
||||||
|
* Geprueft wird der Ablauf durch die Maske: dass unvollstaendige Schritte
|
||||||
|
* aufhalten, dass Rueckwaertsgehen keine Eingaben verliert und dass eine
|
||||||
|
* Fehlermeldung des Servers sichtbar wird statt zu verschwinden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { BackupWizard } from './BackupWizard';
|
||||||
|
import { ApiError } from '../../api/client';
|
||||||
|
import * as jobsApi from './jobsApi';
|
||||||
|
|
||||||
|
/** Ein Sicherungsziel, das Sicherungen annimmt. */
|
||||||
|
const writableRepository: jobsApi.BackupRepository = {
|
||||||
|
id: '11111111-1111-1111-1111-111111111111',
|
||||||
|
name: 'Produktiv',
|
||||||
|
repository_type: 'local',
|
||||||
|
location: '/backup/produktiv',
|
||||||
|
status: 'active',
|
||||||
|
accepts_backups: true,
|
||||||
|
hardened: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ein Sicherungsziel im Nur-Lese-Zustand. */
|
||||||
|
const readOnlyRepository: jobsApi.BackupRepository = {
|
||||||
|
id: '22222222-2222-2222-2222-222222222222',
|
||||||
|
name: 'Archiv',
|
||||||
|
repository_type: 'local',
|
||||||
|
location: '/backup/archiv',
|
||||||
|
status: 'read_only',
|
||||||
|
accepts_backups: false,
|
||||||
|
hardened: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.spyOn(jobsApi, 'listRepositories').mockResolvedValue([writableRepository, readOnlyRepository]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Fuehrt den Assistenten bis zu einem Schritt und fuellt das Noetige aus. */
|
||||||
|
async function fillUntilRepository(user: ReturnType<typeof userEvent.setup>): Promise<void> {
|
||||||
|
await user.type(screen.getByLabelText('Name des Auftrags'), 'Naechtliche Sicherung');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText('Pfad'), '/daten');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
|
||||||
|
// Zeitplan: der Standard "taeglich 02:00" genuegt.
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Backup-Assistent', () => {
|
||||||
|
it('zeigt alle zehn Schritte', async () => {
|
||||||
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
|
// Auf das Laden der Ziele warten: Sonst setzt der Effekt seinen Zustand
|
||||||
|
// erst nach dem Test, und React meldet eine Aktualisierung ausserhalb von act().
|
||||||
|
await waitFor(() => expect(jobsApi.listRepositories).toHaveBeenCalled());
|
||||||
|
|
||||||
|
for (const stepTitle of [
|
||||||
|
'Name',
|
||||||
|
'Quelle',
|
||||||
|
'Zeitplan',
|
||||||
|
'Repository',
|
||||||
|
'Aufbewahrung',
|
||||||
|
'Sicherheit',
|
||||||
|
'Pruefung',
|
||||||
|
'Benachrichtigung',
|
||||||
|
'Uebersicht',
|
||||||
|
'Anlegen',
|
||||||
|
]) {
|
||||||
|
expect(screen.getByRole('button', { name: new RegExp(stepTitle) })).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Die Probleme erscheinen erst beim Versuch weiterzugehen. Sie von Anfang an
|
||||||
|
// zu zeigen hiesse, ein leeres Formular als fehlerhaft zu markieren.
|
||||||
|
it('haelt bei einem leeren Namen auf und erklaert warum', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('braucht einen Namen');
|
||||||
|
expect(screen.getByText(/Schritt 1 von 10/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('haelt ohne Quelle auf', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText('Name des Auftrags'), 'Test');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
|
||||||
|
expect(screen.getByText(/Schritt 2 von 10/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('mindestens eine Quelle');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Der Entwurf lebt in einem Zustand, nicht in den Eingabefeldern. Sonst waere
|
||||||
|
// jeder Blick zurueck ein Datenverlust.
|
||||||
|
it('bewahrt die Eingaben beim Zurueckgehen', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText('Name des Auftrags'), 'Naechtliche Sicherung');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Zurueck' }));
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Name des Auftrags')).toHaveValue('Naechtliche Sicherung');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt eine Vorschau des Zeitplans', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText('Name des Auftrags'), 'Test');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
await user.type(screen.getByLabelText('Pfad'), '/daten');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
|
||||||
|
expect(screen.getByText(/Ergibt: taeglich um 02:00 Uhr/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ein Ziel, das keine Sicherungen annimmt, wird benannt statt stillschweigend
|
||||||
|
// weggelassen: Sonst suchte der Anwender ein Repository, das er nicht findet.
|
||||||
|
it('zeigt gesperrte Repositories, laesst sie aber nicht waehlen', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
|
await fillUntilRepository(user);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Produktiv')).toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(screen.getByText('Archiv')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/nimmt derzeit keine Sicherungen an/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const radioButtons = screen.getAllByRole('radio');
|
||||||
|
expect(radioButtons[0]).toBeEnabled();
|
||||||
|
expect(radioButtons[1]).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Eine Maske, die Werte sammelt, die niemand auswertet, ist ein
|
||||||
|
// vorgetaeuschtes Funktionsversprechen (PROMPT.md §138).
|
||||||
|
it('kennzeichnet die nicht umgesetzten Schritte, statt Eingaben zu sammeln', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
|
await fillUntilRepository(user);
|
||||||
|
await waitFor(() => expect(screen.getByText('Produktiv')).toBeInTheDocument());
|
||||||
|
|
||||||
|
await user.click(screen.getAllByRole('radio')[0] as HTMLElement);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
|
||||||
|
// Schritt 5: Aufbewahrung.
|
||||||
|
expect(screen.getByText('Noch nicht verfuegbar')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/nicht automatisch geloescht/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legt den Auftrag an und meldet ihn nach oben', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const createdJob = { id: 'abc', name: 'Naechtliche Sicherung' } as jobsApi.BackupJob;
|
||||||
|
const createSpy = vi.spyOn(jobsApi, 'createJob').mockResolvedValue(createdJob);
|
||||||
|
const handleCreated = vi.fn();
|
||||||
|
|
||||||
|
render(<BackupWizard onJobCreated={handleCreated} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
|
await fillUntilRepository(user);
|
||||||
|
await waitFor(() => expect(screen.getByText('Produktiv')).toBeInTheDocument());
|
||||||
|
await user.click(screen.getAllByRole('radio')[0] as HTMLElement);
|
||||||
|
|
||||||
|
// Von Repository bis Anlegen sind es sechs Schritte.
|
||||||
|
for (let stepCounter = 0; stepCounter < 6; stepCounter++) {
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(screen.getByText(/Schritt 10 von 10/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Auftrag anlegen' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(handleCreated).toHaveBeenCalledWith(createdJob));
|
||||||
|
|
||||||
|
const sentRequest = createSpy.mock.calls[0]?.[0];
|
||||||
|
expect(sentRequest?.name).toBe('Naechtliche Sicherung');
|
||||||
|
expect(sentRequest?.sources[0]?.id).toBe('/daten');
|
||||||
|
expect(sentRequest?.repository_id).toBe(writableRepository.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Die Meldung des Servers wird wortgetreu gezeigt: Sie nennt den
|
||||||
|
// eigentlichen Grund, den die Oberflaeche nicht kennen kann.
|
||||||
|
it('zeigt eine Fehlermeldung des Servers', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
vi.spyOn(jobsApi, 'createJob').mockRejectedValue(
|
||||||
|
new ApiError({
|
||||||
|
code: 'VALIDATION_FAILED',
|
||||||
|
message: 'der zeitplan laeuft nur alle 1 Tage und kann den geforderten wiederherstellungspunkt nicht einhalten',
|
||||||
|
statusCode: 422,
|
||||||
|
requestId: 'req-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
|
await fillUntilRepository(user);
|
||||||
|
await waitFor(() => expect(screen.getByText('Produktiv')).toBeInTheDocument());
|
||||||
|
await user.click(screen.getAllByRole('radio')[0] as HTMLElement);
|
||||||
|
|
||||||
|
for (let stepCounter = 0; stepCounter < 6; stepCounter++) {
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Auftrag anlegen' }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('wiederherstellungspunkt'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Der Assistent bleibt stehen, damit der Anwender zurueckgehen und den
|
||||||
|
// Zeitplan aendern kann.
|
||||||
|
expect(screen.getByText(/Schritt 10 von 10/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet einen Fehler beim Laden der Ziele', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
vi.spyOn(jobsApi, 'listRepositories').mockRejectedValue(
|
||||||
|
new ApiError({
|
||||||
|
code: 'PERMISSION_DENIED',
|
||||||
|
message: 'Die Berechtigung repositories.read fehlt.',
|
||||||
|
statusCode: 403,
|
||||||
|
requestId: 'req-2',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
|
await fillUntilRepository(user);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText('Die Berechtigung repositories.read fehlt.')).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erlaubt den Sprung zurueck ueber die Schrittleiste, nicht nach vorn', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<BackupWizard onJobCreated={vi.fn()} onCancel={vi.fn()} />);
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText('Name des Auftrags'), 'Test');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Weiter' }));
|
||||||
|
|
||||||
|
// Ein noch nicht erreichter Schritt ist nicht anklickbar: Er wuerde eine
|
||||||
|
// Pruefung ueberspringen, die der Assistent gerade fuehren soll.
|
||||||
|
expect(screen.getByRole('button', { name: /Repository/ })).toBeDisabled();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /1\s*Name/ }));
|
||||||
|
|
||||||
|
expect(screen.getByText(/Schritt 1 von 10/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
720
apps/web/src/features/jobs/BackupWizard.tsx
Normal file
720
apps/web/src/features/jobs/BackupWizard.tsx
Normal file
@ -0,0 +1,720 @@
|
|||||||
|
/**
|
||||||
|
* Backup-Wizard in zehn Schritten (SYNCOVA_IMPLEMENTATION_PLAN.md §10).
|
||||||
|
*
|
||||||
|
* Der Assistent fuehrt von Name bis Anlegen. Die Pruefung geschieht je Schritt,
|
||||||
|
* damit ein Fehler dort auffaellt, wo er entsteht - nicht erst nach dem letzten
|
||||||
|
* Schritt. Rueckwaerts geht es ohne Datenverlust: Der Entwurf lebt in einem
|
||||||
|
* Zustand, nicht in den Eingabefeldern.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ApiError } from '../../api/client';
|
||||||
|
import { createJob, listRepositories } from './jobsApi';
|
||||||
|
import type { BackupJob, BackupRepository, SourceType } from './jobsApi';
|
||||||
|
import {
|
||||||
|
buildCreateRequest,
|
||||||
|
createEmptyDraft,
|
||||||
|
describeDraftSchedule,
|
||||||
|
splitPatternList,
|
||||||
|
STEP_TITLES,
|
||||||
|
UNAVAILABLE_STEPS,
|
||||||
|
validateStep,
|
||||||
|
WEEKDAY_NAMES,
|
||||||
|
WIZARD_STEPS,
|
||||||
|
} from './wizardModel';
|
||||||
|
import type { DraftSource, JobDraft, WizardStep } from './wizardModel';
|
||||||
|
import './BackupWizard.css';
|
||||||
|
|
||||||
|
/** Eigenschaften des Assistenten. */
|
||||||
|
export interface BackupWizardProps {
|
||||||
|
/** Wird nach dem Anlegen mit dem erzeugten Auftrag aufgerufen. */
|
||||||
|
onJobCreated: (createdJob: BackupJob) => void;
|
||||||
|
/** Bricht den Assistenten ab. */
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quellarten, die der Executor derzeit sichern kann. */
|
||||||
|
const SUPPORTED_SOURCE_TYPES: readonly SourceType[] = ['filesystem'];
|
||||||
|
|
||||||
|
/** Beschriftungen der Quellarten. */
|
||||||
|
const SOURCE_TYPE_LABELS: Record<SourceType, string> = {
|
||||||
|
filesystem: 'Verzeichnis',
|
||||||
|
proxmox_vm: 'Proxmox-VM',
|
||||||
|
proxmox_container: 'Proxmox-Container',
|
||||||
|
windows_system: 'Windows-System',
|
||||||
|
linux_system: 'Linux-System',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Zeigt den Assistenten zum Anlegen eines Sicherungsauftrags. */
|
||||||
|
export function BackupWizard({ onJobCreated, onCancel }: BackupWizardProps): React.JSX.Element {
|
||||||
|
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||||
|
const [jobDraft, setJobDraft] = useState<JobDraft>(createEmptyDraft);
|
||||||
|
const [repositories, setRepositories] = useState<BackupRepository[]>([]);
|
||||||
|
const [repositoryError, setRepositoryError] = useState<string | null>(null);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [showProblems, setShowProblems] = useState(false);
|
||||||
|
|
||||||
|
const currentStep = WIZARD_STEPS[currentStepIndex] as WizardStep;
|
||||||
|
const stepValidation = validateStep(currentStep, jobDraft);
|
||||||
|
|
||||||
|
// Die Ziele werden einmal geladen. Ein Abbruchsignal verhindert, dass eine
|
||||||
|
// Antwort nach dem Schliessen des Assistenten noch Zustand setzt.
|
||||||
|
useEffect(() => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
listRepositories(abortController.signal)
|
||||||
|
.then(setRepositories)
|
||||||
|
.catch((caughtError: unknown) => {
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRepositoryError(describeError(caughtError, 'Die Sicherungsziele konnten nicht geladen werden.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Aendert ein Feld des Entwurfs. */
|
||||||
|
function updateDraft(changedFields: Partial<JobDraft>): void {
|
||||||
|
setJobDraft((previousDraft) => ({ ...previousDraft, ...changedFields }));
|
||||||
|
setShowProblems(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Geht einen Schritt vor, sofern der aktuelle vollstaendig ist. */
|
||||||
|
function goToNextStep(): void {
|
||||||
|
if (!stepValidation.isComplete) {
|
||||||
|
// Die Probleme erscheinen erst beim Versuch weiterzugehen. Sie von Anfang
|
||||||
|
// an zu zeigen hiesse, ein leeres Formular als fehlerhaft zu markieren.
|
||||||
|
setShowProblems(true);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowProblems(false);
|
||||||
|
setCurrentStepIndex((previousIndex) => Math.min(previousIndex + 1, WIZARD_STEPS.length - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Geht einen Schritt zurueck. */
|
||||||
|
function goToPreviousStep(): void {
|
||||||
|
setShowProblems(false);
|
||||||
|
setCurrentStepIndex((previousIndex) => Math.max(previousIndex - 1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Springt zu einem bereits erreichten Schritt. */
|
||||||
|
function jumpToStep(targetIndex: number): void {
|
||||||
|
if (targetIndex > currentStepIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowProblems(false);
|
||||||
|
setCurrentStepIndex(targetIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt den Auftrag an. */
|
||||||
|
async function handleCreate(): Promise<void> {
|
||||||
|
setSubmitError(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const createdJob = await createJob(buildCreateRequest(jobDraft));
|
||||||
|
onJobCreated(createdJob);
|
||||||
|
} catch (caughtError: unknown) {
|
||||||
|
// Die Meldung des Servers wird wortgetreu gezeigt: Sie nennt den
|
||||||
|
// eigentlichen Grund, etwa einen Zeitplan, der den geforderten
|
||||||
|
// Wiederherstellungspunkt nicht einhalten kann.
|
||||||
|
setSubmitError(describeError(caughtError, 'Der Auftrag konnte nicht angelegt werden.'));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="wizard" aria-label="Backup-Assistent">
|
||||||
|
<ol className="wizard__steps">
|
||||||
|
{WIZARD_STEPS.map((wizardStep, stepIndex) => (
|
||||||
|
<li key={wizardStep}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={buildStepClassName(stepIndex, currentStepIndex)}
|
||||||
|
onClick={() => jumpToStep(stepIndex)}
|
||||||
|
disabled={stepIndex > currentStepIndex}
|
||||||
|
aria-current={stepIndex === currentStepIndex ? 'step' : undefined}
|
||||||
|
>
|
||||||
|
<span className="wizard__step-number">{stepIndex + 1}</span>
|
||||||
|
<span className="wizard__step-title">{STEP_TITLES[wizardStep]}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="wizard__panel">
|
||||||
|
<h2 className="wizard__heading">
|
||||||
|
Schritt {currentStepIndex + 1} von {WIZARD_STEPS.length}: {STEP_TITLES[currentStep]}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{renderStepContent()}
|
||||||
|
|
||||||
|
{showProblems && stepValidation.problems.length > 0 && (
|
||||||
|
<ul className="wizard__problems" role="alert">
|
||||||
|
{stepValidation.problems.map((problemText) => (
|
||||||
|
<li key={problemText}>{problemText}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="wizard__actions">
|
||||||
|
<button type="button" className="wizard__button" onClick={onCancel}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="wizard__navigation">
|
||||||
|
{currentStepIndex > 0 && (
|
||||||
|
<button type="button" className="wizard__button" onClick={goToPreviousStep}>
|
||||||
|
Zurueck
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep !== 'create' ? (
|
||||||
|
<button type="button" className="wizard__button wizard__button--primary" onClick={goToNextStep}>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="wizard__button wizard__button--primary"
|
||||||
|
onClick={() => void handleCreate()}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Wird angelegt …' : 'Auftrag anlegen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Waehlt den Inhalt des aktuellen Schrittes. */
|
||||||
|
function renderStepContent(): React.JSX.Element {
|
||||||
|
const unavailableExplanation = UNAVAILABLE_STEPS[currentStep];
|
||||||
|
|
||||||
|
if (unavailableExplanation !== undefined) {
|
||||||
|
return renderUnavailableStep(unavailableExplanation);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (currentStep) {
|
||||||
|
case 'name':
|
||||||
|
return renderNameStep();
|
||||||
|
case 'source':
|
||||||
|
return renderSourceStep();
|
||||||
|
case 'schedule':
|
||||||
|
return renderScheduleStep();
|
||||||
|
case 'repository':
|
||||||
|
return renderRepositoryStep();
|
||||||
|
case 'security':
|
||||||
|
return renderSecurityStep();
|
||||||
|
case 'review':
|
||||||
|
return renderReviewStep();
|
||||||
|
case 'create':
|
||||||
|
return renderCreateStep();
|
||||||
|
default:
|
||||||
|
return <p>Unbekannter Schritt.</p>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt einen Schritt ohne Umsetzung im Backend.
|
||||||
|
*
|
||||||
|
* Es werden keine Eingaben abgefragt. Eine Maske, die Werte sammelt, die
|
||||||
|
* niemand auswertet, ist ein vorgetaeuschtes Funktionsversprechen
|
||||||
|
* (PROMPT.md §138) - stattdessen steht hier, was ohne diese Einstellung
|
||||||
|
* tatsaechlich geschieht.
|
||||||
|
*/
|
||||||
|
function renderUnavailableStep(explanationText: string): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="wizard__unavailable">
|
||||||
|
<p className="wizard__unavailable-badge">Noch nicht verfuegbar</p>
|
||||||
|
<p className="wizard__unavailable-text">{explanationText}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schritt 1: Name und Beschreibung. */
|
||||||
|
function renderNameStep(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="wizard__fields">
|
||||||
|
<label className="wizard__field">
|
||||||
|
<span className="wizard__label">Name des Auftrags</span>
|
||||||
|
<input
|
||||||
|
className="wizard__input"
|
||||||
|
type="text"
|
||||||
|
value={jobDraft.name}
|
||||||
|
onChange={(changeEvent) => updateDraft({ name: changeEvent.target.value })}
|
||||||
|
placeholder="Naechtliche Sicherung Dateiserver"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="wizard__field">
|
||||||
|
<span className="wizard__label">Beschreibung (optional)</span>
|
||||||
|
<textarea
|
||||||
|
className="wizard__input wizard__input--area"
|
||||||
|
value={jobDraft.description}
|
||||||
|
onChange={(changeEvent) => updateDraft({ description: changeEvent.target.value })}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schritt 2: Quellen. */
|
||||||
|
function renderSourceStep(): React.JSX.Element {
|
||||||
|
/** Aendert eine einzelne Quelle. */
|
||||||
|
function updateSource(sourceIndex: number, changedFields: Partial<DraftSource>): void {
|
||||||
|
updateDraft({
|
||||||
|
sources: jobDraft.sources.map((existingSource, currentIndex) =>
|
||||||
|
currentIndex === sourceIndex ? { ...existingSource, ...changedFields } : existingSource,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wizard__fields">
|
||||||
|
{jobDraft.sources.map((draftSource, sourceIndex) => (
|
||||||
|
<fieldset key={sourceIndex} className="wizard__source">
|
||||||
|
<legend className="wizard__label">Quelle {sourceIndex + 1}</legend>
|
||||||
|
|
||||||
|
<label className="wizard__field">
|
||||||
|
<span className="wizard__label">Art</span>
|
||||||
|
<select
|
||||||
|
className="wizard__input"
|
||||||
|
value={draftSource.sourceType}
|
||||||
|
onChange={(changeEvent) =>
|
||||||
|
updateSource(sourceIndex, { sourceType: changeEvent.target.value as SourceType })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(Object.keys(SOURCE_TYPE_LABELS) as SourceType[]).map((sourceType) => (
|
||||||
|
<option
|
||||||
|
key={sourceType}
|
||||||
|
value={sourceType}
|
||||||
|
disabled={!SUPPORTED_SOURCE_TYPES.includes(sourceType)}
|
||||||
|
>
|
||||||
|
{SOURCE_TYPE_LABELS[sourceType]}
|
||||||
|
{!SUPPORTED_SOURCE_TYPES.includes(sourceType) ? ' (noch nicht verfuegbar)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="wizard__field">
|
||||||
|
<span className="wizard__label">Pfad</span>
|
||||||
|
<input
|
||||||
|
className="wizard__input"
|
||||||
|
type="text"
|
||||||
|
value={draftSource.sourceIdentifier}
|
||||||
|
onChange={(changeEvent) =>
|
||||||
|
updateSource(sourceIndex, { sourceIdentifier: changeEvent.target.value })
|
||||||
|
}
|
||||||
|
placeholder="/daten/kunden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="wizard__field">
|
||||||
|
<span className="wizard__label">Bezeichnung (optional)</span>
|
||||||
|
<input
|
||||||
|
className="wizard__input"
|
||||||
|
type="text"
|
||||||
|
value={draftSource.sourceName}
|
||||||
|
onChange={(changeEvent) => updateSource(sourceIndex, { sourceName: changeEvent.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="wizard__field">
|
||||||
|
<span className="wizard__label">Auszuschliessen (kommasepariert, optional)</span>
|
||||||
|
<input
|
||||||
|
className="wizard__input"
|
||||||
|
type="text"
|
||||||
|
value={draftSource.excludePatterns}
|
||||||
|
onChange={(changeEvent) =>
|
||||||
|
updateSource(sourceIndex, { excludePatterns: changeEvent.target.value })
|
||||||
|
}
|
||||||
|
placeholder="*.tmp, cache"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{jobDraft.sources.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="wizard__button wizard__button--small"
|
||||||
|
onClick={() =>
|
||||||
|
updateDraft({
|
||||||
|
sources: jobDraft.sources.filter((_, currentIndex) => currentIndex !== sourceIndex),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Quelle entfernen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="wizard__button wizard__button--small"
|
||||||
|
onClick={() =>
|
||||||
|
updateDraft({
|
||||||
|
sources: [
|
||||||
|
...jobDraft.sources,
|
||||||
|
{ sourceType: 'filesystem', sourceIdentifier: '', sourceName: '', excludePatterns: '' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Weitere Quelle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schritt 3: Zeitplan. */
|
||||||
|
function renderScheduleStep(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="wizard__fields">
|
||||||
|
<label className="wizard__field">
|
||||||
|
<span className="wizard__label">Art des Zeitplans</span>
|
||||||
|
<select
|
||||||
|
className="wizard__input"
|
||||||
|
value={jobDraft.scheduleType}
|
||||||
|
onChange={(changeEvent) =>
|
||||||
|
updateDraft({ scheduleType: changeEvent.target.value as JobDraft['scheduleType'] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="manual">Nur auf Anforderung</option>
|
||||||
|
<option value="interval">In festen Abstaenden</option>
|
||||||
|
<option value="hourly">Stuendlich</option>
|
||||||
|
<option value="daily">Taeglich</option>
|
||||||
|
<option value="weekly">Woechentlich</option>
|
||||||
|
<option value="monthly">Monatlich</option>
|
||||||
|
<option value="cron">Cron-Ausdruck</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{jobDraft.scheduleType === 'interval' && (
|
||||||
|
<label className="wizard__field">
|
||||||
|
<span className="wizard__label">Abstand in Stunden</span>
|
||||||
|
<input
|
||||||
|
className="wizard__input"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={jobDraft.scheduleIntervalHours}
|
||||||
|
onChange={(changeEvent) =>
|
||||||
|
updateDraft({ scheduleIntervalHours: Number(changeEvent.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jobDraft.scheduleType !== 'manual' && jobDraft.scheduleType !== 'interval' &&
|
||||||
|
jobDraft.scheduleType !== 'cron' && (
|
||||||
|
<label className="wizard__field">
|
||||||
|
<span className="wizard__label">
|
||||||
|
{jobDraft.scheduleType === 'hourly' ? 'Minute (aus HH:MM)' : 'Uhrzeit'}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="wizard__input"
|
||||||
|
type="time"
|
||||||
|
value={jobDraft.scheduleTime}
|
||||||
|
onChange={(changeEvent) => updateDraft({ scheduleTime: changeEvent.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jobDraft.scheduleType === 'weekly' && (
|
||||||
|
<fieldset className="wizard__field">
|
||||||
|
<legend className="wizard__label">Wochentage</legend>
|
||||||
|
<div className="wizard__checkbox-row">
|
||||||
|
{WEEKDAY_NAMES.map((weekdayName, weekdayNumber) => (
|
||||||
|
<label key={weekdayName} className="wizard__checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={jobDraft.scheduleWeekdays.includes(weekdayNumber)}
|
||||||
|
onChange={(changeEvent) =>
|
||||||
|
updateDraft({
|
||||||
|
scheduleWeekdays: changeEvent.target.checked
|
||||||
|
? [...jobDraft.scheduleWeekdays, weekdayNumber]
|
||||||
|
: jobDraft.scheduleWeekdays.filter((existingDay) => existingDay !== weekdayNumber),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{weekdayName}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jobDraft.scheduleType === 'monthly' && (
|
||||||
|
<fieldset className="wizard__field">
|
||||||
|
<legend className="wizard__label">Tage des Monats</legend>
|
||||||
|
<div className="wizard__checkbox-row">
|
||||||
|
{[1, 15, -1].map((monthDay) => (
|
||||||
|
<label key={monthDay} className="wizard__checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={jobDraft.scheduleMonthDays.includes(monthDay)}
|
||||||
|
onChange={(changeEvent) =>
|
||||||
|
updateDraft({
|
||||||
|
scheduleMonthDays: changeEvent.target.checked
|
||||||
|
? [...jobDraft.scheduleMonthDays, monthDay]
|
||||||
|
: jobDraft.scheduleMonthDays.filter((existingDay) => existingDay !== monthDay),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{monthDay === -1 ? 'Letzter Tag' : `${monthDay}.`}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Der Monatsletzte ist kein Luxus: Wer "am 31." waehlt, bekommt in
|
||||||
|
vier Monaten des Jahres keine Sicherung. */}
|
||||||
|
<p className="wizard__hint">
|
||||||
|
Der letzte Tag des Monats faellt nie aus - anders als etwa der 31.
|
||||||
|
</p>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jobDraft.scheduleType === 'cron' && (
|
||||||
|
<label className="wizard__field">
|
||||||
|
<span className="wizard__label">Cron-Ausdruck</span>
|
||||||
|
<input
|
||||||
|
className="wizard__input wizard__input--mono"
|
||||||
|
type="text"
|
||||||
|
value={jobDraft.scheduleCronExpression}
|
||||||
|
onChange={(changeEvent) => updateDraft({ scheduleCronExpression: changeEvent.target.value })}
|
||||||
|
placeholder="0 2 * * *"
|
||||||
|
/>
|
||||||
|
<span className="wizard__hint">Minute Stunde Tag Monat Wochentag</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jobDraft.scheduleType !== 'manual' && jobDraft.scheduleType !== 'interval' && (
|
||||||
|
<label className="wizard__field">
|
||||||
|
<span className="wizard__label">Zeitzone</span>
|
||||||
|
<input
|
||||||
|
className="wizard__input"
|
||||||
|
type="text"
|
||||||
|
value={jobDraft.scheduleTimeZone}
|
||||||
|
onChange={(changeEvent) => updateDraft({ scheduleTimeZone: changeEvent.target.value })}
|
||||||
|
/>
|
||||||
|
{/* Ohne Zeitzone gilt auf dem Server UTC - derselbe Auftrag liefe
|
||||||
|
dann je nach Standort zu einer anderen Uhrzeit. */}
|
||||||
|
<span className="wizard__hint">
|
||||||
|
Ohne Angabe rechnet der Server in UTC.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="wizard__preview">Ergibt: {describeDraftSchedule(jobDraft)}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schritt 4: Repository. */
|
||||||
|
function renderRepositoryStep(): React.JSX.Element {
|
||||||
|
if (repositoryError !== null) {
|
||||||
|
return <p className="wizard__error">{repositoryError}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repositories.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="wizard__hint">
|
||||||
|
Es ist kein Sicherungsziel eingerichtet. Ein Repository entsteht auf einem Datentraeger und
|
||||||
|
wird mit <code>syncova-repo create --path <pfad> --name <name></code> angelegt.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wizard__fields">
|
||||||
|
{repositories.map((repositoryEntry) => (
|
||||||
|
<label key={repositoryEntry.id} className="wizard__radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="repository"
|
||||||
|
value={repositoryEntry.id}
|
||||||
|
checked={jobDraft.repositoryId === repositoryEntry.id}
|
||||||
|
disabled={!repositoryEntry.accepts_backups}
|
||||||
|
onChange={() => updateDraft({ repositoryId: repositoryEntry.id })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="wizard__radio-body">
|
||||||
|
<span className="wizard__radio-title">{repositoryEntry.name}</span>
|
||||||
|
<span className="wizard__radio-detail">{repositoryEntry.location}</span>
|
||||||
|
|
||||||
|
{/* Ein Ziel, das keine Sicherungen annimmt, wird benannt statt
|
||||||
|
stillschweigend weggelassen: Sonst suchte der Anwender ein
|
||||||
|
Repository, das er sieht und nicht waehlen kann. */}
|
||||||
|
{!repositoryEntry.accepts_backups && (
|
||||||
|
<span className="wizard__radio-warning">
|
||||||
|
Zustand {repositoryEntry.status} - nimmt derzeit keine Sicherungen an
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schritt 6: Sicherheit. */
|
||||||
|
function renderSecurityStep(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="wizard__fields">
|
||||||
|
{/* Verschluesselung ist keine Wahl: Der Executor verweigert den Dienst
|
||||||
|
ohne Schluesselmaterial. Eine Schaltflaeche zum Abschalten waere
|
||||||
|
eine Einstellung, die es nicht gibt. */}
|
||||||
|
<div className="wizard__notice">
|
||||||
|
<strong>Verschluesselung ist immer aktiv.</strong> Die Daten werden mit AES-256-GCM
|
||||||
|
verschluesselt, bevor sie das System verlassen. Der Schluessel gehoert zum Repository und
|
||||||
|
wird vom Dienst verwaltet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="wizard__field">
|
||||||
|
<span className="wizard__label">Dringlichkeit</span>
|
||||||
|
<select
|
||||||
|
className="wizard__input"
|
||||||
|
value={jobDraft.priority}
|
||||||
|
onChange={(changeEvent) =>
|
||||||
|
updateDraft({ priority: changeEvent.target.value as JobDraft['priority'] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="critical">Kritisch</option>
|
||||||
|
<option value="high">Hoch</option>
|
||||||
|
<option value="normal">Normal</option>
|
||||||
|
<option value="low">Niedrig</option>
|
||||||
|
</select>
|
||||||
|
<span className="wizard__hint">
|
||||||
|
Entscheidet die Reihenfolge, wenn mehrere Auftraege gleichzeitig faellig sind.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="wizard__field">
|
||||||
|
<span className="wizard__label">Bandbreitengrenze (optional)</span>
|
||||||
|
<input
|
||||||
|
className="wizard__input"
|
||||||
|
type="text"
|
||||||
|
value={jobDraft.bandwidthLimit}
|
||||||
|
onChange={(changeEvent) => updateDraft({ bandwidthLimit: changeEvent.target.value })}
|
||||||
|
placeholder="50MB"
|
||||||
|
/>
|
||||||
|
<span className="wizard__hint">
|
||||||
|
Begrenzt das Lesen von der Quelle. 100Mbit ist ein Achtel von 100MB.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schritt 9: Uebersicht. */
|
||||||
|
function renderReviewStep(): React.JSX.Element {
|
||||||
|
const selectedRepository = repositories.find(
|
||||||
|
(repositoryEntry) => repositoryEntry.id === jobDraft.repositoryId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dl className="wizard__summary">
|
||||||
|
<dt>Name</dt>
|
||||||
|
<dd>{jobDraft.name}</dd>
|
||||||
|
|
||||||
|
{jobDraft.description.trim() !== '' && (
|
||||||
|
<>
|
||||||
|
<dt>Beschreibung</dt>
|
||||||
|
<dd>{jobDraft.description}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<dt>Quellen</dt>
|
||||||
|
<dd>
|
||||||
|
<ul className="wizard__summary-list">
|
||||||
|
{jobDraft.sources
|
||||||
|
.filter((draftSource) => draftSource.sourceIdentifier.trim() !== '')
|
||||||
|
.map((draftSource, sourceIndex) => (
|
||||||
|
<li key={sourceIndex}>
|
||||||
|
{draftSource.sourceIdentifier}
|
||||||
|
{splitPatternList(draftSource.excludePatterns).length > 0 && (
|
||||||
|
<span className="wizard__summary-detail">
|
||||||
|
{' '}
|
||||||
|
ohne {splitPatternList(draftSource.excludePatterns).join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Zeitplan</dt>
|
||||||
|
<dd>{describeDraftSchedule(jobDraft)}</dd>
|
||||||
|
|
||||||
|
<dt>Repository</dt>
|
||||||
|
<dd>{selectedRepository?.name ?? jobDraft.repositoryId}</dd>
|
||||||
|
|
||||||
|
<dt>Aufbewahrung</dt>
|
||||||
|
<dd className="wizard__summary-unavailable">unbegrenzt (Regeln noch nicht umgesetzt)</dd>
|
||||||
|
|
||||||
|
<dt>Sicherheit</dt>
|
||||||
|
<dd>
|
||||||
|
verschluesselt, Dringlichkeit {jobDraft.priority}
|
||||||
|
{jobDraft.bandwidthLimit.trim() !== '' && `, hoechstens ${jobDraft.bandwidthLimit}`}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt>Pruefung</dt>
|
||||||
|
<dd className="wizard__summary-unavailable">keine automatische Pruefung</dd>
|
||||||
|
|
||||||
|
<dt>Benachrichtigung</dt>
|
||||||
|
<dd className="wizard__summary-unavailable">keine</dd>
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schritt 10: Anlegen. */
|
||||||
|
function renderCreateStep(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="wizard__fields">
|
||||||
|
<p>
|
||||||
|
Der Auftrag wird angelegt und laeuft ab sofort nach Zeitplan. Der erste Lauf ist eine
|
||||||
|
vollstaendige Sicherung; jeder weitere liest nur, was sich geaendert hat.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{submitError !== null && (
|
||||||
|
<p className="wizard__error" role="alert">
|
||||||
|
{submitError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bildet die CSS-Klasse eines Schrittes in der Kopfzeile. */
|
||||||
|
function buildStepClassName(stepIndex: number, currentStepIndex: number): string {
|
||||||
|
if (stepIndex === currentStepIndex) {
|
||||||
|
return 'wizard__step wizard__step--current';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stepIndex < currentStepIndex) {
|
||||||
|
return 'wizard__step wizard__step--done';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'wizard__step';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wandelt einen Fehler in eine verstaendliche Meldung (PROMPT.md §124). */
|
||||||
|
function describeError(caughtError: unknown, fallbackMessage: string): string {
|
||||||
|
if (caughtError instanceof ApiError) {
|
||||||
|
return caughtError.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackMessage;
|
||||||
|
}
|
||||||
108
apps/web/src/features/jobs/JobsPanel.css
Normal file
108
apps/web/src/features/jobs/JobsPanel.css
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Darstellung der Auftragsuebersicht.
|
||||||
|
*
|
||||||
|
* Semantische Farben ausschliesslich fuer Status (PROMPT.md §28). Ein
|
||||||
|
* Teilfehler bekommt Gelb, kein Gruen: Er ist kein Erfolg.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.jobs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__heading {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__create {
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border: var(--border-width) solid var(--color-text-primary);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--color-text-primary);
|
||||||
|
color: var(--color-surface-raised);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__error {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border-left: 3px solid var(--color-status-critical);
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
color: var(--color-status-critical);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__table th,
|
||||||
|
.jobs__table td {
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-bottom: var(--border-width) solid var(--color-border);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__table th {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__table td {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__badge {
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__badge--paused {
|
||||||
|
background: var(--color-surface-page);
|
||||||
|
color: var(--color-status-warning);
|
||||||
|
border: var(--border-width) solid var(--color-status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__outcome {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__outcome--healthy {
|
||||||
|
color: var(--color-status-healthy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein Teilfehler ist kein Erfolg und bekommt deshalb Gelb, nicht Gruen. */
|
||||||
|
.jobs__outcome--warning {
|
||||||
|
color: var(--color-status-warning);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__outcome--critical {
|
||||||
|
color: var(--color-status-critical);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobs__outcome--neutral {
|
||||||
|
color: var(--color-status-neutral);
|
||||||
|
}
|
||||||
173
apps/web/src/features/jobs/JobsPanel.tsx
Normal file
173
apps/web/src/features/jobs/JobsPanel.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Uebersicht der Sicherungsauftraege mit Zugang zum Assistenten.
|
||||||
|
*
|
||||||
|
* Die Liste zeigt, was tatsaechlich hinterlegt ist. Kennzahlen, die es noch
|
||||||
|
* nicht gibt - Erfolgsquoten, Speicherbedarf, Trends -, erscheinen nicht:
|
||||||
|
* Erfundene Zahlen in einer Uebersicht sind schlimmer als eine leere Spalte
|
||||||
|
* (PROMPT.md §138).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ApiError } from '../../api/client';
|
||||||
|
import { BackupWizard } from './BackupWizard';
|
||||||
|
import { listJobs } from './jobsApi';
|
||||||
|
import type { BackupJob } from './jobsApi';
|
||||||
|
import './JobsPanel.css';
|
||||||
|
|
||||||
|
/** Zeigt die Auftraege und den Assistenten zum Anlegen. */
|
||||||
|
export function JobsPanel(): React.JSX.Element {
|
||||||
|
const [jobs, setJobs] = useState<BackupJob[]>([]);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||||
|
|
||||||
|
// reloadCounter erzwingt einen erneuten Lauf des Effekts, nachdem ein Auftrag
|
||||||
|
// angelegt wurde. Die Ladelogik bleibt damit an einer einzigen Stelle.
|
||||||
|
const [reloadCounter, setReloadCounter] = useState(0);
|
||||||
|
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
setReloadCounter((previousCounter) => previousCounter + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Der Controller bricht die Anfrage ab, wenn die Komponente verschwindet.
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
async function loadJobs(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const loadedJobs = await listJobs(abortController.signal);
|
||||||
|
|
||||||
|
setJobs(loadedJobs);
|
||||||
|
setLoadError(null);
|
||||||
|
} catch (caughtError: unknown) {
|
||||||
|
// Ein Abbruch ist kein Fehler, sondern Folge des Aufraeumens.
|
||||||
|
if (caughtError instanceof DOMException && caughtError.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die bisherige Liste wird verworfen: Eine veraltete Anzeige als
|
||||||
|
// aktuellen Stand auszugeben waere irrefuehrend.
|
||||||
|
setJobs([]);
|
||||||
|
setLoadError(
|
||||||
|
caughtError instanceof ApiError
|
||||||
|
? caughtError.message
|
||||||
|
: 'Die Auftraege konnten nicht geladen werden.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadJobs();
|
||||||
|
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [reloadCounter]);
|
||||||
|
|
||||||
|
if (isWizardOpen) {
|
||||||
|
return (
|
||||||
|
<BackupWizard
|
||||||
|
onCancel={() => setIsWizardOpen(false)}
|
||||||
|
onJobCreated={() => {
|
||||||
|
setIsWizardOpen(false);
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="jobs" aria-label="Sicherungsauftraege">
|
||||||
|
<header className="jobs__header">
|
||||||
|
<h2 className="jobs__heading">Sicherungsauftraege</h2>
|
||||||
|
|
||||||
|
<button type="button" className="jobs__create" onClick={() => setIsWizardOpen(true)}>
|
||||||
|
Auftrag anlegen
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loadError !== null && (
|
||||||
|
<p className="jobs__error" role="alert">
|
||||||
|
{loadError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && <p className="jobs__hint">Wird geladen …</p>}
|
||||||
|
|
||||||
|
{!isLoading && loadError === null && jobs.length === 0 && (
|
||||||
|
<p className="jobs__hint">
|
||||||
|
Es ist kein Auftrag eingerichtet. Ohne Auftrag wird nichts gesichert.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{jobs.length > 0 && (
|
||||||
|
<table className="jobs__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Zeitplan</th>
|
||||||
|
<th>Naechster Lauf</th>
|
||||||
|
<th>Letzter Ausgang</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{jobs.map((backupJob) => (
|
||||||
|
<tr key={backupJob.id}>
|
||||||
|
<td>
|
||||||
|
{backupJob.name}
|
||||||
|
{backupJob.status === 'paused' && (
|
||||||
|
<span className="jobs__badge jobs__badge--paused">ausgesetzt</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{backupJob.schedule_description}</td>
|
||||||
|
<td>{formatTimestamp(backupJob.next_run_at)}</td>
|
||||||
|
<td>{renderOutcome(backupJob.last_outcome)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gibt einen Zeitstempel in Ortszeit aus. */
|
||||||
|
function formatTimestamp(isoTimestamp: string | undefined): string {
|
||||||
|
if (isoTimestamp === undefined || isoTimestamp === '') {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedDate = new Date(isoTimestamp);
|
||||||
|
|
||||||
|
if (Number.isNaN(parsedDate.getTime())) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedDate.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt den Ausgang des letzten Laufs dar.
|
||||||
|
*
|
||||||
|
* Ein Teilfehler bekommt eine eigene Farbe und eine eigene Beschriftung. Ihn
|
||||||
|
* gruen als Erfolg zu zeigen waere genau die Beschoenigung, die PROMPT.md §138
|
||||||
|
* verbietet: Der Lauf hat Objekte uebergangen.
|
||||||
|
*/
|
||||||
|
function renderOutcome(lastOutcome: string | undefined): React.JSX.Element {
|
||||||
|
if (lastOutcome === undefined || lastOutcome === '') {
|
||||||
|
return <span className="jobs__outcome">noch nie gelaufen</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcomeLabels: Record<string, { label: string; modifier: string }> = {
|
||||||
|
succeeded: { label: 'erfolgreich', modifier: 'healthy' },
|
||||||
|
partial_failure: { label: 'TEILWEISE FEHLGESCHLAGEN', modifier: 'warning' },
|
||||||
|
failed: { label: 'gescheitert', modifier: 'critical' },
|
||||||
|
cancelled: { label: 'abgebrochen', modifier: 'neutral' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const outcomeInfo = outcomeLabels[lastOutcome] ?? { label: lastOutcome, modifier: 'neutral' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`jobs__outcome jobs__outcome--${outcomeInfo.modifier}`}>{outcomeInfo.label}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
apps/web/src/features/jobs/jobsApi.ts
Normal file
159
apps/web/src/features/jobs/jobsApi.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* API-Anbindung der Sicherungsauftraege (SYNCOVA_API.md §9).
|
||||||
|
*
|
||||||
|
* Das Modul kennt nur den Vertrag nach aussen. Die Gestalt der Anfrage folgt
|
||||||
|
* dem Backend und nicht der Oberflaeche: Der Wizard fuehrt seinen eigenen
|
||||||
|
* Entwurf und uebersetzt ihn erst beim Anlegen. Andernfalls muesste jede
|
||||||
|
* Aenderung an der API sofort die Maske umbauen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { requestApi } from '../../api/client';
|
||||||
|
|
||||||
|
/** Art eines Zeitplans (packages/scheduler). */
|
||||||
|
export type ScheduleType =
|
||||||
|
| 'manual'
|
||||||
|
| 'interval'
|
||||||
|
| 'hourly'
|
||||||
|
| 'daily'
|
||||||
|
| 'weekly'
|
||||||
|
| 'monthly'
|
||||||
|
| 'cron';
|
||||||
|
|
||||||
|
/** Dringlichkeit eines Auftrags. */
|
||||||
|
export type JobPriority = 'critical' | 'high' | 'normal' | 'low';
|
||||||
|
|
||||||
|
/** Art einer Sicherungsquelle. */
|
||||||
|
export type SourceType =
|
||||||
|
| 'filesystem'
|
||||||
|
| 'proxmox_vm'
|
||||||
|
| 'proxmox_container'
|
||||||
|
| 'windows_system'
|
||||||
|
| 'linux_system';
|
||||||
|
|
||||||
|
/** Zeitplan im Anfrage- und Antwortformat der API. */
|
||||||
|
export interface ScheduleDescriptor {
|
||||||
|
/** Art des Zeitplans. */
|
||||||
|
type: ScheduleType;
|
||||||
|
/** Abstand in Sekunden bei type=interval. */
|
||||||
|
interval_seconds?: number;
|
||||||
|
/** Uhrzeit im Format HH:MM. */
|
||||||
|
time?: string;
|
||||||
|
/** Wochentage, 0 = Sonntag. */
|
||||||
|
weekdays?: number[];
|
||||||
|
/** Tage des Monats; -1 bedeutet Monatsletzter. */
|
||||||
|
month_days?: number[];
|
||||||
|
/** Cron-Ausdruck bei type=cron. */
|
||||||
|
cron_expression?: string;
|
||||||
|
/** Zeitzone der Uhrzeiten. */
|
||||||
|
time_zone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quelle im Anfrage- und Antwortformat der API. */
|
||||||
|
export interface SourceDescriptor {
|
||||||
|
/** Art der Quelle. */
|
||||||
|
type: SourceType;
|
||||||
|
/** Kennung innerhalb ihrer Art, etwa ein Pfad. */
|
||||||
|
id: string;
|
||||||
|
/** Sprechende Bezeichnung. */
|
||||||
|
name?: string;
|
||||||
|
/** Einzuschliessende Muster. */
|
||||||
|
include_patterns?: string[];
|
||||||
|
/** Auszuschliessende Muster. */
|
||||||
|
exclude_patterns?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rumpf beim Anlegen eines Auftrags. */
|
||||||
|
export interface CreateJobRequest {
|
||||||
|
/** Eindeutige Bezeichnung. */
|
||||||
|
name: string;
|
||||||
|
/** Erlaeuterung des Zwecks. */
|
||||||
|
description?: string;
|
||||||
|
/** Dringlichkeit. */
|
||||||
|
priority?: JobPriority;
|
||||||
|
/** Zeitplan. */
|
||||||
|
schedule: ScheduleDescriptor;
|
||||||
|
/** Zu sichernde Quellen. */
|
||||||
|
sources: SourceDescriptor[];
|
||||||
|
/** Ziel-Repository. */
|
||||||
|
repository_id: string;
|
||||||
|
/** Zulaessiger Datenverlust in Sekunden. */
|
||||||
|
rpo_seconds?: number;
|
||||||
|
/** Zulaessige Wiederherstellungsdauer in Sekunden. */
|
||||||
|
rto_seconds?: number;
|
||||||
|
/** Bandbreitengrenze in Byte je Sekunde. */
|
||||||
|
bandwidth_limit_bps?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auftrag in der Antwort der API. */
|
||||||
|
export interface BackupJob {
|
||||||
|
/** Oeffentlicher Bezeichner. */
|
||||||
|
id: string;
|
||||||
|
/** Bezeichnung. */
|
||||||
|
name: string;
|
||||||
|
/** Erlaeuterung. */
|
||||||
|
description?: string;
|
||||||
|
/** Zustand. */
|
||||||
|
status: string;
|
||||||
|
/** Dringlichkeit. */
|
||||||
|
priority: JobPriority;
|
||||||
|
/** Zeitplan. */
|
||||||
|
schedule: ScheduleDescriptor;
|
||||||
|
/** Erklaerung des Zeitplans in einem Satz. */
|
||||||
|
schedule_description: string;
|
||||||
|
/** Quellen. */
|
||||||
|
sources: SourceDescriptor[];
|
||||||
|
/** Ziel-Repository. */
|
||||||
|
repository_id: string;
|
||||||
|
/** Naechster Zeitpunkt in UTC. */
|
||||||
|
next_run_at?: string;
|
||||||
|
/** Beginn des letzten Laufs in UTC. */
|
||||||
|
last_run_at?: string;
|
||||||
|
/** Ausgang des letzten Laufs. */
|
||||||
|
last_outcome?: string;
|
||||||
|
/** Bandbreitengrenze in Byte je Sekunde. */
|
||||||
|
bandwidth_limit_bps?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sicherungsziel in der Antwort der API. */
|
||||||
|
export interface BackupRepository {
|
||||||
|
/** Oeffentlicher Bezeichner. */
|
||||||
|
id: string;
|
||||||
|
/** Sprechende Bezeichnung. */
|
||||||
|
name: string;
|
||||||
|
/** Ablageart. */
|
||||||
|
repository_type: string;
|
||||||
|
/** Pfad oder Adresse der Ablage. */
|
||||||
|
location: string;
|
||||||
|
/** Betriebszustand. */
|
||||||
|
status: string;
|
||||||
|
/**
|
||||||
|
* Meldet, ob dieses Ziel Sicherungen annimmt.
|
||||||
|
*
|
||||||
|
* Die Auskunft kommt vom Server. Die Oberflaeche muesste sonst wissen, welche
|
||||||
|
* Zustaende schreibend sind - eine Regel, die dort nicht hingehoert.
|
||||||
|
*/
|
||||||
|
accepts_backups: boolean;
|
||||||
|
/** Meldet den gehaerteten Modus. */
|
||||||
|
hardened: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laedt die bekannten Sicherungsziele.
|
||||||
|
*
|
||||||
|
* Das Abbruchsignal wird nur gesetzt, wenn es vorliegt: Bei
|
||||||
|
* exactOptionalPropertyTypes ist ein ausdrueckliches undefined etwas anderes
|
||||||
|
* als ein fehlendes Feld.
|
||||||
|
*/
|
||||||
|
export async function listRepositories(abortSignal?: AbortSignal): Promise<BackupRepository[]> {
|
||||||
|
return requestApi<BackupRepository[]>('/repositories', abortSignal ? { signal: abortSignal } : {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt die vorhandenen Sicherungsauftraege. */
|
||||||
|
export async function listJobs(abortSignal?: AbortSignal): Promise<BackupJob[]> {
|
||||||
|
return requestApi<BackupJob[]>('/jobs?page_size=100', abortSignal ? { signal: abortSignal } : {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt einen Sicherungsauftrag an. */
|
||||||
|
export async function createJob(jobRequest: CreateJobRequest): Promise<BackupJob> {
|
||||||
|
return requestApi<BackupJob>('/jobs', { method: 'POST', body: jobRequest });
|
||||||
|
}
|
||||||
271
apps/web/src/features/jobs/wizardModel.test.ts
Normal file
271
apps/web/src/features/jobs/wizardModel.test.ts
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* Tests des Wizard-Modells.
|
||||||
|
*
|
||||||
|
* Geprueft wird die Logik ohne gerenderte Maske: welcher Schritt vollstaendig
|
||||||
|
* ist, was in die Anfrage wandert und wie Bandbreitenangaben gedeutet werden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildCreateRequest,
|
||||||
|
createEmptyDraft,
|
||||||
|
describeDraftSchedule,
|
||||||
|
findFirstIncompleteStep,
|
||||||
|
isBandwidthNotationValid,
|
||||||
|
parseBandwidthToBytesPerSecond,
|
||||||
|
splitPatternList,
|
||||||
|
validateStep,
|
||||||
|
WIZARD_STEPS,
|
||||||
|
} from './wizardModel';
|
||||||
|
import type { JobDraft } from './wizardModel';
|
||||||
|
|
||||||
|
/** Liefert einen vollstaendig ausgefuellten Entwurf. */
|
||||||
|
function buildCompleteDraft(): JobDraft {
|
||||||
|
return {
|
||||||
|
...createEmptyDraft(),
|
||||||
|
name: 'Naechtliche Sicherung',
|
||||||
|
sources: [
|
||||||
|
{ sourceType: 'filesystem', sourceIdentifier: '/daten', sourceName: 'Dateiserver', excludePatterns: '*.tmp' },
|
||||||
|
],
|
||||||
|
scheduleType: 'daily',
|
||||||
|
scheduleTime: '02:00',
|
||||||
|
scheduleTimeZone: 'Europe/Berlin',
|
||||||
|
repositoryId: '11111111-1111-1111-1111-111111111111',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Schrittfolge', () => {
|
||||||
|
it('umfasst genau die zehn Schritte des Plans', () => {
|
||||||
|
expect(WIZARD_STEPS).toHaveLength(10);
|
||||||
|
expect(WIZARD_STEPS[0]).toBe('name');
|
||||||
|
expect(WIZARD_STEPS[8]).toBe('review');
|
||||||
|
expect(WIZARD_STEPS[9]).toBe('create');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Pruefung der Schritte', () => {
|
||||||
|
it('nimmt einen vollstaendigen Entwurf an', () => {
|
||||||
|
expect(findFirstIncompleteStep(buildCompleteDraft())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlangt einen Namen', () => {
|
||||||
|
const namelessDraft = { ...buildCompleteDraft(), name: ' ' };
|
||||||
|
|
||||||
|
expect(validateStep('name', namelessDraft).isComplete).toBe(false);
|
||||||
|
expect(findFirstIncompleteStep(namelessDraft)).toBe('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ein Auftrag ohne Quelle liefe erfolgreich durch, ohne etwas zu sichern -
|
||||||
|
// die gefaehrlichste Fehlkonfiguration, weil sie wie ein Erfolg aussieht.
|
||||||
|
it('verlangt mindestens eine Quelle', () => {
|
||||||
|
const sourcelessDraft = {
|
||||||
|
...buildCompleteDraft(),
|
||||||
|
sources: [{ sourceType: 'filesystem' as const, sourceIdentifier: ' ', sourceName: '', excludePatterns: '' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const validation = validateStep('source', sourcelessDraft);
|
||||||
|
|
||||||
|
expect(validation.isComplete).toBe(false);
|
||||||
|
expect(validation.problems[0]).toContain('mindestens eine Quelle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist dieselbe Quelle zweimal ab', () => {
|
||||||
|
const duplicateDraft = {
|
||||||
|
...buildCompleteDraft(),
|
||||||
|
sources: [
|
||||||
|
{ sourceType: 'filesystem' as const, sourceIdentifier: '/daten', sourceName: '', excludePatterns: '' },
|
||||||
|
{ sourceType: 'filesystem' as const, sourceIdentifier: '/daten', sourceName: '', excludePatterns: '' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(validateStep('source', duplicateDraft).isComplete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlangt einen absoluten Pfad', () => {
|
||||||
|
const relativeDraft = {
|
||||||
|
...buildCompleteDraft(),
|
||||||
|
sources: [
|
||||||
|
{ sourceType: 'filesystem' as const, sourceIdentifier: 'daten', sourceName: '', excludePatterns: '' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(validateStep('source', relativeDraft).problems[0]).toContain('absolut');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlangt ein Repository', () => {
|
||||||
|
const targetlessDraft = { ...buildCompleteDraft(), repositoryId: '' };
|
||||||
|
|
||||||
|
expect(validateStep('repository', targetlessDraft).isComplete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlangt bei einem Wochenplan mindestens einen Tag', () => {
|
||||||
|
const weeklyDraft: JobDraft = { ...buildCompleteDraft(), scheduleType: 'weekly', scheduleWeekdays: [] };
|
||||||
|
|
||||||
|
expect(validateStep('schedule', weeklyDraft).isComplete).toBe(false);
|
||||||
|
|
||||||
|
weeklyDraft.scheduleWeekdays = [6];
|
||||||
|
|
||||||
|
expect(validateStep('schedule', weeklyDraft).isComplete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist eine unmoegliche Uhrzeit ab', () => {
|
||||||
|
const brokenTimeDraft: JobDraft = { ...buildCompleteDraft(), scheduleTime: '25:00' };
|
||||||
|
|
||||||
|
expect(validateStep('schedule', brokenTimeDraft).isComplete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verlangt fuenf Felder in einem Cron-Ausdruck', () => {
|
||||||
|
const cronDraft: JobDraft = {
|
||||||
|
...buildCompleteDraft(),
|
||||||
|
scheduleType: 'cron',
|
||||||
|
scheduleCronExpression: '0 2 *',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(validateStep('schedule', cronDraft).isComplete).toBe(false);
|
||||||
|
|
||||||
|
cronDraft.scheduleCronExpression = '0 2 * * *';
|
||||||
|
|
||||||
|
expect(validateStep('schedule', cronDraft).isComplete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aufbewahrung, Pruefung und Benachrichtigung fragen nichts ab und duerfen
|
||||||
|
// den Anwender deshalb auch nicht aufhalten.
|
||||||
|
it('haelt bei den nicht umgesetzten Schritten nicht auf', () => {
|
||||||
|
const emptyDraft = createEmptyDraft();
|
||||||
|
|
||||||
|
for (const wizardStep of ['retention', 'verification', 'notifications'] as const) {
|
||||||
|
expect(validateStep(wizardStep, emptyDraft).isComplete).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bandbreitenangabe', () => {
|
||||||
|
// 100Mbit ist ein Achtel von 100MB. Wer das verwechselt, vergibt das
|
||||||
|
// Achtfache der beabsichtigten Rate.
|
||||||
|
it('haelt Bit und Byte auseinander', () => {
|
||||||
|
const bitValue = parseBandwidthToBytesPerSecond('100Mbit');
|
||||||
|
const byteValue = parseBandwidthToBytesPerSecond('100MB');
|
||||||
|
|
||||||
|
expect(bitValue).toBe(12_500_000);
|
||||||
|
expect(byteValue).toBe(104_857_600);
|
||||||
|
expect(bitValue).toBeLessThan(byteValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt die gebraeuchlichen Schreibweisen an', () => {
|
||||||
|
expect(parseBandwidthToBytesPerSecond('50MB')).toBe(52_428_800);
|
||||||
|
expect(parseBandwidthToBytesPerSecond('50 MB/s')).toBe(52_428_800);
|
||||||
|
expect(parseBandwidthToBytesPerSecond('1,5MB')).toBe(1_572_864);
|
||||||
|
expect(parseBandwidthToBytesPerSecond('1.5MB')).toBe(1_572_864);
|
||||||
|
expect(parseBandwidthToBytesPerSecond('')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('weist Unsinn ab', () => {
|
||||||
|
expect(isBandwidthNotationValid('schnell')).toBe(false);
|
||||||
|
expect(isBandwidthNotationValid('50MB')).toBe(true);
|
||||||
|
|
||||||
|
const brokenDraft = { ...buildCompleteDraft(), bandwidthLimit: 'schnell' };
|
||||||
|
|
||||||
|
expect(validateStep('security', brokenDraft).isComplete).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Anfrage an die API', () => {
|
||||||
|
it('uebertraegt Name, Quelle, Zeitplan und Ziel', () => {
|
||||||
|
const jobRequest = buildCreateRequest(buildCompleteDraft());
|
||||||
|
|
||||||
|
expect(jobRequest.name).toBe('Naechtliche Sicherung');
|
||||||
|
expect(jobRequest.repository_id).toBe('11111111-1111-1111-1111-111111111111');
|
||||||
|
expect(jobRequest.schedule.type).toBe('daily');
|
||||||
|
expect(jobRequest.schedule.time).toBe('02:00');
|
||||||
|
expect(jobRequest.sources).toHaveLength(1);
|
||||||
|
expect(jobRequest.sources[0]?.id).toBe('/daten');
|
||||||
|
expect(jobRequest.sources[0]?.exclude_patterns).toEqual(['*.tmp']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ohne Zeitzone rechnet der Server in UTC - derselbe Auftrag liefe dann je
|
||||||
|
// nach Standort zu einer anderen Uhrzeit.
|
||||||
|
it('sendet die Zeitzone bei Zeitplaenen mit Uhrzeit', () => {
|
||||||
|
const jobRequest = buildCreateRequest(buildCompleteDraft());
|
||||||
|
|
||||||
|
expect(jobRequest.schedule.time_zone).toBe('Europe/Berlin');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ein Intervallplan zaehlt Abstaende, keine Uhrzeiten. Eine Zeitzone waere
|
||||||
|
// dort bedeutungslos.
|
||||||
|
it('laesst die Zeitzone bei einem Intervallplan weg', () => {
|
||||||
|
const intervalDraft: JobDraft = {
|
||||||
|
...buildCompleteDraft(),
|
||||||
|
scheduleType: 'interval',
|
||||||
|
scheduleIntervalHours: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
const jobRequest = buildCreateRequest(intervalDraft);
|
||||||
|
|
||||||
|
expect(jobRequest.schedule.time_zone).toBeUndefined();
|
||||||
|
expect(jobRequest.schedule.interval_seconds).toBe(21_600);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ein leeres Feld mitzusenden ueberschriebe auf dem Server einen sinnvollen
|
||||||
|
// Standard mit einem Leerwert.
|
||||||
|
it('laesst leere Felder weg', () => {
|
||||||
|
const sparseDraft: JobDraft = { ...buildCompleteDraft(), description: ' ', bandwidthLimit: '' };
|
||||||
|
|
||||||
|
const jobRequest = buildCreateRequest(sparseDraft);
|
||||||
|
|
||||||
|
expect(jobRequest.description).toBeUndefined();
|
||||||
|
expect(jobRequest.bandwidth_limit_bps).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uebertraegt die Bandbreitengrenze in Byte je Sekunde', () => {
|
||||||
|
const limitedDraft: JobDraft = { ...buildCompleteDraft(), bandwidthLimit: '50MB' };
|
||||||
|
|
||||||
|
expect(buildCreateRequest(limitedDraft).bandwidth_limit_bps).toBe(52_428_800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uebergeht leere Quellzeilen', () => {
|
||||||
|
const mixedDraft: JobDraft = {
|
||||||
|
...buildCompleteDraft(),
|
||||||
|
sources: [
|
||||||
|
{ sourceType: 'filesystem', sourceIdentifier: '/daten', sourceName: '', excludePatterns: '' },
|
||||||
|
{ sourceType: 'filesystem', sourceIdentifier: ' ', sourceName: '', excludePatterns: '' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildCreateRequest(mixedDraft).sources).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sortiert Wochentage und Monatstage', () => {
|
||||||
|
const weeklyDraft: JobDraft = {
|
||||||
|
...buildCompleteDraft(),
|
||||||
|
scheduleType: 'weekly',
|
||||||
|
scheduleWeekdays: [6, 0, 3],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildCreateRequest(weeklyDraft).schedule.weekdays).toEqual([0, 3, 6]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Beschreibung des Zeitplans', () => {
|
||||||
|
it('beschreibt die Zeitplaene verstaendlich', () => {
|
||||||
|
const dailyDraft = buildCompleteDraft();
|
||||||
|
|
||||||
|
expect(describeDraftSchedule(dailyDraft)).toBe('taeglich um 02:00 Uhr (Europe/Berlin)');
|
||||||
|
|
||||||
|
expect(describeDraftSchedule({ ...dailyDraft, scheduleType: 'manual' })).toBe('nur auf Anforderung');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
describeDraftSchedule({ ...dailyDraft, scheduleType: 'weekly', scheduleWeekdays: [6, 0] }),
|
||||||
|
).toBe('Sonntag, Samstag um 02:00 Uhr (Europe/Berlin)');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
describeDraftSchedule({ ...dailyDraft, scheduleType: 'monthly', scheduleMonthDays: [-1] }),
|
||||||
|
).toBe('am letzten Tag um 02:00 Uhr (Europe/Berlin)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Musterlisten', () => {
|
||||||
|
it('zerlegt kommaseparierte Muster und verwirft Leeres', () => {
|
||||||
|
expect(splitPatternList('*.tmp, cache , ')).toEqual(['*.tmp', 'cache']);
|
||||||
|
expect(splitPatternList('')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
apps/web/src/features/jobs/wizardModel.ts
Normal file
BIN
apps/web/src/features/jobs/wizardModel.ts
Normal file
Binary file not shown.
136
apps/web/src/features/metrics/LineChart.test.tsx
Normal file
136
apps/web/src/features/metrics/LineChart.test.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* Tests des Liniendiagramms.
|
||||||
|
*
|
||||||
|
* Sie pruefen die eine Eigenschaft, wegen der dieses Diagramm selbst geschrieben
|
||||||
|
* wurde: **Eine Luecke wird nicht ueberbrueckt und nicht auf null gezogen.**
|
||||||
|
* Beides ergaebe eine Kurve, die etwas anderes behauptet als die Daten hergeben.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { LineChart } from './LineChart';
|
||||||
|
import type { DataPoint, Series } from './metricsApi';
|
||||||
|
|
||||||
|
/** Baut einen Punkt mit Wert. */
|
||||||
|
function pointWithValue(minuteOffset: number, dataValue: number): DataPoint {
|
||||||
|
return {
|
||||||
|
timestamp: new Date(Date.UTC(2026, 7, 12, 0, minuteOffset)).toISOString(),
|
||||||
|
value: dataValue,
|
||||||
|
has_value: true,
|
||||||
|
sample_count: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut einen Punkt ohne Messung. */
|
||||||
|
function pointWithoutValue(minuteOffset: number): DataPoint {
|
||||||
|
return {
|
||||||
|
timestamp: new Date(Date.UTC(2026, 7, 12, 0, minuteOffset)).toISOString(),
|
||||||
|
has_value: false,
|
||||||
|
sample_count: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut eine Reihe aus den uebergebenen Punkten. */
|
||||||
|
function seriesOf(points: readonly DataPoint[]): Series {
|
||||||
|
return { name: 'test', label: 'Testreihe', unit: 'count', points };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest alle gezeichneten Linienzuege aus dem Dokument. */
|
||||||
|
function readPathDefinitions(container: HTMLElement): string[] {
|
||||||
|
return Array.from(container.querySelectorAll('path')).map(
|
||||||
|
(pathElement) => pathElement.getAttribute('d') ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Liniendiagramm', () => {
|
||||||
|
it('unterbricht die Linie an einer Luecke, statt sie zu ueberbruecken', () => {
|
||||||
|
// Zwei Messungen, dazwischen ein Zeitfenster ohne Lauf.
|
||||||
|
const { container } = render(
|
||||||
|
<LineChart
|
||||||
|
series={[
|
||||||
|
seriesOf([
|
||||||
|
pointWithValue(0, 10),
|
||||||
|
pointWithValue(1, 12),
|
||||||
|
pointWithoutValue(2),
|
||||||
|
pointWithValue(3, 11),
|
||||||
|
pointWithValue(4, 13),
|
||||||
|
]),
|
||||||
|
]}
|
||||||
|
unit="count"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pathDefinitions = readPathDefinitions(container);
|
||||||
|
|
||||||
|
// Zwei getrennte Linienzuege — nicht einer, der die Luecke ueberspringt.
|
||||||
|
expect(pathDefinitions).toHaveLength(2);
|
||||||
|
|
||||||
|
for (const pathDefinition of pathDefinitions) {
|
||||||
|
// Jeder Abschnitt beginnt mit einem eigenen Move-Befehl. Ein einziger
|
||||||
|
// Linienzug mit zwei M-Befehlen waere zwar optisch gleich, liesse sich
|
||||||
|
// aber nicht mehr als getrennt erkennen.
|
||||||
|
expect(pathDefinition.startsWith('M')).toBe(true);
|
||||||
|
expect(pathDefinition.split('M').length - 1).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zieht eine Luecke nicht auf den Nullpunkt', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<LineChart
|
||||||
|
series={[seriesOf([pointWithValue(0, 100), pointWithoutValue(1), pointWithValue(2, 100)])]}
|
||||||
|
unit="count"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const pathDefinitions = readPathDefinitions(container);
|
||||||
|
const allCoordinates = pathDefinitions.join(' ');
|
||||||
|
|
||||||
|
// Bei einem Maximum von 100 liegt der Nullpunkt am unteren Rand der
|
||||||
|
// Zeichenflaeche. Taucht er auf, wurde die Luecke als Null gezeichnet — und
|
||||||
|
// die Kurve behauptet einen Einbruch, den es nicht gab.
|
||||||
|
const lowestDrawnPosition = Math.max(
|
||||||
|
...Array.from(allCoordinates.matchAll(/,(\d+\.\d)/g)).map((match) => Number(match[1])),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lowestDrawnPosition).toBeLessThan(190);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeichnet eine einzelne Messung als Punkt', () => {
|
||||||
|
// Eine einzelne Messung ergaebe eine Linie der Laenge null und waere
|
||||||
|
// unsichtbar — die Kurve saehe aus wie „nichts gemessen".
|
||||||
|
const { container } = render(
|
||||||
|
<LineChart
|
||||||
|
series={[seriesOf([pointWithoutValue(0), pointWithValue(1, 42), pointWithoutValue(2)])]}
|
||||||
|
unit="count"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelectorAll('circle')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sagt bei leerer Reihe, dass nichts gemessen wurde', () => {
|
||||||
|
render(
|
||||||
|
<LineChart
|
||||||
|
series={[seriesOf([pointWithoutValue(0), pointWithoutValue(1), pointWithoutValue(2)])]}
|
||||||
|
unit="count"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Kein leeres Achsenkreuz: Das saehe aus wie eine Kurve auf null.
|
||||||
|
expect(screen.getByText(/keine Daten/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/nicht/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beginnt die Werteachse bei null', () => {
|
||||||
|
// Eine abgeschnittene Achse laesst kleine Schwankungen wie Einbrueche
|
||||||
|
// aussehen — der haeufigste Weg, mit korrekten Zahlen etwas Falsches zu
|
||||||
|
// zeigen.
|
||||||
|
render(
|
||||||
|
<LineChart series={[seriesOf([pointWithValue(0, 100), pointWithValue(1, 102)])]} unit="count" />,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Die Achse laeuft von null bis zum groessten Wert der Reihe.
|
||||||
|
expect(screen.getByText('0')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('102')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
349
apps/web/src/features/metrics/LineChart.tsx
Normal file
349
apps/web/src/features/metrics/LineChart.tsx
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
/**
|
||||||
|
* Liniendiagramm als SVG, ohne Diagrammbibliothek.
|
||||||
|
*
|
||||||
|
* Warum keine? Die gaengigen Bibliotheken bringen mehr Code mit, als die ganze
|
||||||
|
* Oberflaeche heute hat — und die eine Eigenschaft, auf die es hier ankommt,
|
||||||
|
* beherrschen sie standardmaessig falsch: **Sie zeichnen Luecken als Nullen.**
|
||||||
|
*
|
||||||
|
* Ein Zeitfenster ohne Sicherungslauf hat keinen Durchsatz. Eine Kurve, die
|
||||||
|
* dort auf den Nullpunkt faellt, laesst eine Anlage aussehen, als waere ihre
|
||||||
|
* Leistung eingebrochen, obwohl sie nur nichts zu tun hatte. Diese Datei
|
||||||
|
* unterbricht die Linie stattdessen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DataPoint, Series, SeriesUnit } from './metricsApi';
|
||||||
|
|
||||||
|
/** Abmessungen der Zeichenflaeche. */
|
||||||
|
const CHART_VIEWBOX_WIDTH = 720;
|
||||||
|
/** Hoehe der Zeichenflaeche. */
|
||||||
|
const CHART_VIEWBOX_HEIGHT = 220;
|
||||||
|
/** Innenabstand links fuer die Beschriftung der Werteachse. */
|
||||||
|
const CHART_PADDING_LEFT = 64;
|
||||||
|
/** Innenabstand rechts. */
|
||||||
|
const CHART_PADDING_RIGHT = 12;
|
||||||
|
/** Innenabstand oben. */
|
||||||
|
const CHART_PADDING_TOP = 12;
|
||||||
|
/** Innenabstand unten fuer die Zeitachse. */
|
||||||
|
const CHART_PADDING_BOTTOM = 28;
|
||||||
|
|
||||||
|
/** Farben der Reihen in Zeichenreihenfolge. */
|
||||||
|
const SERIES_COLORS = [
|
||||||
|
'var(--color-status-info)',
|
||||||
|
'var(--color-status-healthy)',
|
||||||
|
'var(--color-status-high)',
|
||||||
|
'var(--color-status-critical)',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Eigenschaften des Diagramms. */
|
||||||
|
interface LineChartProperties {
|
||||||
|
/** Die darzustellenden Reihen. */
|
||||||
|
readonly series: readonly Series[];
|
||||||
|
/** Einheit der Werte. */
|
||||||
|
readonly unit: SeriesUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichnet ein Liniendiagramm. */
|
||||||
|
export function LineChart({ series, unit }: LineChartProperties): React.JSX.Element {
|
||||||
|
const maximumValue = findMaximumValue(series);
|
||||||
|
|
||||||
|
// Ohne einen einzigen Wert gibt es nichts zu zeichnen. Eine leere Flaeche mit
|
||||||
|
// Achsen sieht aus wie eine Kurve auf null — deshalb steht hier ein Satz.
|
||||||
|
if (maximumValue === null) {
|
||||||
|
return (
|
||||||
|
<p className="chart__empty">
|
||||||
|
In diesem Zeitraum wurde nichts gemessen. Die leere Flaeche bedeutet „keine Daten", nicht
|
||||||
|
„Wert null".
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ab hier steht fest, dass ein Maximum vorliegt; TypeScript weiss das nach
|
||||||
|
// der Rueckgabe oben nicht mehr, weil die Hilfsfunktionen es einfangen.
|
||||||
|
const scaleMaximum: number = maximumValue;
|
||||||
|
|
||||||
|
const plotWidth = CHART_VIEWBOX_WIDTH - CHART_PADDING_LEFT - CHART_PADDING_RIGHT;
|
||||||
|
const plotHeight = CHART_VIEWBOX_HEIGHT - CHART_PADDING_TOP - CHART_PADDING_BOTTOM;
|
||||||
|
const pointCount = series[0]?.points.length ?? 0;
|
||||||
|
|
||||||
|
/** Rechnet einen Punktindex in eine X-Koordinate um. */
|
||||||
|
function horizontalPositionOf(pointIndex: number): number {
|
||||||
|
if (pointCount <= 1) {
|
||||||
|
return CHART_PADDING_LEFT + plotWidth / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CHART_PADDING_LEFT + (pointIndex / (pointCount - 1)) * plotWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rechnet einen Wert in eine Y-Koordinate um. */
|
||||||
|
function verticalPositionOf(dataValue: number): number {
|
||||||
|
// Die Werteachse beginnt immer bei null. Eine abgeschnittene Achse laesst
|
||||||
|
// kleine Schwankungen wie Einbrueche aussehen — der haeufigste Weg, mit
|
||||||
|
// einem korrekten Diagramm etwas Falsches zu zeigen.
|
||||||
|
return CHART_PADDING_TOP + plotHeight - (dataValue / scaleMaximum) * plotHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<figure className="chart">
|
||||||
|
<svg
|
||||||
|
className="chart__canvas"
|
||||||
|
viewBox={`0 0 ${CHART_VIEWBOX_WIDTH} ${CHART_VIEWBOX_HEIGHT}`}
|
||||||
|
role="img"
|
||||||
|
aria-label={series.map((oneSeries) => oneSeries.label).join(', ')}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<GridLines
|
||||||
|
maximumValue={scaleMaximum}
|
||||||
|
unit={unit}
|
||||||
|
plotWidth={plotWidth}
|
||||||
|
verticalPositionOf={verticalPositionOf}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{series.map((oneSeries, seriesIndex) => (
|
||||||
|
<SeriesPath
|
||||||
|
key={oneSeries.name}
|
||||||
|
series={oneSeries}
|
||||||
|
color={SERIES_COLORS[seriesIndex % SERIES_COLORS.length] ?? SERIES_COLORS[0]!}
|
||||||
|
horizontalPositionOf={horizontalPositionOf}
|
||||||
|
verticalPositionOf={verticalPositionOf}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<figcaption className="chart__legend">
|
||||||
|
{series.map((oneSeries, seriesIndex) => (
|
||||||
|
<span className="chart__legend-item" key={oneSeries.name}>
|
||||||
|
<span
|
||||||
|
className="chart__legend-swatch"
|
||||||
|
style={{ backgroundColor: SERIES_COLORS[seriesIndex % SERIES_COLORS.length] }}
|
||||||
|
/>
|
||||||
|
{oneSeries.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Hilfslinien. */
|
||||||
|
interface GridLinesProperties {
|
||||||
|
/** Groesster Wert der Reihen. */
|
||||||
|
readonly maximumValue: number;
|
||||||
|
/** Einheit der Werte. */
|
||||||
|
readonly unit: SeriesUnit;
|
||||||
|
/** Breite der Zeichenflaeche. */
|
||||||
|
readonly plotWidth: number;
|
||||||
|
/** Rechnet einen Wert in eine Y-Koordinate um. */
|
||||||
|
readonly verticalPositionOf: (dataValue: number) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichnet Hilfslinien und die Beschriftung der Werteachse. */
|
||||||
|
function GridLines({
|
||||||
|
maximumValue,
|
||||||
|
unit,
|
||||||
|
plotWidth,
|
||||||
|
verticalPositionOf,
|
||||||
|
}: GridLinesProperties): React.JSX.Element {
|
||||||
|
const gridValues = [0, maximumValue / 2, maximumValue];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g className="chart__grid">
|
||||||
|
{gridValues.map((gridValue) => (
|
||||||
|
<g key={gridValue}>
|
||||||
|
<line
|
||||||
|
x1={CHART_PADDING_LEFT}
|
||||||
|
y1={verticalPositionOf(gridValue)}
|
||||||
|
x2={CHART_PADDING_LEFT + plotWidth}
|
||||||
|
y2={verticalPositionOf(gridValue)}
|
||||||
|
/>
|
||||||
|
<text x={CHART_PADDING_LEFT - 8} y={verticalPositionOf(gridValue) + 4} textAnchor="end">
|
||||||
|
{formatAxisValue(gridValue, unit)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften einer gezeichneten Reihe. */
|
||||||
|
interface SeriesPathProperties {
|
||||||
|
/** Die darzustellende Reihe. */
|
||||||
|
readonly series: Series;
|
||||||
|
/** Farbe der Linie. */
|
||||||
|
readonly color: string;
|
||||||
|
/** Rechnet einen Punktindex in eine X-Koordinate um. */
|
||||||
|
readonly horizontalPositionOf: (pointIndex: number) => number;
|
||||||
|
/** Rechnet einen Wert in eine Y-Koordinate um. */
|
||||||
|
readonly verticalPositionOf: (dataValue: number) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichnet eine Reihe als unterbrochene Linie. */
|
||||||
|
function SeriesPath({
|
||||||
|
series,
|
||||||
|
color,
|
||||||
|
horizontalPositionOf,
|
||||||
|
verticalPositionOf,
|
||||||
|
}: SeriesPathProperties): React.JSX.Element {
|
||||||
|
const pathSegments = buildPathSegments(series.points, horizontalPositionOf, verticalPositionOf);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
{pathSegments.map((pathSegment, segmentIndex) => (
|
||||||
|
<path
|
||||||
|
className="chart__line"
|
||||||
|
key={segmentIndex}
|
||||||
|
d={pathSegment}
|
||||||
|
stroke={color}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Ein einzelner Messpunkt ohne Nachbarn ergaebe eine Linie der Laenge
|
||||||
|
null und waere unsichtbar. Er wird deshalb als Punkt gezeichnet. */}
|
||||||
|
{series.points.map((dataPoint, pointIndex) =>
|
||||||
|
dataPoint.has_value && isIsolatedPoint(series.points, pointIndex) ? (
|
||||||
|
<circle
|
||||||
|
className="chart__point"
|
||||||
|
key={dataPoint.timestamp}
|
||||||
|
cx={horizontalPositionOf(pointIndex)}
|
||||||
|
cy={verticalPositionOf(dataPoint.value ?? 0)}
|
||||||
|
r={2.5}
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die Linienabschnitte einer Reihe.
|
||||||
|
*
|
||||||
|
* Jede zusammenhaengende Folge von Punkten mit Wert wird ein eigener Abschnitt.
|
||||||
|
* Eine Luecke unterbricht die Linie — sie wird nicht ueberbrueckt und nicht auf
|
||||||
|
* null gezogen.
|
||||||
|
*/
|
||||||
|
function buildPathSegments(
|
||||||
|
points: readonly DataPoint[],
|
||||||
|
horizontalPositionOf: (pointIndex: number) => number,
|
||||||
|
verticalPositionOf: (dataValue: number) => number,
|
||||||
|
): string[] {
|
||||||
|
const pathSegments: string[] = [];
|
||||||
|
let currentSegment = '';
|
||||||
|
|
||||||
|
points.forEach((dataPoint, pointIndex) => {
|
||||||
|
if (!dataPoint.has_value) {
|
||||||
|
if (currentSegment !== '') {
|
||||||
|
pathSegments.push(currentSegment);
|
||||||
|
currentSegment = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const horizontalPosition = horizontalPositionOf(pointIndex);
|
||||||
|
const verticalPosition = verticalPositionOf(dataPoint.value ?? 0);
|
||||||
|
const commandLetter = currentSegment === '' ? 'M' : 'L';
|
||||||
|
|
||||||
|
currentSegment += `${commandLetter}${horizontalPosition.toFixed(1)},${verticalPosition.toFixed(1)} `;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentSegment !== '') {
|
||||||
|
pathSegments.push(currentSegment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathSegments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meldet einen Messpunkt ohne benachbarte Messung. */
|
||||||
|
function isIsolatedPoint(points: readonly DataPoint[], pointIndex: number): boolean {
|
||||||
|
const previousHasValue = pointIndex > 0 && (points[pointIndex - 1]?.has_value ?? false);
|
||||||
|
const nextHasValue =
|
||||||
|
pointIndex < points.length - 1 && (points[pointIndex + 1]?.has_value ?? false);
|
||||||
|
|
||||||
|
return !previousHasValue && !nextHasValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sucht den groessten Wert aller Reihen. */
|
||||||
|
function findMaximumValue(series: readonly Series[]): number | null {
|
||||||
|
let maximumValue: number | null = null;
|
||||||
|
|
||||||
|
for (const oneSeries of series) {
|
||||||
|
for (const dataPoint of oneSeries.points) {
|
||||||
|
if (!dataPoint.has_value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataValue = dataPoint.value ?? 0;
|
||||||
|
|
||||||
|
if (maximumValue === null || dataValue > maximumValue) {
|
||||||
|
maximumValue = dataValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Maximum von null macht die Division unmoeglich. Eine Reihe aus lauter
|
||||||
|
// Nullen ist ein gueltiger Fall — etwa null Fehlschlaege.
|
||||||
|
if (maximumValue !== null && maximumValue === 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return maximumValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Faktor zwischen zwei Groesseneinheiten. */
|
||||||
|
const BYTE_UNIT_STEP = 1024;
|
||||||
|
|
||||||
|
/** Schreibt einen Achsenwert lesbar. */
|
||||||
|
export function formatAxisValue(dataValue: number, unit: SeriesUnit): string {
|
||||||
|
switch (unit) {
|
||||||
|
case 'bytes':
|
||||||
|
return formatBytes(dataValue);
|
||||||
|
case 'bytes_per_second':
|
||||||
|
return `${formatBytes(dataValue)}/s`;
|
||||||
|
case 'seconds':
|
||||||
|
return formatDuration(dataValue);
|
||||||
|
case 'percent':
|
||||||
|
return `${dataValue.toLocaleString('de-DE', { maximumFractionDigits: 1 })} %`;
|
||||||
|
default:
|
||||||
|
return dataValue.toLocaleString('de-DE', { maximumFractionDigits: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt eine Datenmenge lesbar. */
|
||||||
|
function formatBytes(byteCount: number): string {
|
||||||
|
if (byteCount < BYTE_UNIT_STEP) {
|
||||||
|
return `${Math.round(byteCount)} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitNames = ['KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
|
||||||
|
let remainingValue = byteCount;
|
||||||
|
let chosenUnit = unitNames[0];
|
||||||
|
|
||||||
|
for (const unitName of unitNames) {
|
||||||
|
remainingValue /= BYTE_UNIT_STEP;
|
||||||
|
chosenUnit = unitName;
|
||||||
|
|
||||||
|
if (remainingValue < BYTE_UNIT_STEP) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${remainingValue.toLocaleString('de-DE', { maximumFractionDigits: 1 })} ${chosenUnit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt eine Dauer lesbar. */
|
||||||
|
function formatDuration(secondCount: number): string {
|
||||||
|
if (secondCount < 60) {
|
||||||
|
return `${secondCount.toLocaleString('de-DE', { maximumFractionDigits: 1 })} s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secondCount < 3600) {
|
||||||
|
return `${Math.round(secondCount / 60)} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secondCount < 86400) {
|
||||||
|
return `${(secondCount / 3600).toLocaleString('de-DE', { maximumFractionDigits: 1 })} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${(secondCount / 86400).toLocaleString('de-DE', { maximumFractionDigits: 1 })} d`;
|
||||||
|
}
|
||||||
139
apps/web/src/features/metrics/MetricsPage.tsx
Normal file
139
apps/web/src/features/metrics/MetricsPage.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Kennzahlen und Diagramme.
|
||||||
|
*
|
||||||
|
* Alle zwoelf Diagramme aus dem Plan erscheinen. Das eine ohne Datengrundlage
|
||||||
|
* — die Ressourcenlast der Agenten — steht mit dabei und sagt, was fehlt.
|
||||||
|
* Dieselbe Regel wie in der Uebersicht: Ein weggelassenes Diagramm sieht aus wie
|
||||||
|
* ein vergessenes, ein leeres wie eine Anlage ohne Betrieb.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useApiResource } from '../../api/useApiResource';
|
||||||
|
import { ErrorState, LoadingState } from '../../components/PageState';
|
||||||
|
import { LineChart } from './LineChart';
|
||||||
|
import { fetchChart, fetchChartCatalog } from './metricsApi';
|
||||||
|
import type { Chart, ChartCatalog, ChartDefinition } from './metricsApi';
|
||||||
|
|
||||||
|
/** Auswaehlbare Zeitraeume mit ihrer Beschriftung. */
|
||||||
|
const RANGE_OPTIONS: readonly { readonly value: string; readonly label: string }[] = [
|
||||||
|
{ value: '1h', label: 'Letzte Stunde' },
|
||||||
|
{ value: '24h', label: 'Letzte 24 Stunden' },
|
||||||
|
{ value: '7d', label: 'Letzte 7 Tage' },
|
||||||
|
{ value: '30d', label: 'Letzte 30 Tage' },
|
||||||
|
{ value: '90d', label: 'Letzte 90 Tage' },
|
||||||
|
{ value: '1y', label: 'Letztes Jahr' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Zeigt die Kennzahlen. */
|
||||||
|
export function MetricsPage(): React.JSX.Element {
|
||||||
|
const [selectedRange, setSelectedRange] = useState('7d');
|
||||||
|
|
||||||
|
const loadCatalog = useCallback((abortSignal: AbortSignal) => fetchChartCatalog(abortSignal), []);
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<ChartCatalog>(loadCatalog);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page__header">
|
||||||
|
<h1 className="page__title">Kennzahlen</h1>
|
||||||
|
|
||||||
|
{data !== null && (
|
||||||
|
<span className="page__meta">
|
||||||
|
{data.available_count} von {data.charts.length} Diagrammen haben eine Datengrundlage
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="filter-bar">
|
||||||
|
<label className="filter-bar__field">
|
||||||
|
<span className="filter-bar__label">Zeitraum</span>
|
||||||
|
<select
|
||||||
|
className="filter-bar__select"
|
||||||
|
value={selectedRange}
|
||||||
|
onChange={(changeEvent) => setSelectedRange(changeEvent.target.value)}
|
||||||
|
>
|
||||||
|
{RANGE_OPTIONS.map((rangeOption) => (
|
||||||
|
<option key={rangeOption.value} value={rangeOption.value}>
|
||||||
|
{rangeOption.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadState === 'loading' && <LoadingState what="Die Kennzahlen" />}
|
||||||
|
{loadState === 'failed' && loadError !== null && (
|
||||||
|
<ErrorState error={loadError} onRetry={reload} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && data !== null && (
|
||||||
|
<div className="chart-list">
|
||||||
|
{data.charts.map((chartDefinition) => (
|
||||||
|
<ChartCard
|
||||||
|
key={chartDefinition.metric}
|
||||||
|
definition={chartDefinition}
|
||||||
|
timeRange={selectedRange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften einer Diagrammkachel. */
|
||||||
|
interface ChartCardProperties {
|
||||||
|
/** Das darzustellende Diagramm. */
|
||||||
|
readonly definition: ChartDefinition;
|
||||||
|
/** Der gewaehlte Zeitraum. */
|
||||||
|
readonly timeRange: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt ein einzelnes Diagramm. */
|
||||||
|
function ChartCard({ definition, timeRange }: ChartCardProperties): React.JSX.Element {
|
||||||
|
const loadChart = useCallback(
|
||||||
|
(abortSignal: AbortSignal) => fetchChart(definition.metric, timeRange, abortSignal),
|
||||||
|
[definition.metric, timeRange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ein Diagramm ohne Datengrundlage wird gar nicht erst abgerufen: Der Server
|
||||||
|
// antwortete mit 501, und ein Fehler in der Oberflaeche saehe aus wie eine
|
||||||
|
// Stoerung. Es ist keine — die Funktion gibt es nur noch nicht.
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<Chart>(
|
||||||
|
loadChart,
|
||||||
|
definition.available ? `${definition.metric}|${timeRange}` : 'unavailable',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!definition.available) {
|
||||||
|
return (
|
||||||
|
<article className="chart-card chart-card--unavailable">
|
||||||
|
<h2 className="chart-card__title">{definition.title}</h2>
|
||||||
|
<p className="chart-card__badge">noch nicht verfuegbar</p>
|
||||||
|
<p className="chart-card__description">{definition.unavailable_reason}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="chart-card">
|
||||||
|
<h2 className="chart-card__title">{definition.title}</h2>
|
||||||
|
<p className="chart-card__description">{definition.description}</p>
|
||||||
|
|
||||||
|
{loadState === 'loading' && <LoadingState what="Die Reihe" />}
|
||||||
|
{loadState === 'failed' && loadError !== null && (
|
||||||
|
<ErrorState error={loadError} onRetry={reload} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && data !== null && (
|
||||||
|
<>
|
||||||
|
<LineChart series={data.series} unit={definition.unit} />
|
||||||
|
|
||||||
|
{/* Der Hinweis ordnet ein, was die Kurve wert ist: Zwei Punkte sehen
|
||||||
|
aus wie ein Trend und sind keiner. */}
|
||||||
|
{data.note !== undefined && data.note !== '' && (
|
||||||
|
<p className="chart-card__note">{data.note}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
apps/web/src/features/metrics/metricsApi.ts
Normal file
108
apps/web/src/features/metrics/metricsApi.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Zugriff auf Kennzahlen und Diagramme.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { requestApi } from '../../api/client';
|
||||||
|
|
||||||
|
/** Einheit einer Zeitreihe. */
|
||||||
|
export type SeriesUnit =
|
||||||
|
| 'bytes'
|
||||||
|
| 'bytes_per_second'
|
||||||
|
| 'seconds'
|
||||||
|
| 'percent'
|
||||||
|
| 'count'
|
||||||
|
| 'ratio';
|
||||||
|
|
||||||
|
/** Ein Diagramm des Katalogs. */
|
||||||
|
export interface ChartDefinition {
|
||||||
|
/** Bezeichner im Pfad. */
|
||||||
|
readonly metric: string;
|
||||||
|
/** Ueberschrift. */
|
||||||
|
readonly title: string;
|
||||||
|
/** Erklaerung. */
|
||||||
|
readonly description: string;
|
||||||
|
/** Einheit der Werte. */
|
||||||
|
readonly unit: SeriesUnit;
|
||||||
|
/** Herkunft der Daten. */
|
||||||
|
readonly source: string;
|
||||||
|
/** Meldet, ob es eine Datengrundlage gibt. */
|
||||||
|
readonly available: boolean;
|
||||||
|
/** Erklaert eine fehlende Datengrundlage. */
|
||||||
|
readonly unavailable_reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Katalog aller Diagramme. */
|
||||||
|
export interface ChartCatalog {
|
||||||
|
/** Alle Diagramme in Anzeigereihenfolge. */
|
||||||
|
readonly charts: readonly ChartDefinition[];
|
||||||
|
/** Zahl der Diagramme mit Datengrundlage. */
|
||||||
|
readonly available_count: number;
|
||||||
|
/** Waehlbare Zeitraeume. */
|
||||||
|
readonly ranges: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Punkt einer Zeitreihe. */
|
||||||
|
export interface DataPoint {
|
||||||
|
/** Beginn des Zeitfensters in UTC. */
|
||||||
|
readonly timestamp: string;
|
||||||
|
/** Wert; nur gueltig, wenn has_value gesetzt ist. */
|
||||||
|
readonly value?: number;
|
||||||
|
/**
|
||||||
|
* Meldet, ob in diesem Zeitfenster etwas gemessen wurde.
|
||||||
|
*
|
||||||
|
* Der Unterschied zu einem Wert von null ist der Kern der ganzen Darstellung:
|
||||||
|
* Ein Zeitfenster ohne Lauf hat keinen Durchsatz — nicht null Byte je Sekunde.
|
||||||
|
*/
|
||||||
|
readonly has_value: boolean;
|
||||||
|
/** Zahl der eingeflossenen Messungen. */
|
||||||
|
readonly sample_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine benannte Zeitreihe. */
|
||||||
|
export interface Series {
|
||||||
|
/** Maschinenlesbarer Bezeichner. */
|
||||||
|
readonly name: string;
|
||||||
|
/** Beschriftung. */
|
||||||
|
readonly label: string;
|
||||||
|
/** Einheit der Werte. */
|
||||||
|
readonly unit: SeriesUnit;
|
||||||
|
/** Punkte in zeitlicher Reihenfolge. */
|
||||||
|
readonly points: readonly DataPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein vollstaendiges Diagramm. */
|
||||||
|
export interface Chart {
|
||||||
|
/** Bezeichner. */
|
||||||
|
readonly metric: string;
|
||||||
|
/** Ueberschrift. */
|
||||||
|
readonly title: string;
|
||||||
|
/** Erklaerung. */
|
||||||
|
readonly description: string;
|
||||||
|
/** Ausgewerteter Zeitraum. */
|
||||||
|
readonly window: {
|
||||||
|
readonly range: string;
|
||||||
|
readonly from: string;
|
||||||
|
readonly to: string;
|
||||||
|
};
|
||||||
|
/** Enthaltene Reihen. */
|
||||||
|
readonly series: readonly Series[];
|
||||||
|
/** Hinweis zur Aussagekraft. */
|
||||||
|
readonly note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt den Katalog aller Diagramme. */
|
||||||
|
export async function fetchChartCatalog(abortSignal?: AbortSignal): Promise<ChartCatalog> {
|
||||||
|
return requestApi<ChartCatalog>('/metrics', abortSignal ? { signal: abortSignal } : {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt ein Diagramm fuer den angegebenen Zeitraum. */
|
||||||
|
export async function fetchChart(
|
||||||
|
metricName: string,
|
||||||
|
timeRange: string,
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
|
): Promise<Chart> {
|
||||||
|
return requestApi<Chart>(
|
||||||
|
`/metrics/${encodeURIComponent(metricName)}?range=${encodeURIComponent(timeRange)}`,
|
||||||
|
abortSignal ? { signal: abortSignal } : {},
|
||||||
|
);
|
||||||
|
}
|
||||||
200
apps/web/src/features/reports/ReportsPage.test.tsx
Normal file
200
apps/web/src/features/reports/ReportsPage.test.tsx
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Tests der Berichtsseite.
|
||||||
|
*
|
||||||
|
* Der wichtigste Test ist der dritte: Eine Kennzahl ohne Messung darf nirgends
|
||||||
|
* als Zahl erscheinen — weder in der Vorschau noch in der Datei. In einem
|
||||||
|
* Bericht wiegt dieser Fehler schwerer als anderswo: Der Bericht verlaesst die
|
||||||
|
* Anlage, landet in einer Tabellenkalkulation und in einem Ordner, und dort
|
||||||
|
* ueberlebt eine erfundene Null jede muendliche Erlaeuterung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ReportsPage } from './ReportsPage';
|
||||||
|
|
||||||
|
/** Baut eine Antwort in der Standardhuelle. */
|
||||||
|
function buildJsonResponse(payload: unknown): Response {
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
ok: true,
|
||||||
|
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||||
|
json: () => Promise.resolve({ data: payload, meta: { request_id: 'test' } }),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Katalog, wie ihn die API liefert. */
|
||||||
|
const reportCatalog = [
|
||||||
|
{
|
||||||
|
type: 'daily_backup',
|
||||||
|
title: 'Tagesbericht Sicherungen',
|
||||||
|
description: 'Alle Sicherungslaeufe eines Tages.',
|
||||||
|
period_kind: 'range',
|
||||||
|
default_period: 'letzte 24 Stunden',
|
||||||
|
formats: ['json', 'csv', 'pdf'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'repository_capacity',
|
||||||
|
title: 'Bericht Repository-Kapazitaet',
|
||||||
|
description: 'Belegung und Zustand aller Repositories.',
|
||||||
|
period_kind: 'point_in_time',
|
||||||
|
formats: ['json', 'csv', 'pdf'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Ein Bericht mit einer gemessenen und einer ungemessenen Kennzahl. */
|
||||||
|
const reportWithUnknownMetric = {
|
||||||
|
type: 'daily_backup',
|
||||||
|
title: 'Tagesbericht Sicherungen',
|
||||||
|
description: 'Alle Sicherungslaeufe eines Tages.',
|
||||||
|
period_from: '2026-08-12T00:00:00Z',
|
||||||
|
period_to: '2026-08-13T00:00:00Z',
|
||||||
|
generated_at: '2026-08-13T06:00:00Z',
|
||||||
|
generated_by: 'pruefer',
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Ueberblick',
|
||||||
|
metrics: [
|
||||||
|
{ label: 'Laeufe insgesamt', value: 0, unit: 'count', is_known: true },
|
||||||
|
{
|
||||||
|
label: 'Erfolgsquote',
|
||||||
|
unit: 'percent',
|
||||||
|
is_known: false,
|
||||||
|
unknown_reason: 'Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
title: 'Einzelne Laeufe',
|
||||||
|
columns: ['Auftrag', 'Ergebnis'],
|
||||||
|
rows: [],
|
||||||
|
empty_notice: 'Im gewaehlten Zeitraum wurde kein Lauf begonnen.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
notes: ['Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.'],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Berichte', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt alle Berichtsarten des Katalogs', async () => {
|
||||||
|
vi.mocked(fetch).mockResolvedValue(buildJsonResponse(reportCatalog));
|
||||||
|
|
||||||
|
render(<ReportsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Tagesbericht Sicherungen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Bericht Repository-Kapazitaet')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Ein Zustandsbericht wird als solcher gekennzeichnet: Ein Zeitraum, den er
|
||||||
|
// nicht auswertet, waere ein Versprechen, das er nicht einloest.
|
||||||
|
expect(screen.getByText('Zustandsbericht')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Zeitraum: letzte 24 Stunden')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt eine ungemessene Kennzahl niemals als Zahl', async () => {
|
||||||
|
vi.mocked(fetch)
|
||||||
|
.mockResolvedValueOnce(buildJsonResponse(reportCatalog))
|
||||||
|
.mockResolvedValueOnce(buildJsonResponse(reportWithUnknownMetric));
|
||||||
|
|
||||||
|
render(<ReportsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Tagesbericht Sicherungen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Tagesbericht Sicherungen'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Erfolgsquote')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Die ungemessene Quote steht als „nicht gemessen" da — mit Begruendung.
|
||||||
|
expect(screen.getByText('nicht gemessen')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('Im gewaehlten Zeitraum wurde kein Lauf abgeschlossen.', {
|
||||||
|
selector: '.reports__metric-reason',
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Die gemessene Null bleibt dagegen sichtbar: „null Laeufe" ist eine
|
||||||
|
// Aussage, und sie darf nicht mit „nicht gemessen" verwechselt werden.
|
||||||
|
const measuredValue = screen.getByText('Laeufe insgesamt').nextElementSibling;
|
||||||
|
expect(measuredValue?.textContent).toBe('0');
|
||||||
|
expect(measuredValue?.className).not.toContain('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('benennt eine leere Tabelle, statt sie wegzulassen', async () => {
|
||||||
|
vi.mocked(fetch)
|
||||||
|
.mockResolvedValueOnce(buildJsonResponse(reportCatalog))
|
||||||
|
.mockResolvedValueOnce(buildJsonResponse(reportWithUnknownMetric));
|
||||||
|
|
||||||
|
render(<ReportsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Tagesbericht Sicherungen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Tagesbericht Sicherungen'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Einzelne Laeufe')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('Im gewaehlten Zeitraum wurde kein Lauf begonnen.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet einen Fehler der Ausgabe samt Vorgangskennung', async () => {
|
||||||
|
vi.mocked(fetch)
|
||||||
|
.mockResolvedValueOnce(buildJsonResponse(reportCatalog))
|
||||||
|
.mockResolvedValueOnce(buildJsonResponse(reportWithUnknownMetric))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
status: 503,
|
||||||
|
ok: false,
|
||||||
|
headers: new Headers({ 'Content-Type': 'application/json' }),
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
error: {
|
||||||
|
code: 'SERVICE_UNAVAILABLE',
|
||||||
|
message: 'Für diesen Bericht ist keine Sicherheitsprüfung eingerichtet.',
|
||||||
|
request_id: 'abc-123',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as unknown as Response);
|
||||||
|
|
||||||
|
render(<ReportsPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Tagesbericht Sicherungen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Tagesbericht Sicherungen'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('PDF herunterladen')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('PDF herunterladen'));
|
||||||
|
|
||||||
|
// Ohne die Vorgangskennung bleibt „es hat nicht funktioniert" (Phase 12).
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||||
|
'Für diesen Bericht ist keine Sicherheitsprüfung eingerichtet.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('abc-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
523
apps/web/src/features/reports/ReportsPage.tsx
Normal file
523
apps/web/src/features/reports/ReportsPage.tsx
Normal file
@ -0,0 +1,523 @@
|
|||||||
|
/**
|
||||||
|
* Berichte.
|
||||||
|
*
|
||||||
|
* Die Seite, die in Phase 12 als „noch nicht verfuegbar" im Menue stand.
|
||||||
|
*
|
||||||
|
* Zwei Dinge unterscheidet sie von einer gewoehnlichen Berichtsmaske:
|
||||||
|
*
|
||||||
|
* Sie zeigt die Vorschau **im selben Modell**, in dem der Bericht auch als CSV
|
||||||
|
* und PDF herausgeht — was hier steht, steht auch in der Datei. Und sie stellt
|
||||||
|
* eine ungemessene Kennzahl als solche dar, nicht als Null. Genau dieser
|
||||||
|
* Unterschied entscheidet, ob ein Bericht die Wahrheit sagt: „Erfolgsquote 0 %"
|
||||||
|
* meldet eine ausgefallene Sicherung, „nicht bestimmbar" meldet, dass es nichts
|
||||||
|
* zu bewerten gab.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { ApiError, downloadApiFile, requestApi } from '../../api/client';
|
||||||
|
import { useApiResource } from '../../api/useApiResource';
|
||||||
|
import { ErrorState, LoadingState } from '../../components/PageState';
|
||||||
|
|
||||||
|
/** Art des Zeitbezugs eines Berichts. */
|
||||||
|
type PeriodKind = 'range' | 'point_in_time';
|
||||||
|
|
||||||
|
/** Ausgabeformat. */
|
||||||
|
type ReportFormat = 'json' | 'csv' | 'pdf';
|
||||||
|
|
||||||
|
/** Eintrag des Berichtskatalogs. */
|
||||||
|
interface ReportCatalogEntry {
|
||||||
|
/** Maschinenlesbarer Bezeichner. */
|
||||||
|
readonly type: string;
|
||||||
|
/** Bezeichnung. */
|
||||||
|
readonly title: string;
|
||||||
|
/** Erklaert, welche Frage der Bericht beantwortet. */
|
||||||
|
readonly description: string;
|
||||||
|
/** Art des Zeitbezugs. */
|
||||||
|
readonly period_kind: PeriodKind;
|
||||||
|
/** Beschreibung des Standardzeitraums. */
|
||||||
|
readonly default_period?: string;
|
||||||
|
/** Verfuegbare Ausgabeformate. */
|
||||||
|
readonly formats: readonly ReportFormat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Kennzahl eines Berichts. */
|
||||||
|
interface ReportMetric {
|
||||||
|
/** Beschriftung. */
|
||||||
|
readonly label: string;
|
||||||
|
/** Gemessener Wert; nur gueltig, wenn is_known gilt. */
|
||||||
|
readonly value?: number;
|
||||||
|
/** Einheit. */
|
||||||
|
readonly unit?: string;
|
||||||
|
/** Nicht numerische Angabe. */
|
||||||
|
readonly text?: string;
|
||||||
|
/** Meldet, ob der Wert gemessen wurde. */
|
||||||
|
readonly is_known: boolean;
|
||||||
|
/** Erklaert einen fehlenden Wert. */
|
||||||
|
readonly unknown_reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Tabelle eines Berichts. */
|
||||||
|
interface ReportTable {
|
||||||
|
/** Ueberschrift. */
|
||||||
|
readonly title: string;
|
||||||
|
/** Spaltenbeschriftungen. */
|
||||||
|
readonly columns: readonly string[];
|
||||||
|
/** Zeilen. */
|
||||||
|
readonly rows: readonly (readonly string[])[];
|
||||||
|
/** Hinweis anstelle einer leeren Tabelle. */
|
||||||
|
readonly empty_notice?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Abschnitt eines Berichts. */
|
||||||
|
interface ReportSection {
|
||||||
|
/** Ueberschrift. */
|
||||||
|
readonly title: string;
|
||||||
|
/** Erklaerung. */
|
||||||
|
readonly description?: string;
|
||||||
|
/** Kennzahlen. */
|
||||||
|
readonly metrics?: readonly ReportMetric[];
|
||||||
|
/** Tabellen. */
|
||||||
|
readonly tables?: readonly ReportTable[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein fertiger Bericht. */
|
||||||
|
interface GeneratedReport {
|
||||||
|
/** Reportart. */
|
||||||
|
readonly type: string;
|
||||||
|
/** Bezeichnung. */
|
||||||
|
readonly title: string;
|
||||||
|
/** Erklaerung. */
|
||||||
|
readonly description: string;
|
||||||
|
/** Beginn des Zeitraums. */
|
||||||
|
readonly period_from: string;
|
||||||
|
/** Ende des Zeitraums. */
|
||||||
|
readonly period_to: string;
|
||||||
|
/** Erzeugungszeitpunkt. */
|
||||||
|
readonly generated_at: string;
|
||||||
|
/** Anmeldename des Anfordernden. */
|
||||||
|
readonly generated_by?: string;
|
||||||
|
/** Abschnitte. */
|
||||||
|
readonly sections: readonly ReportSection[];
|
||||||
|
/** Benannte Luecken und Einschraenkungen. */
|
||||||
|
readonly notes?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zustand einer laufenden Ausgabe. */
|
||||||
|
interface ExportState {
|
||||||
|
/** Format, das gerade erzeugt wird; null, wenn nichts laeuft. */
|
||||||
|
readonly runningFormat: ReportFormat | null;
|
||||||
|
/** Fehler der letzten Ausgabe. */
|
||||||
|
readonly exportError: ApiError | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Laedt den Berichtskatalog. */
|
||||||
|
function loadReportCatalog(abortSignal: AbortSignal): Promise<readonly ReportCatalogEntry[]> {
|
||||||
|
return requestApi<readonly ReportCatalogEntry[]>('/reports', { signal: abortSignal });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Berichtsseite. */
|
||||||
|
export function ReportsPage(): React.JSX.Element {
|
||||||
|
const catalogResource = useApiResource<readonly ReportCatalogEntry[]>(loadReportCatalog);
|
||||||
|
|
||||||
|
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||||
|
const [previewReport, setPreviewReport] = useState<GeneratedReport | null>(null);
|
||||||
|
const [exportState, setExportState] = useState<ExportState>({
|
||||||
|
runningFormat: null,
|
||||||
|
exportError: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedEntry =
|
||||||
|
catalogResource.data?.find((entry) => entry.type === selectedType) ?? null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fordert einen Bericht an.
|
||||||
|
*
|
||||||
|
* JSON wandert in die Vorschau, CSV und PDF in den Download-Ordner. Beide Wege
|
||||||
|
* erzeugen **denselben** Bericht — die Vorschau ist keine eigene Ansicht der
|
||||||
|
* Daten, sondern dieselbe Struktur in anderer Darstellung.
|
||||||
|
*/
|
||||||
|
const generateReport = useCallback(
|
||||||
|
async (reportType: string, outputFormat: ReportFormat): Promise<void> => {
|
||||||
|
setExportState({ runningFormat: outputFormat, exportError: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (outputFormat === 'json') {
|
||||||
|
const generatedReport = await requestApi<GeneratedReport>('/reports/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { type: reportType, format: 'json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
setPreviewReport(generatedReport);
|
||||||
|
setExportState({ runningFormat: null, exportError: null });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadedFile = await downloadApiFile('/reports/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { type: reportType, format: outputFormat },
|
||||||
|
});
|
||||||
|
|
||||||
|
triggerBrowserDownload(downloadedFile.blob, downloadedFile.fileName || `bericht.${outputFormat}`);
|
||||||
|
setExportState({ runningFormat: null, exportError: null });
|
||||||
|
} catch (caughtError) {
|
||||||
|
setExportState({
|
||||||
|
runningFormat: null,
|
||||||
|
exportError: caughtError instanceof ApiError ? caughtError : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectReport = useCallback(
|
||||||
|
(reportType: string): void => {
|
||||||
|
setSelectedType(reportType);
|
||||||
|
setPreviewReport(null);
|
||||||
|
setExportState({ runningFormat: null, exportError: null });
|
||||||
|
|
||||||
|
void generateReport(reportType, 'json');
|
||||||
|
},
|
||||||
|
[generateReport],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (catalogResource.loadState === 'loading') {
|
||||||
|
return <LoadingState what="die Berichte" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (catalogResource.loadState === 'failed' || catalogResource.data === null) {
|
||||||
|
// loadError kann in diesem Zweig nur dann fehlen, wenn die Nutzlast leer
|
||||||
|
// blieb — dann steht der Katalog trotzdem nicht zur Verfuegung.
|
||||||
|
return catalogResource.loadError === null ? (
|
||||||
|
<p className="page__state">Der Berichtskatalog steht nicht zur Verfügung.</p>
|
||||||
|
) : (
|
||||||
|
<ErrorState error={catalogResource.loadError} onRetry={catalogResource.reload} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page__header">
|
||||||
|
<h1 className="page__title">Berichte</h1>
|
||||||
|
<p className="page__subtitle">
|
||||||
|
Neun Berichte in drei Formaten. Was in der Vorschau steht, steht auch in der Datei —
|
||||||
|
einschließlich der Angaben, die sich nicht messen ließen.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="reports__catalog">
|
||||||
|
{catalogResource.data.map((catalogEntry) => (
|
||||||
|
<button
|
||||||
|
key={catalogEntry.type}
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
catalogEntry.type === selectedType
|
||||||
|
? 'reports__entry reports__entry--selected'
|
||||||
|
: 'reports__entry'
|
||||||
|
}
|
||||||
|
onClick={() => selectReport(catalogEntry.type)}
|
||||||
|
>
|
||||||
|
<span className="reports__entry-title">{catalogEntry.title}</span>
|
||||||
|
<span className="reports__entry-description">{catalogEntry.description}</span>
|
||||||
|
<span className="reports__entry-period">
|
||||||
|
{catalogEntry.period_kind === 'point_in_time'
|
||||||
|
? 'Zustandsbericht'
|
||||||
|
: `Zeitraum: ${catalogEntry.default_period ?? 'wählbar'}`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedEntry !== null && (
|
||||||
|
<ReportPanel
|
||||||
|
catalogEntry={selectedEntry}
|
||||||
|
previewReport={previewReport}
|
||||||
|
exportState={exportState}
|
||||||
|
onExport={(outputFormat) => void generateReport(selectedEntry.type, outputFormat)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt einen erzeugten Bericht mit seinen Ausgabemoeglichkeiten. */
|
||||||
|
function ReportPanel(properties: {
|
||||||
|
readonly catalogEntry: ReportCatalogEntry;
|
||||||
|
readonly previewReport: GeneratedReport | null;
|
||||||
|
readonly exportState: ExportState;
|
||||||
|
readonly onExport: (outputFormat: ReportFormat) => void;
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const { catalogEntry, previewReport, exportState, onExport } = properties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="reports__panel">
|
||||||
|
<header className="reports__panel-header">
|
||||||
|
<h2 className="reports__panel-title">{catalogEntry.title}</h2>
|
||||||
|
<div className="reports__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--secondary"
|
||||||
|
disabled={exportState.runningFormat !== null}
|
||||||
|
onClick={() => onExport('csv')}
|
||||||
|
>
|
||||||
|
{exportState.runningFormat === 'csv' ? 'CSV wird erzeugt …' : 'CSV herunterladen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button--secondary"
|
||||||
|
disabled={exportState.runningFormat !== null}
|
||||||
|
onClick={() => onExport('pdf')}
|
||||||
|
>
|
||||||
|
{exportState.runningFormat === 'pdf' ? 'PDF wird erzeugt …' : 'PDF herunterladen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{exportState.exportError !== null && (
|
||||||
|
<p className="reports__error" role="alert">
|
||||||
|
{exportState.exportError.message}
|
||||||
|
<span className="reports__request-id">
|
||||||
|
Vorgang {exportState.exportError.requestId}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewReport === null ? (
|
||||||
|
<LoadingState what="den Bericht" />
|
||||||
|
) : (
|
||||||
|
<ReportPreview report={previewReport} />
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stellt einen Bericht dar. */
|
||||||
|
function ReportPreview(properties: { readonly report: GeneratedReport }): React.JSX.Element {
|
||||||
|
const { report } = properties;
|
||||||
|
|
||||||
|
// Ein Zustandsbericht hat keinen Zeitraum. Zwei gleiche Zeitpunkte
|
||||||
|
// auszuweisen sähe nach einem Fehler aus.
|
||||||
|
const isPointInTime = report.period_from === report.period_to;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="reports__preview">
|
||||||
|
<p className="reports__meta">
|
||||||
|
{isPointInTime
|
||||||
|
? `Zustand zum ${formatTimestamp(report.period_to)}`
|
||||||
|
: `Zeitraum ${formatTimestamp(report.period_from)} bis ${formatTimestamp(report.period_to)}`}
|
||||||
|
{' · '}
|
||||||
|
Erzeugt am {formatTimestamp(report.generated_at)}
|
||||||
|
{report.generated_by !== undefined && report.generated_by !== '' && ` von ${report.generated_by}`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{report.sections.map((reportSection) => (
|
||||||
|
<section key={reportSection.title} className="reports__section">
|
||||||
|
<h3 className="reports__section-title">{reportSection.title}</h3>
|
||||||
|
{reportSection.description !== undefined && (
|
||||||
|
<p className="reports__section-description">{reportSection.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reportSection.metrics !== undefined && reportSection.metrics.length > 0 && (
|
||||||
|
<dl className="reports__metrics">
|
||||||
|
{reportSection.metrics.map((reportMetric) => (
|
||||||
|
<div key={reportMetric.label} className="reports__metric">
|
||||||
|
<dt className="reports__metric-label">{reportMetric.label}</dt>
|
||||||
|
<dd
|
||||||
|
className={
|
||||||
|
reportMetric.is_known
|
||||||
|
? 'reports__metric-value'
|
||||||
|
: 'reports__metric-value reports__metric-value--unknown'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{formatMetricValue(reportMetric)}
|
||||||
|
{!reportMetric.is_known && reportMetric.unknown_reason !== undefined && (
|
||||||
|
<span className="reports__metric-reason">{reportMetric.unknown_reason}</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reportSection.tables?.map((reportTable) => (
|
||||||
|
<ReportTableView key={reportTable.title} table={reportTable} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{report.notes !== undefined && report.notes.length > 0 && (
|
||||||
|
<section className="reports__notes">
|
||||||
|
<h3 className="reports__section-title">Hinweise</h3>
|
||||||
|
<ul className="reports__note-list">
|
||||||
|
{report.notes.map((note) => (
|
||||||
|
<li key={note}>{note}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zahl der Zeilen, die in der Vorschau gezeigt werden. */
|
||||||
|
const previewRowLimit = 25;
|
||||||
|
|
||||||
|
/** Stellt eine Tabelle dar. */
|
||||||
|
function ReportTableView(properties: { readonly table: ReportTable }): React.JSX.Element {
|
||||||
|
const { table } = properties;
|
||||||
|
|
||||||
|
if (table.rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="reports__table">
|
||||||
|
<h4 className="reports__table-title">{table.title}</h4>
|
||||||
|
<p className="reports__empty">{table.empty_notice ?? 'Keine Einträge.'}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleRows = table.rows.slice(0, previewRowLimit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="reports__table">
|
||||||
|
<h4 className="reports__table-title">{table.title}</h4>
|
||||||
|
<div className="reports__table-scroll">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{table.columns.map((columnLabel) => (
|
||||||
|
<th key={columnLabel}>{columnLabel}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{/*
|
||||||
|
Die Position ist der Schluessel: Eine Berichtszeile traegt keine
|
||||||
|
Kennung, und zwei Zeilen koennen Zeichen fuer Zeichen gleich sein.
|
||||||
|
Unbedenklich, weil die Tabelle statisch ist — sie wird weder
|
||||||
|
umsortiert noch ergaenzt, sondern mit dem Bericht neu aufgebaut.
|
||||||
|
*/}
|
||||||
|
<tbody>
|
||||||
|
{visibleRows.map((tableRow, rowIndex) => (
|
||||||
|
<tr key={rowIndex}>
|
||||||
|
{tableRow.map((cellValue, cellIndex) => (
|
||||||
|
<td key={cellIndex}>{cellValue}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{table.rows.length > visibleRows.length && (
|
||||||
|
<p className="reports__truncation">
|
||||||
|
Die Vorschau zeigt {visibleRows.length} von {table.rows.length} Zeilen. Die
|
||||||
|
heruntergeladene Datei enthält alle.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt den Wert einer Kennzahl dar.
|
||||||
|
*
|
||||||
|
* Eine ungemessene Kennzahl zeigt „nicht gemessen", keine Null. Der Unterschied
|
||||||
|
* ist der Kern des ganzen Berichtswesens.
|
||||||
|
*/
|
||||||
|
function formatMetricValue(reportMetric: ReportMetric): string {
|
||||||
|
if (!reportMetric.is_known) {
|
||||||
|
return 'nicht gemessen';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reportMetric.text !== undefined && reportMetric.text !== '') {
|
||||||
|
return reportMetric.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericValue = reportMetric.value ?? 0;
|
||||||
|
|
||||||
|
switch (reportMetric.unit) {
|
||||||
|
case 'bytes':
|
||||||
|
return formatBytes(numericValue);
|
||||||
|
case 'seconds':
|
||||||
|
return formatDuration(numericValue);
|
||||||
|
case 'percent':
|
||||||
|
return formatPercent(numericValue);
|
||||||
|
case 'count':
|
||||||
|
return numericValue.toLocaleString('de-DE');
|
||||||
|
default:
|
||||||
|
return numericValue.toLocaleString('de-DE');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stellt eine Datenmenge lesbar dar. */
|
||||||
|
function formatBytes(byteCount: number): string {
|
||||||
|
const unitNames = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
|
||||||
|
let scaledValue = byteCount;
|
||||||
|
let unitIndex = 0;
|
||||||
|
|
||||||
|
while (scaledValue >= 1024 && unitIndex < unitNames.length - 1) {
|
||||||
|
scaledValue /= 1024;
|
||||||
|
unitIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${scaledValue.toLocaleString('de-DE', { maximumFractionDigits: 1 })} ${unitNames[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stellt eine Dauer lesbar dar. */
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
if (seconds < 1) {
|
||||||
|
return `${Math.round(seconds * 1000)} ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${seconds.toLocaleString('de-DE', { maximumFractionDigits: 1 })} s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seconds < 3600) {
|
||||||
|
return `${Math.floor(seconds / 60)} min ${Math.round(seconds % 60)} s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seconds < 86400) {
|
||||||
|
return `${Math.floor(seconds / 3600)} h ${Math.round((seconds % 3600) / 60)} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.floor(seconds / 86400)} Tage ${Math.round((seconds % 86400) / 3600)} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt einen Anteil dar.
|
||||||
|
*
|
||||||
|
* Ein kleiner Wert erscheint als „< 0,1 %", nicht als „0,0 %": Null Prozent
|
||||||
|
* liest sich wie „nichts vorhanden" (dieselbe Regel wie in der Uebersicht).
|
||||||
|
*/
|
||||||
|
function formatPercent(percentValue: number): string {
|
||||||
|
if (percentValue > 0 && percentValue < 0.1) {
|
||||||
|
return '< 0,1 %';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${percentValue.toLocaleString('de-DE', { maximumFractionDigits: 1 })} %`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stellt einen Zeitpunkt in Ortszeit dar. */
|
||||||
|
function formatTimestamp(isoTimestamp: string): string {
|
||||||
|
return new Date(isoTimestamp).toLocaleString('de-DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loest den Browser-Download einer Datei aus.
|
||||||
|
*
|
||||||
|
* Die Objekt-URL wird unmittelbar wieder freigegeben. Ohne das haelt der Browser
|
||||||
|
* jeden erzeugten Bericht im Speicher, bis die Seite neu geladen wird — bei
|
||||||
|
* einem Monatsbericht ueber eine grosse Anlage sind das schnell einige Megabyte
|
||||||
|
* je Klick.
|
||||||
|
*/
|
||||||
|
function triggerBrowserDownload(fileBlob: Blob, fileName: string): void {
|
||||||
|
const objectUrl = URL.createObjectURL(fileBlob);
|
||||||
|
const downloadLink = document.createElement('a');
|
||||||
|
|
||||||
|
downloadLink.href = objectUrl;
|
||||||
|
downloadLink.download = fileName;
|
||||||
|
document.body.appendChild(downloadLink);
|
||||||
|
downloadLink.click();
|
||||||
|
document.body.removeChild(downloadLink);
|
||||||
|
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
305
apps/web/src/features/security/SecurityPage.tsx
Normal file
305
apps/web/src/features/security/SecurityPage.tsx
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
/**
|
||||||
|
* Security Center.
|
||||||
|
*
|
||||||
|
* Die Seite, die in Phase 12 als „noch nicht verfuegbar" stand — mit dem
|
||||||
|
* Hinweis, die Einzelangaben seien vorhanden, aber keine Gesamtbewertung. Jetzt
|
||||||
|
* gibt es beides.
|
||||||
|
*
|
||||||
|
* Der Aufbau folgt der Regel aus dem Plan (§17): Jeder Befund traegt
|
||||||
|
* Schweregrad, Erklaerung, betroffenes Objekt und Empfehlung. Die Empfehlung ist
|
||||||
|
* der wichtigste Teil — ein Befund ohne sie ist eine Beunruhigung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { requestApi } from '../../api/client';
|
||||||
|
import { useApiResource } from '../../api/useApiResource';
|
||||||
|
import { ErrorState, LoadingState } from '../../components/PageState';
|
||||||
|
|
||||||
|
/** Schweregrad eines Befunds. */
|
||||||
|
type FindingSeverity = 'information' | 'warning' | 'high' | 'critical';
|
||||||
|
|
||||||
|
/** Ein Sicherheitsbefund. */
|
||||||
|
interface SecurityFinding {
|
||||||
|
/** Pruefbereich. */
|
||||||
|
readonly area: string;
|
||||||
|
/** Schweregrad. */
|
||||||
|
readonly severity: FindingSeverity;
|
||||||
|
/** Ueberschrift. */
|
||||||
|
readonly title: string;
|
||||||
|
/** Erklaerung, warum der Zustand ein Problem ist. */
|
||||||
|
readonly explanation: string;
|
||||||
|
/** Das betroffene Objekt. */
|
||||||
|
readonly affected_object: string;
|
||||||
|
/** Die naechste Handlung. */
|
||||||
|
readonly recommendation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ergebnis eines Pruefbereichs. */
|
||||||
|
interface AreaResult {
|
||||||
|
/** Bezeichner. */
|
||||||
|
readonly area: string;
|
||||||
|
/** Bezeichnung. */
|
||||||
|
readonly title: string;
|
||||||
|
/** Meldet, ob der Bereich geprueft werden konnte. */
|
||||||
|
readonly available: boolean;
|
||||||
|
/** Erklaert einen nicht pruefbaren Bereich. */
|
||||||
|
readonly unavailable_reason?: string;
|
||||||
|
/** Befunde des Bereichs. */
|
||||||
|
readonly findings: readonly SecurityFinding[];
|
||||||
|
/** Zusammenfassung. */
|
||||||
|
readonly summary: string;
|
||||||
|
/** Gewicht in der Gesamtbewertung. */
|
||||||
|
readonly weight: number;
|
||||||
|
/** Erreichte Punkte. */
|
||||||
|
readonly earned_points: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die Sicherheitslage. */
|
||||||
|
interface SecurityAssessment {
|
||||||
|
/** Gesamtbewertung in Prozent. */
|
||||||
|
readonly score: number;
|
||||||
|
/** Erreichbare Punkte der geprueften Bereiche. */
|
||||||
|
readonly maximum_score: number;
|
||||||
|
/** Einstufung. */
|
||||||
|
readonly grade: string;
|
||||||
|
/** Ergebnisse je Bereich. */
|
||||||
|
readonly areas: readonly AreaResult[];
|
||||||
|
/** Zahl nicht pruefbarer Bereiche. */
|
||||||
|
readonly unchecked_area_count: number;
|
||||||
|
/** Zahl kritischer Befunde. */
|
||||||
|
readonly critical_finding_count: number;
|
||||||
|
/** Zahl ernster Befunde. */
|
||||||
|
readonly high_finding_count: number;
|
||||||
|
/** Zusammenfassung. */
|
||||||
|
readonly summary: string;
|
||||||
|
/** Nicht gepruefte Bereiche. */
|
||||||
|
readonly unchecked_areas: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Antwort des Security Center. */
|
||||||
|
interface SecurityResponse {
|
||||||
|
/** Die Sicherheitslage. */
|
||||||
|
readonly assessment: SecurityAssessment;
|
||||||
|
/** Meldet eine belastbare Bewertung. */
|
||||||
|
readonly is_trustworthy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt das Security Center. */
|
||||||
|
export function SecurityPage(): React.JSX.Element {
|
||||||
|
const loadAssessment = useCallback(
|
||||||
|
(abortSignal: AbortSignal) => requestApi<SecurityResponse>('/security', { signal: abortSignal }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { loadState, data, loadError, reload } = useApiResource<SecurityResponse>(loadAssessment);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page__header">
|
||||||
|
<h1 className="page__title">Sicherheit</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loadState === 'loading' && <LoadingState what="Die Sicherheitslage" />}
|
||||||
|
{loadState === 'failed' && loadError !== null && (
|
||||||
|
<ErrorState error={loadError} onRetry={reload} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadState === 'loaded' && data !== null && (
|
||||||
|
<>
|
||||||
|
<ScoreHeader response={data} />
|
||||||
|
<FindingList assessment={data.assessment} />
|
||||||
|
<AreaTable assessment={data.assessment} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Gesamtbewertung. */
|
||||||
|
interface ScoreHeaderProperties {
|
||||||
|
/** Die Antwort des Security Center. */
|
||||||
|
readonly response: SecurityResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Gesamtbewertung. */
|
||||||
|
function ScoreHeader({ response }: ScoreHeaderProperties): React.JSX.Element {
|
||||||
|
const { assessment } = response;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`score-card score-card--${gradeClassOf(assessment)}`}>
|
||||||
|
<div className="score-card__figure">
|
||||||
|
<span className="score-card__value">{assessment.score}</span>
|
||||||
|
<span className="score-card__unit">%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="score-card__text">
|
||||||
|
<p className="score-card__grade">{assessment.grade}</p>
|
||||||
|
<p className="score-card__summary">{assessment.summary}</p>
|
||||||
|
|
||||||
|
{/* Die nicht gepruefte Bereiche stehen namentlich da: Sie sind die
|
||||||
|
Handlungsanweisung, nicht nur eine Einschraenkung. */}
|
||||||
|
{assessment.unchecked_areas.length > 0 && (
|
||||||
|
<p className="score-card__unchecked">
|
||||||
|
Nicht geprueft: {assessment.unchecked_areas.join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Befundliste. */
|
||||||
|
interface FindingListProperties {
|
||||||
|
/** Die Sicherheitslage. */
|
||||||
|
readonly assessment: SecurityAssessment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Befunde nach Schweregrad geordnet. */
|
||||||
|
function FindingList({ assessment }: FindingListProperties): React.JSX.Element {
|
||||||
|
const allFindings = assessment.areas.flatMap((areaResult) => areaResult.findings);
|
||||||
|
|
||||||
|
const orderedFindings = [...allFindings].sort(
|
||||||
|
(firstFinding, secondFinding) =>
|
||||||
|
severityRankOf(secondFinding.severity) - severityRankOf(firstFinding.severity),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orderedFindings.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="page__state page__state--empty">
|
||||||
|
Keine Befunde in den geprueften Bereichen.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="finding-list">
|
||||||
|
{orderedFindings.map((finding) => (
|
||||||
|
<li className={`finding finding--${finding.severity}`} key={`${finding.area}-${finding.title}`}>
|
||||||
|
<div className="finding__head">
|
||||||
|
<span className={`badge badge--${severityClassOf(finding.severity)}`}>
|
||||||
|
{finding.severity}
|
||||||
|
</span>
|
||||||
|
<h2 className="finding__title">{finding.title}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="finding__explanation">{finding.explanation}</p>
|
||||||
|
|
||||||
|
<dl className="finding__details">
|
||||||
|
<div className="finding__detail">
|
||||||
|
<dt>Betroffen</dt>
|
||||||
|
<dd>{finding.affected_object}</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum der
|
||||||
|
Befund ueberhaupt angezeigt wird. */}
|
||||||
|
<div className="finding__detail finding__detail--recommendation">
|
||||||
|
<dt>Naechster Schritt</dt>
|
||||||
|
<dd>{finding.recommendation}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften der Bereichsuebersicht. */
|
||||||
|
interface AreaTableProperties {
|
||||||
|
/** Die Sicherheitslage. */
|
||||||
|
readonly assessment: SecurityAssessment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Ergebnisse je Pruefbereich. */
|
||||||
|
function AreaTable({ assessment }: AreaTableProperties): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<section className="area-overview">
|
||||||
|
<h2 className="area-overview__title">Geprueft wurde</h2>
|
||||||
|
|
||||||
|
<div className="table-wrapper">
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Bereich</th>
|
||||||
|
<th scope="col">Ergebnis</th>
|
||||||
|
<th scope="col">Punkte</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{assessment.areas.map((areaResult) => (
|
||||||
|
<tr key={areaResult.area}>
|
||||||
|
<td>{areaResult.title}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
{areaResult.available ? (
|
||||||
|
areaResult.summary
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="badge badge--neutral">nicht pruefbar</span>
|
||||||
|
<span className="data-table__secondary">{areaResult.unavailable_reason}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="data-table__number">
|
||||||
|
{areaResult.available ? (
|
||||||
|
`${areaResult.earned_points} / ${areaResult.weight}`
|
||||||
|
) : (
|
||||||
|
// Ein ungeprueft Bereich geht nicht in die Rechnung ein —
|
||||||
|
// weder positiv noch negativ.
|
||||||
|
<span className="data-table__unknown">zaehlt nicht</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bildet die Einstufung auf eine Statusfarbe ab. */
|
||||||
|
function gradeClassOf(assessment: SecurityAssessment): string {
|
||||||
|
if (assessment.critical_finding_count > 0) {
|
||||||
|
return 'critical';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assessment.score >= 90) {
|
||||||
|
return 'healthy';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assessment.score >= 70) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'high';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bildet einen Schweregrad auf eine Statusfarbe ab. */
|
||||||
|
function severityClassOf(severity: FindingSeverity): string {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical':
|
||||||
|
return 'critical';
|
||||||
|
case 'high':
|
||||||
|
return 'high';
|
||||||
|
case 'warning':
|
||||||
|
return 'warning';
|
||||||
|
default:
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liefert die Ordnungszahl eines Schweregrads. */
|
||||||
|
function severityRankOf(severity: FindingSeverity): number {
|
||||||
|
switch (severity) {
|
||||||
|
case 'critical':
|
||||||
|
return 4;
|
||||||
|
case 'high':
|
||||||
|
return 3;
|
||||||
|
case 'warning':
|
||||||
|
return 2;
|
||||||
|
default:
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/web/src/main.tsx
Normal file
20
apps/web/src/main.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/** Einstiegspunkt der Syncova-Oberflaeche. */
|
||||||
|
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
import './styles/tokens.css';
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
|
||||||
|
// Fehlt der Wurzelknoten, ist das Dokument fehlerhaft ausgeliefert worden.
|
||||||
|
// Ein stiller Abbruch wuerde nur eine leere Seite hinterlassen.
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error('Das Wurzelelement #root wurde im Dokument nicht gefunden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
108
apps/web/src/navigation/NavigationSidebar.tsx
Normal file
108
apps/web/src/navigation/NavigationSidebar.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Seitenleiste mit der Bereichsnavigation.
|
||||||
|
*
|
||||||
|
* Eintraege ohne Backend erscheinen deaktiviert statt versteckt: Wer die Anlage
|
||||||
|
* bedient, soll den Ausbaustand sehen — und beim Klick erfahren, was fehlt,
|
||||||
|
* statt in eine leere Maske zu laufen (PROMPT.md §139).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ALL_PAGES, SECTION_LABELS, SECTION_ORDER, mayViewPage } from './pages';
|
||||||
|
import type { PageDefinition } from './pages';
|
||||||
|
|
||||||
|
/** Eigenschaften der Seitenleiste. */
|
||||||
|
interface NavigationSidebarProperties {
|
||||||
|
/** Bezeichner der angezeigten Seite. */
|
||||||
|
readonly currentPageId: string;
|
||||||
|
/** Berechtigungen des angemeldeten Benutzers. */
|
||||||
|
readonly grantedPermissions: readonly string[];
|
||||||
|
/** Wird beim Wechsel auf eine andere Seite gerufen. */
|
||||||
|
readonly onNavigate: (pageIdentifier: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt die Bereichsnavigation. */
|
||||||
|
export function NavigationSidebar({
|
||||||
|
currentPageId,
|
||||||
|
grantedPermissions,
|
||||||
|
onNavigate,
|
||||||
|
}: NavigationSidebarProperties): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<nav className="navigation" aria-label="Bereiche">
|
||||||
|
{SECTION_ORDER.map((sectionName) => {
|
||||||
|
// Seiten, die der Benutzer ohnehin nicht aufrufen darf, werden nicht
|
||||||
|
// gezeigt. Das ist keine Sicherheitsmassnahme — die liegt auf dem Server
|
||||||
|
// — sondern verhindert eine Oberflaeche voller Sackgassen.
|
||||||
|
const visiblePages = ALL_PAGES.filter(
|
||||||
|
(pageDefinition) =>
|
||||||
|
pageDefinition.section === sectionName && mayViewPage(pageDefinition, grantedPermissions),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (visiblePages.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="navigation__section" key={sectionName}>
|
||||||
|
<h2 className="navigation__section-title">{SECTION_LABELS[sectionName]}</h2>
|
||||||
|
|
||||||
|
<ul className="navigation__list">
|
||||||
|
{visiblePages.map((pageDefinition) => (
|
||||||
|
<li key={pageDefinition.id}>
|
||||||
|
<NavigationEntry
|
||||||
|
pageDefinition={pageDefinition}
|
||||||
|
isCurrent={pageDefinition.id === currentPageId}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eigenschaften eines Navigationseintrags. */
|
||||||
|
interface NavigationEntryProperties {
|
||||||
|
/** Die dargestellte Seite. */
|
||||||
|
readonly pageDefinition: PageDefinition;
|
||||||
|
/** Meldet die derzeit angezeigte Seite. */
|
||||||
|
readonly isCurrent: boolean;
|
||||||
|
/** Wird beim Wechsel gerufen. */
|
||||||
|
readonly onNavigate: (pageIdentifier: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt einen einzelnen Navigationseintrag. */
|
||||||
|
function NavigationEntry({
|
||||||
|
pageDefinition,
|
||||||
|
isCurrent,
|
||||||
|
onNavigate,
|
||||||
|
}: NavigationEntryProperties): React.JSX.Element {
|
||||||
|
const entryClassNames = ['navigation__entry'];
|
||||||
|
|
||||||
|
if (isCurrent) {
|
||||||
|
entryClassNames.push('navigation__entry--current');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pageDefinition.available) {
|
||||||
|
entryClassNames.push('navigation__entry--unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={entryClassNames.join(' ')}
|
||||||
|
type="button"
|
||||||
|
aria-current={isCurrent ? 'page' : undefined}
|
||||||
|
onClick={() => onNavigate(pageDefinition.id)}
|
||||||
|
>
|
||||||
|
<span className="navigation__entry-label">{pageDefinition.label}</span>
|
||||||
|
|
||||||
|
{/* Der Vermerk steht am Eintrag, nicht nur auf der Seite dahinter: Sonst
|
||||||
|
muesste man jeden Bereich einzeln anklicken, um den Ausbaustand zu
|
||||||
|
erkennen. */}
|
||||||
|
{!pageDefinition.available && (
|
||||||
|
<span className="navigation__entry-badge">noch nicht verfuegbar</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/web/src/navigation/UnavailablePage.tsx
Normal file
37
apps/web/src/navigation/UnavailablePage.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Seite fuer einen Bereich ohne Backend.
|
||||||
|
*
|
||||||
|
* Sie ist keine Fehlermeldung und keine Entschuldigung, sondern eine Auskunft:
|
||||||
|
* Was fehlt, und wie kommt man heute an dieselbe Information? Eine Seite, die
|
||||||
|
* nur „in Arbeit" sagt, laesst den Bediener ratlos zurueck — und eine, die eine
|
||||||
|
* leere Tabelle zeigt, laesst ihn glauben, es gaebe nichts zu sehen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PageDefinition } from './pages';
|
||||||
|
|
||||||
|
/** Eigenschaften der Hinweisseite. */
|
||||||
|
interface UnavailablePageProperties {
|
||||||
|
/** Die betroffene Seite. */
|
||||||
|
readonly pageDefinition: PageDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeigt den Ausbaustand eines Bereichs. */
|
||||||
|
export function UnavailablePage({ pageDefinition }: UnavailablePageProperties): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<section className="page">
|
||||||
|
<header className="page__header">
|
||||||
|
<h1 className="page__title">{pageDefinition.label}</h1>
|
||||||
|
<span className="page__badge">noch nicht verfuegbar</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="notice notice--information">
|
||||||
|
<p className="notice__text">{pageDefinition.unavailableReason}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="page__hint">
|
||||||
|
Dieser Bereich wird bewusst leer gezeigt statt mit Beispieldaten gefuellt. Eine Maske mit
|
||||||
|
erfundenen Zahlen liesse sich im Betrieb nicht von einer echten unterscheiden.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
apps/web/src/navigation/pages.test.ts
Normal file
75
apps/web/src/navigation/pages.test.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Tests des Seitenverzeichnisses.
|
||||||
|
*
|
||||||
|
* Sie pruefen weniger die Technik als eine Zusage: Jede Seite aus dem
|
||||||
|
* Implementierungsplan erscheint, und jede unfertige erklaert sich.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ALL_PAGES, findPage, mayViewPage } from './pages';
|
||||||
|
|
||||||
|
describe('Seitenverzeichnis', () => {
|
||||||
|
it('nennt zu jeder nicht verfuegbaren Seite einen Grund', () => {
|
||||||
|
// Ein Bereich, der nur „nicht verfuegbar" sagt, laesst den Bediener ratlos
|
||||||
|
// zurueck. Der Grund ist der eigentliche Inhalt dieser Seiten.
|
||||||
|
for (const pageDefinition of ALL_PAGES) {
|
||||||
|
if (!pageDefinition.available) {
|
||||||
|
expect(pageDefinition.unavailableReason, `Seite ${pageDefinition.id}`).toBeTruthy();
|
||||||
|
expect(pageDefinition.unavailableReason?.length ?? 0).toBeGreaterThan(40);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt keiner verfuegbaren Seite einen Nichtverfuegbarkeitsgrund', () => {
|
||||||
|
for (const pageDefinition of ALL_PAGES) {
|
||||||
|
if (pageDefinition.available) {
|
||||||
|
expect(pageDefinition.unavailableReason, `Seite ${pageDefinition.id}`).toBeUndefined();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vergibt jeden Bezeichner nur einmal', () => {
|
||||||
|
const pageIdentifiers = ALL_PAGES.map((pageDefinition) => pageDefinition.id);
|
||||||
|
|
||||||
|
expect(new Set(pageIdentifiers).size).toBe(pageIdentifiers.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deckt die im Implementierungsplan genannten Bereiche ab', () => {
|
||||||
|
// Der Plan (§14) nennt fuenfzehn Seiten. Fehlt eine davon im Verzeichnis,
|
||||||
|
// ist sie nicht „noch nicht gebaut", sondern vergessen — und niemand sieht
|
||||||
|
// es.
|
||||||
|
const expectedIdentifiers = [
|
||||||
|
'dashboard',
|
||||||
|
'jobs',
|
||||||
|
'protected-systems',
|
||||||
|
'restores',
|
||||||
|
'recovery-points',
|
||||||
|
'repositories',
|
||||||
|
'proxmox',
|
||||||
|
'agents',
|
||||||
|
'alerts',
|
||||||
|
'events',
|
||||||
|
'security',
|
||||||
|
'reports',
|
||||||
|
'users',
|
||||||
|
'roles',
|
||||||
|
'settings',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const expectedIdentifier of expectedIdentifiers) {
|
||||||
|
expect(findPage(expectedIdentifier), `Seite ${expectedIdentifier}`).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verbirgt Seiten ohne die noetige Berechtigung', () => {
|
||||||
|
const usersPage = findPage('users');
|
||||||
|
expect(usersPage).toBeDefined();
|
||||||
|
|
||||||
|
if (usersPage === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mayViewPage(usersPage, ['jobs.read'])).toBe(false);
|
||||||
|
expect(mayViewPage(usersPage, ['users.read'])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user