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>
944 lines
37 KiB
Go
944 lines
37 KiB
Go
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
|
|
}
|