syncova-backup/apps/api/internal/httpapi/job_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

783 lines
30 KiB
Go

package httpapi
import (
"errors"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/audit"
"github.com/syncova/syncova/packages/auth"
"github.com/syncova/syncova/packages/jobs"
"github.com/syncova/syncova/packages/platform/logging"
)
// jobHandler bedient die Sicherungsaufträge (SYNCOVA_API.md §9).
type jobHandler struct {
// store ist die Datenzugriffsschicht der Aufträge.
store *jobs.PostgresStore
// auditRecorder protokolliert Änderungen an Aufträgen.
auditRecorder audit.Recorder
// logger protokolliert technische Fehler.
logger *slog.Logger
}
// scheduleRequest beschreibt einen Zeitplan im Anfragerumpf.
//
// Bewusst eine eigene Struktur statt scheduler.Schedule: Die API-Gestalt darf
// sich nicht mitverändern, wenn ein internes Feld umbenannt wird. Der Vertrag
// nach außen ist stabiler als der Code dahinter.
type scheduleRequest struct {
// Type ist die Art des Zeitplans.
Type string `json:"type"`
// Interval ist der Abstand in Sekunden bei type=interval.
IntervalSeconds int64 `json:"interval_seconds,omitempty"`
// Time ist die Uhrzeit im Format "HH:MM".
//
// Eine Zeichenkette statt zweier Zahlen, weil SYNCOVA_API.md §9 sie so
// vorgibt und weil "02:00" für einen Anwender lesbar ist.
Time string `json:"time,omitempty"`
// Weekdays sind die Wochentage (0 = Sonntag).
Weekdays []int `json:"weekdays,omitempty"`
// MonthDays sind die Tage des Monats; -1 bedeutet Monatsletzter.
MonthDays []int `json:"month_days,omitempty"`
// CronExpression ist der Ausdruck bei type=cron.
CronExpression string `json:"cron_expression,omitempty"`
// TimeZone ist die Zeitzone der Uhrzeiten.
TimeZone string `json:"time_zone,omitempty"`
}
// sourceRequest beschreibt eine Quelle im Anfragerumpf.
type sourceRequest struct {
// Type ist die Art der Quelle.
Type string `json:"type"`
// ID ist die Kennung innerhalb ihrer Art.
ID string `json:"id"`
// Name ist die sprechende Bezeichnung.
Name string `json:"name,omitempty"`
// AgentID ist der ausführende Agent.
AgentID *uuid.UUID `json:"agent_id,omitempty"`
// IncludePatterns beschränken die Erfassung.
IncludePatterns []string `json:"include_patterns,omitempty"`
// ExcludePatterns nehmen Pfade aus.
ExcludePatterns []string `json:"exclude_patterns,omitempty"`
}
// jobRequest ist der Rumpf von POST und PATCH auf /jobs.
type jobRequest struct {
// Name ist die eindeutige Bezeichnung.
Name string `json:"name"`
// Description erläutert den Zweck.
Description string `json:"description,omitempty"`
// Priority ist die Dringlichkeit.
Priority string `json:"priority,omitempty"`
// Schedule ist der Zeitplan.
Schedule scheduleRequest `json:"schedule"`
// Sources sind die zu sichernden Quellen.
Sources []sourceRequest `json:"sources"`
// RepositoryID ist das Ziel-Repository.
RepositoryID uuid.UUID `json:"repository_id"`
// RetentionPolicyID ist die Aufbewahrungsregel.
RetentionPolicyID *uuid.UUID `json:"retention_policy_id,omitempty"`
// DependsOnJobIDs sind vorausgesetzte Aufträge.
DependsOnJobIDs []uuid.UUID `json:"depends_on_job_ids,omitempty"`
// RecoveryPointSeconds ist der zulässige Datenverlust in Sekunden.
RecoveryPointSeconds int64 `json:"rpo_seconds,omitempty"`
// RecoveryTimeSeconds ist die zulässige Wiederherstellungsdauer in Sekunden.
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
// MaximumConcurrency begrenzt gleichzeitige Läufe.
MaximumConcurrency int `json:"max_concurrency,omitempty"`
}
// jobResponse ist die Darstellung eines Auftrags nach außen.
type jobResponse struct {
// ID ist der öffentliche Bezeichner.
ID uuid.UUID `json:"id"`
// Name ist die Bezeichnung.
Name string `json:"name"`
// Description erläutert den Zweck.
Description string `json:"description,omitempty"`
// Status ist der Zustand.
Status string `json:"status"`
// Priority ist die Dringlichkeit.
Priority string `json:"priority"`
// Schedule ist der Zeitplan.
Schedule scheduleRequest `json:"schedule"`
// ScheduleDescription erklärt den Zeitplan in einem Satz.
//
// Ein Cron-Ausdruck sagt einem Anwender wenig; „täglich um 02:00 Uhr
// (Europe/Berlin)" dagegen alles. Die Erklärung entsteht auf dem Server,
// damit sie in Oberfläche und Benachrichtigung gleich lautet.
ScheduleDescription string `json:"schedule_description"`
// Sources sind die Quellen.
Sources []sourceRequest `json:"sources"`
// RepositoryID ist das Ziel-Repository.
RepositoryID uuid.UUID `json:"repository_id"`
// RetentionPolicyID ist die Aufbewahrungsregel.
RetentionPolicyID *uuid.UUID `json:"retention_policy_id,omitempty"`
// DependsOnJobIDs sind vorausgesetzte Aufträge.
DependsOnJobIDs []uuid.UUID `json:"depends_on_job_ids,omitempty"`
// RecoveryPointSeconds ist der zulässige Datenverlust.
RecoveryPointSeconds int64 `json:"rpo_seconds,omitempty"`
// RecoveryTimeSeconds ist die zulässige Wiederherstellungsdauer.
RecoveryTimeSeconds int64 `json:"rto_seconds,omitempty"`
// BandwidthLimitBytesPerSecond begrenzt den Durchsatz.
BandwidthLimitBytesPerSecond int64 `json:"bandwidth_limit_bps,omitempty"`
// MaximumConcurrency begrenzt gleichzeitige Läufe.
MaximumConcurrency int `json:"max_concurrency"`
// NextRunAt ist der nächste Zeitpunkt in UTC.
NextRunAt *time.Time `json:"next_run_at,omitempty"`
// LastRunAt ist der Beginn des letzten Laufs in UTC.
LastRunAt *time.Time `json:"last_run_at,omitempty"`
// LastOutcome ist der Ausgang des letzten Laufs.
LastOutcome string `json:"last_outcome,omitempty"`
// PausedAt ist der Zeitpunkt einer Aussetzung in UTC.
PausedAt *time.Time `json:"paused_at,omitempty"`
// CreatedAt ist der Anlagezeitpunkt in UTC.
CreatedAt time.Time `json:"created_at"`
// UpdatedAt ist der Zeitpunkt der letzten Änderung in UTC.
UpdatedAt time.Time `json:"updated_at"`
}
// handleListJobs bedient GET /jobs.
func (handler *jobHandler) handleListJobs(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
listFilter := jobs.ListFilter{
Status: jobs.JobStatus(request.URL.Query().Get("status")),
SearchTerm: request.URL.Query().Get("search"),
Page: parsePositiveInteger(request.URL.Query().Get("page"), 1),
PageSize: parsePositiveInteger(request.URL.Query().Get("page_size"), 50),
}
if repositoryParameter := request.URL.Query().Get("repository"); repositoryParameter != "" {
repositoryID, parseError := uuid.Parse(repositoryParameter)
if parseError != nil {
WriteError(responseWriter, request, requestLogger,
NewBadRequestError("Der Filter 'repository' ist keine gültige Kennung."))
return
}
listFilter.RepositoryID = &repositoryID
}
loadedJobs, totalCount, listError := handler.store.ListJobs(request.Context(), listFilter)
if listError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
return
}
jobResponses := make([]jobResponse, 0, len(loadedJobs))
for jobIndex := range loadedJobs {
jobResponses = append(jobResponses, buildJobResponse(&loadedJobs[jobIndex]))
}
WritePaginatedSuccess(responseWriter, request, jobResponses, PaginationMeta{
Page: listFilter.Page,
PageSize: listFilter.PageSize,
Total: int64(totalCount),
})
}
// handleCreateJob bedient POST /jobs.
func (handler *jobHandler) handleCreateJob(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
actingUser, _ := AuthenticatedUserFromContext(request.Context())
var jobPayload jobRequest
if decodeError := decodeJSONBody(request, &jobPayload); decodeError != nil {
WriteError(responseWriter, request, requestLogger, decodeError)
return
}
newJob, conversionError := buildJobFromRequest(jobPayload, actingUser.ID)
if conversionError != nil {
WriteError(responseWriter, request, requestLogger, conversionError)
return
}
// Der nächste Zeitpunkt wird sofort berechnet und mitgespeichert. Ohne ihn
// stünde der Auftrag als aktiv da, ohne je zu laufen — bis jemand ihn von
// Hand anstößt.
if nextRun, nextError := newJob.Schedule.NextRun(time.Now().UTC()); nextError == nil {
newJob.NextRunAt = &nextRun
}
createdJobID, createError := handler.store.CreateJob(request.Context(), newJob)
if createError != nil {
WriteError(responseWriter, request, requestLogger, translateJobError(createError))
return
}
handler.recordAudit(request, actingUser, audit.ActionBackupJobCreated, createdJobID, map[string]any{
"name": newJob.Name,
"schedule": newJob.Schedule.Describe(),
"source_count": len(newJob.Sources),
"repository_id": newJob.RepositoryID.String(),
})
createdJob, readError := handler.store.GetJob(request.Context(), createdJobID)
if readError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(readError))
return
}
WriteSuccess(responseWriter, request, http.StatusCreated, buildJobResponse(createdJob))
}
// handleGetJob bedient GET /jobs/{id}.
func (handler *jobHandler) handleGetJob(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
jobIdentifier, parseError := parseJobIdentifier(request)
if parseError != nil {
WriteError(responseWriter, request, requestLogger, parseError)
return
}
loadedJob, readError := handler.store.GetJob(request.Context(), jobIdentifier)
if readError != nil {
WriteError(responseWriter, request, requestLogger, translateJobError(readError))
return
}
WriteSuccess(responseWriter, request, http.StatusOK, buildJobResponse(loadedJob))
}
// handleDeleteJob bedient DELETE /jobs/{id}.
//
// Die Löschung ist weich: Die Läufe eines gelöschten Auftrags bleiben als
// Nachweis erhalten. Sie wird immer auditiert (PROMPT.md §140: destruktive
// Aktionen niemals still).
func (handler *jobHandler) handleDeleteJob(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
actingUser, _ := AuthenticatedUserFromContext(request.Context())
jobIdentifier, parseError := parseJobIdentifier(request)
if parseError != nil {
WriteError(responseWriter, request, requestLogger, parseError)
return
}
// Der Auftrag wird vor der Löschung gelesen, damit das Auditprotokoll
// festhält, was verschwunden ist. Danach wäre es nicht mehr feststellbar.
existingJob, readError := handler.store.GetJob(request.Context(), jobIdentifier)
if readError != nil {
WriteError(responseWriter, request, requestLogger, translateJobError(readError))
return
}
if deleteError := handler.store.SoftDeleteJob(request.Context(), jobIdentifier); deleteError != nil {
WriteError(responseWriter, request, requestLogger, translateJobError(deleteError))
return
}
handler.recordAudit(request, actingUser, audit.ActionBackupJobDeleted, jobIdentifier, map[string]any{
"name": existingJob.Name,
"schedule": existingJob.Schedule.Describe(),
"source_count": len(existingJob.Sources),
})
WriteSuccess(responseWriter, request, http.StatusOK, map[string]string{
"status": "deleted",
"message": "Der Auftrag wurde gelöscht. Seine bisherigen Läufe bleiben als Nachweis erhalten.",
})
}
// handlePauseJob bedient POST /jobs/{id}/pause.
func (handler *jobHandler) handlePauseJob(responseWriter http.ResponseWriter, request *http.Request) {
handler.changeJobStatus(responseWriter, request, jobs.JobStatusPaused, audit.ActionBackupJobPaused)
}
// handleResumeJob bedient POST /jobs/{id}/resume.
func (handler *jobHandler) handleResumeJob(responseWriter http.ResponseWriter, request *http.Request) {
handler.changeJobStatus(responseWriter, request, jobs.JobStatusActive, audit.ActionBackupJobResumed)
}
// changeJobStatus setzt den Zustand eines Auftrags und auditiert die Änderung.
//
// Das Aussetzen einer Sicherung ist sicherheitsrelevant: Es lässt den Schutz
// still auslaufen, ohne dass etwas kaputtgeht. Deshalb wird es protokolliert
// wie eine Löschung.
func (handler *jobHandler) changeJobStatus(responseWriter http.ResponseWriter, request *http.Request, newStatus jobs.JobStatus, auditAction audit.Action) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
actingUser, _ := AuthenticatedUserFromContext(request.Context())
jobIdentifier, parseError := parseJobIdentifier(request)
if parseError != nil {
WriteError(responseWriter, request, requestLogger, parseError)
return
}
existingJob, readError := handler.store.GetJob(request.Context(), jobIdentifier)
if readError != nil {
WriteError(responseWriter, request, requestLogger, translateJobError(readError))
return
}
if statusError := handler.store.SetJobStatus(request.Context(), jobIdentifier, newStatus, &actingUser.ID); statusError != nil {
WriteError(responseWriter, request, requestLogger, translateJobError(statusError))
return
}
// Beim Fortsetzen wird der nächste Zeitpunkt neu berechnet. Ohne diesen
// Schritt bliebe der alte stehen: Ein Auftrag, der eine Woche ausgesetzt
// war, liefe sofort los und danach zur falschen Zeit weiter.
if newStatus == jobs.JobStatusActive {
if nextRun, nextError := existingJob.Schedule.NextRun(time.Now().UTC()); nextError == nil {
if updateError := handler.store.SetNextRun(request.Context(), jobIdentifier, &nextRun); updateError != nil {
requestLogger.Warn("der nächste zeitpunkt konnte nicht gesetzt werden",
slog.String("job_id", jobIdentifier.String()),
slog.String("grund", updateError.Error()))
}
}
}
handler.recordAudit(request, actingUser, auditAction, jobIdentifier, map[string]any{
"name": existingJob.Name,
"von_status": string(existingJob.Status),
"nach_status": string(newStatus),
})
updatedJob, updateReadError := handler.store.GetJob(request.Context(), jobIdentifier)
if updateReadError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(updateReadError))
return
}
WriteSuccess(responseWriter, request, http.StatusOK, buildJobResponse(updatedJob))
}
// runResponse ist die Darstellung eines Laufs nach außen.
type runResponse struct {
// ID ist der öffentliche Bezeichner.
ID uuid.UUID `json:"id"`
// JobID ist der ausgeführte Auftrag.
JobID uuid.UUID `json:"job_id"`
// Status ist der Zustand.
Status string `json:"status"`
// Trigger benennt den Auslöser.
Trigger string `json:"trigger"`
// AttemptNumber ist die Nummer des Versuchs.
AttemptNumber int `json:"attempt_number"`
// ScheduledFor ist der geplante Zeitpunkt in UTC.
ScheduledFor *time.Time `json:"scheduled_for,omitempty"`
// StartedAt ist der Beginn in UTC.
StartedAt *time.Time `json:"started_at,omitempty"`
// CompletedAt ist das Ende in UTC.
CompletedAt *time.Time `json:"completed_at,omitempty"`
// DurationSeconds ist die Dauer in Sekunden.
DurationSeconds float64 `json:"duration_seconds,omitempty"`
// DelaySeconds ist die Verspätung gegenüber dem geplanten Zeitpunkt.
//
// Die Abweichung ist die eigentliche Auskunft: Ein Lauf, der regelmäßig
// eine Stunde zu spät beginnt, hat ein Problem, das man ohne diesen
// Vergleich nicht sieht.
DelaySeconds float64 `json:"delay_seconds,omitempty"`
// BytesProcessed ist die gelesene Datenmenge.
BytesProcessed int64 `json:"bytes_processed"`
// BytesWritten ist die abgelegte Datenmenge.
BytesWritten int64 `json:"bytes_written"`
// FilesProcessed ist die Zahl bearbeiteter Objekte.
FilesProcessed int64 `json:"files_processed"`
// FilesSkipped ist die Zahl übergangener Objekte.
FilesSkipped int64 `json:"files_skipped"`
// ErrorCode ist die Fehlerkennung.
ErrorCode string `json:"error_code,omitempty"`
// ErrorMessage ist die verständliche Fehlermeldung.
ErrorMessage string `json:"error_message,omitempty"`
// FailureClass ordnet den Fehler ein.
FailureClass string `json:"failure_class,omitempty"`
// CorrelationID verbindet den Lauf mit seinen Protokollzeilen.
CorrelationID uuid.UUID `json:"correlation_id"`
// CreatedAt ist der Anlagezeitpunkt in UTC.
CreatedAt time.Time `json:"created_at"`
}
// buildRunResponse wandelt einen Lauf in seine Darstellung.
func buildRunResponse(sourceRun *jobs.Run) runResponse {
builtResponse := runResponse{
ID: sourceRun.ID,
JobID: sourceRun.JobID,
Status: string(sourceRun.Status),
Trigger: string(sourceRun.Trigger),
AttemptNumber: sourceRun.AttemptNumber,
ScheduledFor: sourceRun.ScheduledFor,
StartedAt: sourceRun.StartedAt,
CompletedAt: sourceRun.CompletedAt,
BytesProcessed: sourceRun.BytesProcessed,
BytesWritten: sourceRun.BytesWritten,
FilesProcessed: sourceRun.FilesProcessed,
FilesSkipped: sourceRun.FilesSkipped,
ErrorCode: sourceRun.ErrorCode,
ErrorMessage: sourceRun.ErrorMessage,
FailureClass: string(sourceRun.FailureClass),
CorrelationID: sourceRun.CorrelationID,
CreatedAt: sourceRun.CreatedAt,
}
builtResponse.DurationSeconds = sourceRun.Duration().Seconds()
if sourceRun.ScheduledFor != nil && sourceRun.StartedAt != nil {
builtResponse.DelaySeconds = sourceRun.StartedAt.Sub(*sourceRun.ScheduledFor).Seconds()
}
return builtResponse
}
// handleRunJob bedient POST /jobs/{id}/run.
//
// Der Lauf wird eingereiht, nicht ausgeführt: Die Ausführungsschleife holt ihn
// im nächsten Durchgang. Der Endpunkt antwortet deshalb mit 202 statt 201 —
// die Sicherung hat noch nicht begonnen.
func (handler *jobHandler) handleRunJob(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
actingUser, _ := AuthenticatedUserFromContext(request.Context())
jobIdentifier, parseError := parseJobIdentifier(request)
if parseError != nil {
WriteError(responseWriter, request, requestLogger, parseError)
return
}
existingJob, readError := handler.store.GetJob(request.Context(), jobIdentifier)
if readError != nil {
WriteError(responseWriter, request, requestLogger, translateJobError(readError))
return
}
// Ein ausgesetzter Auftrag wird nicht heimlich reaktiviert. Wer ihn
// ausführen will, setzt ihn zuerst fort — sonst liefe er einmal und
// schwiege danach wieder, ohne dass es jemandem auffiele.
if existingJob.Status == jobs.JobStatusPaused {
WriteError(responseWriter, request, requestLogger, NewValidationError(
"Der Auftrag ist ausgesetzt. Setzen Sie ihn zuerst fort, bevor Sie ihn ausführen."))
return
}
createdRun, createError := handler.store.CreateManualRun(request.Context(), jobIdentifier, &actingUser.ID)
if createError != nil {
WriteError(responseWriter, request, requestLogger, translateJobError(createError))
return
}
handler.recordAudit(request, actingUser, audit.ActionBackupJobRunRequested, jobIdentifier, map[string]any{
"name": existingJob.Name,
"run_id": createdRun.ID.String(),
})
WriteSuccess(responseWriter, request, http.StatusAccepted, buildRunResponse(createdRun))
}
// handleListJobRuns bedient GET /jobs/{id}/runs.
func (handler *jobHandler) handleListJobRuns(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
jobIdentifier, parseError := parseJobIdentifier(request)
if parseError != nil {
WriteError(responseWriter, request, requestLogger, parseError)
return
}
if _, readError := handler.store.GetJob(request.Context(), jobIdentifier); readError != nil {
WriteError(responseWriter, request, requestLogger, translateJobError(readError))
return
}
requestedPage := parsePositiveInteger(request.URL.Query().Get("page"), 1)
requestedPageSize := parsePositiveInteger(request.URL.Query().Get("page_size"), 50)
loadedRuns, totalCount, listError := handler.store.ListRuns(request.Context(),
jobIdentifier, requestedPage, requestedPageSize)
if listError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
return
}
runResponses := make([]runResponse, 0, len(loadedRuns))
for runIndex := range loadedRuns {
runResponses = append(runResponses, buildRunResponse(&loadedRuns[runIndex]))
}
WritePaginatedSuccess(responseWriter, request, runResponses, PaginationMeta{
Page: requestedPage,
PageSize: requestedPageSize,
Total: int64(totalCount),
})
}
// handleCancelRun bedient POST /backup-runs/{id}/cancel.
func (handler *jobHandler) handleCancelRun(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
actingUser, _ := AuthenticatedUserFromContext(request.Context())
runIdentifier, uuidError := uuid.Parse(request.PathValue("id"))
if uuidError != nil {
WriteError(responseWriter, request, requestLogger,
NewBadRequestError("Die Laufkennung ist keine gültige UUID."))
return
}
existingRun, readError := handler.store.GetRun(request.Context(), runIdentifier)
if readError != nil {
WriteError(responseWriter, request, requestLogger, translateJobError(readError))
return
}
if cancelError := handler.store.CancelRun(request.Context(), runIdentifier,
"Der Lauf wurde von "+actingUser.Username+" abgebrochen."); cancelError != nil {
WriteError(responseWriter, request, requestLogger, translateJobError(cancelError))
return
}
handler.recordAudit(request, actingUser, audit.ActionBackupRunCancelled, existingRun.JobID, map[string]any{
"run_id": runIdentifier.String(),
})
cancelledRun, refreshError := handler.store.GetRun(request.Context(), runIdentifier)
if refreshError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(refreshError))
return
}
// Der Abbruch wirkt nicht sofort: Die Ausführungsschleife beendet den
// laufenden Vorgang beim nächsten Durchgang. Das wird gesagt, statt einen
// bereits beendeten Lauf vorzutäuschen.
WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{
"run": buildRunResponse(cancelledRun),
"message": "Der Abbruch wurde vermerkt. Ein bereits laufender Vorgang wird in Kürze beendet.",
})
}
// recordAudit schreibt ein Auditereignis.
//
// Ein Fehler beim Protokollieren darf die Antwort nicht verändern — die
// Handlung ist bereits geschehen. Er wird aber deutlich protokolliert: Ein
// stiller Verlust von Auditereignissen wäre ein Sicherheitsmangel.
func (handler *jobHandler) recordAudit(request *http.Request, actingUser auth.User, auditAction audit.Action, jobIdentifier uuid.UUID, auditDetails map[string]any) {
if handler.auditRecorder == nil {
return
}
correlationID, _ := logging.CorrelationIDFromContext(request.Context())
recordError := handler.auditRecorder.Record(request.Context(), audit.Event{
UserID: &actingUser.ID,
ActorUsername: actingUser.Username,
Action: auditAction,
EntityType: "backup_job",
EntityID: &jobIdentifier,
Result: audit.ResultSuccess,
IPAddress: clientIPAddress(request),
UserAgent: request.UserAgent(),
CorrelationID: correlationID,
Details: auditDetails,
})
if recordError != nil {
logging.WithContext(request.Context(), handler.logger).Error("das auditereignis konnte nicht geschrieben werden",
slog.String("aktion", string(auditAction)),
slog.String("job_id", jobIdentifier.String()),
slog.String("grund", recordError.Error()))
}
}
// parseJobIdentifier liest die Auftragskennung aus dem Pfad.
func parseJobIdentifier(request *http.Request) (uuid.UUID, *APIError) {
jobIdentifier, parseError := uuid.Parse(request.PathValue("id"))
if parseError != nil {
return uuid.Nil, NewBadRequestError("Die Auftragskennung ist keine gültige UUID.")
}
return jobIdentifier, nil
}
// parsePositiveInteger liest eine positive Zahl mit Standardwert.
func parsePositiveInteger(parameterValue string, defaultValue int) int {
parsedValue, parseError := strconv.Atoi(parameterValue)
if parseError != nil || parsedValue < 1 {
return defaultValue
}
return parsedValue
}
// translateJobError bildet Fehler der Fachschicht auf API-Fehler ab.
//
// Ein durchgereichter interner Fehler verriete Aufbau und Tabellennamen der
// Datenbank. Bekannte Fälle bekommen deshalb eine eigene, verständliche
// Meldung; alles Übrige wird zu einem allgemeinen Serverfehler.
func translateJobError(occurredError error) *APIError {
switch {
case errors.Is(occurredError, jobs.ErrJobNotFound):
return NewNotFoundError("Der Sicherungsauftrag wurde nicht gefunden.")
case errors.Is(occurredError, jobs.ErrRunNotFound):
return NewNotFoundError("Der Sicherungslauf wurde nicht gefunden oder ist bereits beendet.")
case errors.Is(occurredError, jobs.ErrRunAlreadyActive):
// 409 und nicht 500: Der Aufrufer hat nichts falsch gemacht, der
// Auftrag läuft nur bereits. Ein Serverfehler schickte ihn auf die
// Suche nach einem Defekt, den es nicht gibt.
activeError := NewValidationError(
"Für diesen Auftrag läuft bereits ein Sicherungslauf. Warten Sie dessen Ende ab.")
activeError.Code = ErrorCodeConflict
activeError.StatusCode = http.StatusConflict
return activeError
case errors.Is(occurredError, jobs.ErrJobNameTaken):
conflictError := NewValidationError("Ein Sicherungsauftrag dieses Namens besteht bereits.")
conflictError.Code = ErrorCodeConflict
conflictError.StatusCode = http.StatusConflict
return conflictError
case errors.Is(occurredError, jobs.ErrInvalidJob):
// Die Meldung der Fachschicht ist bereits verständlich formuliert und
// enthält keine Interna — sie wird deshalb weitergereicht.
return NewValidationError(occurredError.Error())
default:
return NewInternalError(occurredError)
}
}
// repositoryResponse ist die Darstellung eines Repositorys nach außen.
type repositoryResponse struct {
// ID ist der öffentliche Bezeichner.
ID uuid.UUID `json:"id"`
// Name ist die sprechende Bezeichnung.
Name string `json:"name"`
// RepositoryType benennt die Ablageart.
RepositoryType string `json:"repository_type"`
// Location ist der Pfad oder die Adresse der Ablage.
//
// Der Pfad ist keine Zugangsinformation und kein Geheimnis; ohne ihn liesse
// sich in der Oberfläche nicht unterscheiden, welches von zwei gleich
// benannten Zielen gemeint ist.
Location string `json:"location"`
// Status ist der Betriebszustand.
Status string `json:"status"`
// AcceptsBackups meldet, ob dieses Ziel Sicherungen annimmt.
//
// Der Zustand allein genügt der Oberfläche nicht: Sie müsste sonst wissen,
// welche Zustände schreibend sind. Diese Regel gehört auf den Server.
AcceptsBackups bool `json:"accepts_backups"`
// Hardened meldet den gehärteten Modus.
Hardened bool `json:"hardened"`
// CreatedAt ist der Anlagezeitpunkt in UTC.
CreatedAt time.Time `json:"created_at"`
}
// handleListRepositories bedient GET /repositories.
//
// Rein lesend und ohne Pagination: Ein Betrieb hat eine Handvoll Repositories,
// nicht tausende. Das Anlegen geschieht weiterhin über syncova-repo — ein
// Repository entsteht auf einem Datenträger, nicht in einer Datenbankzeile.
func (handler *jobHandler) handleListRepositories(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
loadedRepositories, listError := handler.store.ListRepositories(request.Context())
if listError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
return
}
repositoryResponses := make([]repositoryResponse, 0, len(loadedRepositories))
for _, loadedRepository := range loadedRepositories {
repositoryResponses = append(repositoryResponses, repositoryResponse{
ID: loadedRepository.ID,
Name: loadedRepository.Name,
RepositoryType: loadedRepository.RepositoryType,
Location: loadedRepository.Location,
Status: string(loadedRepository.Status),
AcceptsBackups: loadedRepository.Status.AcceptsWrites(),
Hardened: loadedRepository.Hardened,
CreatedAt: loadedRepository.CreatedAt,
})
}
WriteSuccess(responseWriter, request, http.StatusOK, repositoryResponses)
}
// handleListBackups bedient GET /backups (Wiederherstellungspunkte).
//
// Die Seite „Recovery Points" ist die zentrale Auskunft der Anlage: Welche
// Punkte gibt es, und kann man sich auf sie verlassen? Die Antwort trägt
// deshalb Einstufung, Bewertung und Schutzlage je Zeile — sonst müsste die
// Oberfläche je Punkt drei weitere Anfragen stellen.
func (handler *jobHandler) handleListBackups(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
listFilter := jobs.BackupListFilter{
Status: request.URL.Query().Get("status"),
Classification: request.URL.Query().Get("classification"),
IncludeDeleted: request.URL.Query().Get("include_deleted") == "true",
OnlyProtected: request.URL.Query().Get("only_protected") == "true",
Page: parsePositiveInteger(request.URL.Query().Get("page"), 1),
PageSize: parsePositiveInteger(request.URL.Query().Get("page_size"), 50),
}
if repositoryText := request.URL.Query().Get("repository_id"); repositoryText != "" {
repositoryIdentifier, parseError := uuid.Parse(repositoryText)
if parseError != nil {
WriteError(responseWriter, request, requestLogger,
NewBadRequestError("Die Repositorykennung ist keine gültige UUID."))
return
}
listFilter.RepositoryID = &repositoryIdentifier
}
if jobText := request.URL.Query().Get("job_id"); jobText != "" {
jobIdentifier, parseError := uuid.Parse(jobText)
if parseError != nil {
WriteError(responseWriter, request, requestLogger,
NewBadRequestError("Die Auftragskennung ist keine gültige UUID."))
return
}
listFilter.JobID = &jobIdentifier
}
loadedBackups, totalCount, listError := handler.store.ListBackups(request.Context(), listFilter)
if listError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(listError))
return
}
WritePaginatedSuccess(responseWriter, request, loadedBackups, PaginationMeta{
Page: listFilter.Page,
PageSize: listFilter.PageSize,
Total: int64(totalCount),
})
}
// handleDashboard bedient GET /dashboard.
//
// Jedes Widget meldet, ob es eine Datengrundlage hat. Drei der zehn im Plan
// genannten haben sie in dieser Ausbaustufe nicht; sie erscheinen trotzdem —
// mit der Angabe, was fehlt. Ein weggelassenes Widget sieht aus wie ein
// vergessenes, ein gefülltes wäre eine erfundene Statistik (PROMPT.md §139).
func (handler *jobHandler) handleDashboard(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
dashboardData, dashboardError := handler.store.Dashboard(request.Context())
if dashboardError != nil {
WriteError(responseWriter, request, requestLogger, NewInternalError(dashboardError))
return
}
WriteSuccess(responseWriter, request, http.StatusOK, dashboardData)
}