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

253 lines
9.2 KiB
Go

package httpapi
import (
"errors"
"log/slog"
"net/http"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/agenttasks"
"github.com/syncova/syncova/packages/platform/logging"
)
// agentTaskHandler bedient die Auftragsübermittlung an Agenten (Phase 5).
//
// Alle drei Endpunkte hängen am **Betriebstoken des Agenten**, nicht an der
// Sitzung eines Benutzers. Und jeder von ihnen prüft, dass der Auftrag dem
// anfragenden Agenten gehört: Ohne diese Prüfung könnte ein übernommener Agent
// die Aufträge aller anderen Systeme abholen — samt Quellpfaden und
// Repositorypfaden, also einer Landkarte der gesamten Anlage.
type agentTaskHandler struct {
// taskStore ist die Datenzugriffsschicht der Aufträge.
taskStore *agenttasks.Store
// logger protokolliert technische Fehler.
logger *slog.Logger
}
// claimedTaskResponse ist ein abgeholter Auftrag.
type claimedTaskResponse struct {
// TaskID ist die Kennung des Auftrags.
TaskID uuid.UUID `json:"task_id"`
// TaskType ist die Art des Auftrags.
TaskType string `json:"task_type"`
// Backup ist der Auftragsinhalt einer Sicherung.
Backup agenttasks.BackupPayload `json:"backup"`
// Restore ist der Auftragsinhalt einer Wiederherstellung.
Restore agenttasks.RestorePayload `json:"restore"`
}
// handleClaimTask bedient POST /agents/tasks/claim.
//
// Antwortet mit 204, wenn nichts anliegt. Kein Fehler und kein leeres Objekt:
// Der Agent fragt regelmäßig, und „nichts zu tun" ist der Normalfall — er darf
// sich nicht von einer Störung unterscheiden lassen müssen.
func (handler *agentTaskHandler) handleClaimTask(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
authenticatedAgent, isAuthenticated := AuthenticatedAgentFromContext(request.Context())
if !isAuthenticated {
WriteError(responseWriter, request, requestLogger,
newUnauthenticatedError("Für diesen Zugriff ist ein Agent-Token erforderlich."))
return
}
claimedTask, claimError := handler.taskStore.ClaimNextTask(request.Context(), authenticatedAgent.ID)
if claimError != nil {
if errors.Is(claimError, agenttasks.ErrNoTaskAvailable) {
responseWriter.WriteHeader(http.StatusNoContent)
return
}
requestLogger.Error("ein auftrag liess sich nicht uebernehmen",
slog.String("agent", authenticatedAgent.ID.String()),
slog.String("grund", claimError.Error()))
WriteError(responseWriter, request, requestLogger, NewInternalError(claimError))
return
}
requestLogger.Info("ein agent hat einen auftrag uebernommen",
slog.String("agent", authenticatedAgent.Name),
slog.String("auftrag", claimedTask.ID.String()),
slog.String("art", string(claimedTask.TaskType)))
WriteSuccess(responseWriter, request, http.StatusOK, claimedTaskResponse{
TaskID: claimedTask.ID,
TaskType: string(claimedTask.TaskType),
Backup: claimedTask.Backup,
Restore: claimedTask.Restore,
})
}
// taskProgressRequest ist der Rumpf von POST /agents/tasks/{id}/progress.
type taskProgressRequest struct {
// BytesProcessed ist die bisher gelesene Datenmenge.
BytesProcessed int64 `json:"bytes_processed"`
// FilesProcessed ist die bisher erfasste Objektzahl.
FilesProcessed int64 `json:"files_processed"`
}
// handleReportProgress bedient POST /agents/tasks/{id}/progress.
//
// Der Fortschritt ist zugleich die Lebendmeldung des laufenden Auftrags. Bleibt
// sie aus, gibt der Server den Auftrag nach einer Frist als gescheitert frei —
// sonst bliebe er nach einem Absturz des Agenten dauerhaft auf „läuft" stehen
// und blockierte den Agenten für immer.
func (handler *agentTaskHandler) handleReportProgress(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
authenticatedAgent, isAuthenticated := AuthenticatedAgentFromContext(request.Context())
if !isAuthenticated {
WriteError(responseWriter, request, requestLogger,
newUnauthenticatedError("Für diesen Zugriff ist ein Agent-Token erforderlich."))
return
}
taskIdentifier, parseError := uuid.Parse(request.PathValue("id"))
if parseError != nil {
WriteError(responseWriter, request, requestLogger,
NewBadRequestError("Die Auftragskennung ist keine gueltige UUID."))
return
}
var progressPayload taskProgressRequest
if decodeError := decodeJSONBody(request, &progressPayload); decodeError != nil {
WriteError(responseWriter, request, requestLogger, decodeError)
return
}
progressError := handler.taskStore.ReportProgress(request.Context(), taskIdentifier,
authenticatedAgent.ID, progressPayload.BytesProcessed, progressPayload.FilesProcessed)
if progressError != nil {
handler.writeTaskError(responseWriter, request, requestLogger, progressError)
return
}
responseWriter.WriteHeader(http.StatusNoContent)
}
// taskResultRequest ist der Rumpf von POST /agents/tasks/{id}/result.
type taskResultRequest struct {
// Status ist der erreichte Zustand.
Status string `json:"status"`
// BytesProcessed ist die gelesene Datenmenge.
BytesProcessed int64 `json:"bytes_processed"`
// BytesWritten ist die abgelegte Datenmenge.
BytesWritten int64 `json:"bytes_written"`
// FilesProcessed ist die Zahl erfasster Objekte.
FilesProcessed int64 `json:"files_processed"`
// FilesSkipped ist die Zahl übergangener Objekte.
FilesSkipped int64 `json:"files_skipped"`
// BackupIDInRepository ist die Kennung des entstandenen Backups.
BackupIDInRepository string `json:"backup_id_in_repository,omitempty"`
// ErrorCode ist der maschinenlesbare Fehlercode.
ErrorCode string `json:"error_code,omitempty"`
// ErrorMessage beschreibt den Fehler.
ErrorMessage string `json:"error_message,omitempty"`
// FailureClass ist die Einstufung des Fehlers.
FailureClass string `json:"failure_class,omitempty"`
}
// handleReportResult bedient POST /agents/tasks/{id}/result.
func (handler *agentTaskHandler) handleReportResult(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), handler.logger)
authenticatedAgent, isAuthenticated := AuthenticatedAgentFromContext(request.Context())
if !isAuthenticated {
WriteError(responseWriter, request, requestLogger,
newUnauthenticatedError("Für diesen Zugriff ist ein Agent-Token erforderlich."))
return
}
taskIdentifier, parseError := uuid.Parse(request.PathValue("id"))
if parseError != nil {
WriteError(responseWriter, request, requestLogger,
NewBadRequestError("Die Auftragskennung ist keine gueltige UUID."))
return
}
var resultPayload taskResultRequest
if decodeError := decodeJSONBody(request, &resultPayload); decodeError != nil {
WriteError(responseWriter, request, requestLogger, decodeError)
return
}
taskResult := agenttasks.TaskResult{
Status: agenttasks.TaskStatus(resultPayload.Status),
BytesProcessed: resultPayload.BytesProcessed,
BytesWritten: resultPayload.BytesWritten,
FilesProcessed: resultPayload.FilesProcessed,
FilesSkipped: resultPayload.FilesSkipped,
BackupIDInRepository: resultPayload.BackupIDInRepository,
ErrorCode: resultPayload.ErrorCode,
ErrorMessage: resultPayload.ErrorMessage,
FailureClass: resultPayload.FailureClass,
}
if !isReportableStatus(taskResult.Status) {
WriteError(responseWriter, request, requestLogger, NewValidationError(
"Ein Ergebnis muss einen abgeschlossenen Zustand melden: succeeded, "+
"partial_failure, failed oder cancelled."))
return
}
completeError := handler.taskStore.CompleteTask(request.Context(), taskIdentifier,
authenticatedAgent.ID, taskResult)
if completeError != nil {
handler.writeTaskError(responseWriter, request, requestLogger, completeError)
return
}
requestLogger.Info("ein agent hat einen auftrag abgeschlossen",
slog.String("agent", authenticatedAgent.Name),
slog.String("auftrag", taskIdentifier.String()),
slog.String("ergebnis", resultPayload.Status),
slog.Int64("bytes", resultPayload.BytesProcessed),
slog.Int64("uebergangen", resultPayload.FilesSkipped))
responseWriter.WriteHeader(http.StatusNoContent)
}
// isReportableStatus meldet einen zulässigen Ergebniszustand.
//
// Ein Agent darf kein „läuft" als Ergebnis melden: Das wäre ein Auftrag, der
// nie endet, und der Lauf wartete darauf, bis die Frist ihn freigibt.
func isReportableStatus(status agenttasks.TaskStatus) bool {
return status.IsTerminal()
}
// writeTaskError bildet einen Speicherfehler auf eine Antwort ab.
func (handler *agentTaskHandler) writeTaskError(responseWriter http.ResponseWriter,
request *http.Request, requestLogger *slog.Logger, occurredError error) {
// Ein fremder oder bereits abgeschlossener Auftrag ergibt dieselbe Antwort.
//
// Die Unterscheidung verriete einem übernommenen Agenten, welche
// Auftragskennungen es überhaupt gibt — dieselbe Überlegung wie bei der
// Anmeldung, die nicht verrät, ob ein Konto existiert.
if errors.Is(occurredError, agenttasks.ErrTaskNotOwned) {
WriteError(responseWriter, request, requestLogger,
NewNotFoundError("Zu dieser Kennung gibt es keinen laufenden Auftrag dieses Agenten."))
return
}
requestLogger.Error("ein auftrag liess sich nicht fortschreiben",
slog.String("grund", occurredError.Error()))
WriteError(responseWriter, request, requestLogger, NewInternalError(occurredError))
}