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>
783 lines
30 KiB
Go
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)
|
|
}
|