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>
649 lines
25 KiB
Go
649 lines
25 KiB
Go
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)
|
|
}
|
|
}
|