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)) }