syncova-backup/apps/api/internal/httpapi/restore_handler.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
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>
2026-08-17 09:10:54 +02:00

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