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>
253 lines
9.2 KiB
Go
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))
|
|
}
|