syncova-backup/apps/api/internal/httpapi/job_handler.go
Jerrit Fritzsche 8e98cc7510
Some checks failed
CI / Backend (Go) (push) Failing after 31s
CI / Frontend (React/TypeScript) (push) Successful in 46s
CI / Sicherheitsprüfungen (push) Successful in 28s
Sicherungsart je Auftrag, Agenten-Token und -Anleitung, update.sh
**Sicherungsart.** Bisher entschied der Executor allein: Liegt ein Elternbackup
vor, wird inkrementell gesichert. Jetzt waehlbar je Auftrag —

- `incremental` (Standard, bisheriges Verhalten),
- `always_full`, oder
- inkrementell **mit einem festen Volltag** ("immer freitags").

Migration 000014 mit drei CHECKs. Der dritte lehnt "immer voll" zusammen mit
einem Wochentag ab: Dann ist ohnehin jeder Lauf voll, und die Regel gehoert in
die Datenbank, weil im Code jede Stelle sie einhalten muesste — eine vergisst
es. Real geprueft: der Widerspruch wird abgewiesen.

Der Wochentag wird in der **Zeitzone des Zeitplans** bestimmt. Rechnete der
Server in UTC, bekaeme ein Betreiber in Berlin seine Vollsicherung am
Donnerstagabend und wunderte sich, warum sie freitags fehlt. Vier Tests, der
entscheidende durch Mutation als fangend bestaetigt.

Zur Einordnung, weil es leicht verwechselt wird: Der Platzbedarf steigt bei
"immer voll" **nicht** nennenswert — unveraenderte Bloecke werden dedupliziert
und liegen weiterhin nur einmal im Repository. Was steigt, ist die Laufzeit.
Steht so in der Maske.

**Aufnahme-Token zeigte "undefined".** Das Feld heisst `token`, nicht
`enrollment_token` — Letzteres ist der Name im *Anfrage*koerper der
Registrierung. Der dritte Formfehler dieser Art; alle konsumierten Endpunkte
sind jetzt gegen den laufenden Dienst abgeglichen.

**Der Aufnahmedialog** hat jetzt eine vollstaendige Anleitung fuer Linux und
Windows mit fertig ausgefuellten Befehlen — Serveradresse und Token eingesetzt,
je Schritt einzeln kopierbar. Eine Anleitung mit Platzhaltern fuehrt
zuverlaessig dazu, dass jemand `<token>` woertlich einsetzt und dann eine
Fehlermeldung sucht, die nichts mit seinem Problem zu tun hat. Dazu die beiden
Stolperstellen: `--state` will eine Datei, und der Agent braucht Schreibzugriff
aufs Repository. Beim Windows-Weg steht dabei, dass der Dienst nie auf echter
Hardware lief.

**update.sh ruestet die Wiederherstellungsflaeche nach** — anlegen und in
ReadWritePaths eintragen. Ein Schritt, den man von Hand ausfuehren muss, wird
uebersehen und faellt erst im Ernstfall auf.

84 Tests im Frontend, alle Go-Tests gruen, shellcheck sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:13:22 +02:00

799 lines
31 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"`
// BackupMode ist "incremental" (Standard) oder "always_full".
BackupMode string `json:"backup_mode,omitempty"`
// FullBackupWeekday erzwingt an diesem Wochentag eine Vollsicherung.
//
// 0 = Sonntag … 6 = Samstag, nil = keiner. Ein Zeiger, weil 0 ein gueltiger
// Wert ist: Ohne ihn liesse sich "Sonntag" nicht von "nicht gesetzt"
// unterscheiden.
FullBackupWeekday *int `json:"full_backup_weekday,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"`
// BackupMode ist "incremental" (Standard) oder "always_full".
BackupMode string `json:"backup_mode,omitempty"`
// FullBackupWeekday erzwingt an diesem Wochentag eine Vollsicherung.
//
// 0 = Sonntag … 6 = Samstag, nil = keiner. Ein Zeiger, weil 0 ein gueltiger
// Wert ist: Ohne ihn liesse sich "Sonntag" nicht von "nicht gesetzt"
// unterscheiden.
FullBackupWeekday *int `json:"full_backup_weekday,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)
}