package httpapi import ( "errors" "log/slog" "net/http" "github.com/google/uuid" "github.com/syncova/syncova/packages/audit" "github.com/syncova/syncova/packages/hypervisor" "github.com/syncova/syncova/packages/platform/logging" ) // hypervisorHandler bedient die Endpunkte der Virtualisierungsumgebungen. type hypervisorHandler struct { // clusterStore ist die Datenzugriffsschicht der Verbünde. clusterStore *hypervisor.Store // auditRecorder protokolliert die verändernden Zugriffe. auditRecorder audit.Recorder // logger protokolliert technische Fehler. logger *slog.Logger } // handleListClusters bedient GET /proxmox/clusters. func (handler *hypervisorHandler) handleListClusters(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) clusterList, listError := handler.clusterStore.ListClusters(request.Context()) if listError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(listError)) return } WriteSuccess(responseWriter, request, http.StatusOK, clusterList) } // handleCreateCluster bedient POST /proxmox/clusters. func (handler *hypervisorHandler) handleCreateCluster(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) var clusterInput hypervisor.ClusterInput if decodeError := decodeJSONBody(request, &clusterInput); decodeError != nil { WriteError(responseWriter, request, requestLogger, decodeError) return } createdCluster, createError := handler.clusterStore.CreateCluster(request.Context(), clusterInput) if createError != nil { // Eine ungültige Eingabe ist kein Serverfehler. Die Meldung wird // wörtlich weitergereicht, weil sie erklärt, was fehlt — eine // allgemeine „Eingabe ungültig" ließe den Betreiber raten. WriteError(responseWriter, request, requestLogger, NewValidationError(createError.Error())) return } // Ein neuer Verbund bedeutet neue Zugangsdaten im System — das gehört ins // Protokoll, unabhängig davon, ob es später jemanden interessiert. handler.recordAudit(request, actingUser.ID, actingUser.Username, audit.ActionHypervisorClusterCreated, createdCluster.ID, map[string]any{ "name": createdCluster.Name, "api_endpoint": createdCluster.APIEndpoint, "archive_transport": string(createdCluster.ArchiveTransport), }) WriteSuccess(responseWriter, request, http.StatusCreated, createdCluster) } // handleGetCluster bedient GET /proxmox/clusters/{id}. func (handler *hypervisorHandler) handleGetCluster(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) clusterIdentifier, parseError := uuid.Parse(request.PathValue("id")) if parseError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig.")) return } foundCluster, readError := handler.clusterStore.GetCluster(request.Context(), clusterIdentifier) if errors.Is(readError, hypervisor.ErrClusterNotFound) { WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der Verbund wurde nicht gefunden.")) return } if readError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(readError)) return } WriteSuccess(responseWriter, request, http.StatusOK, foundCluster) } // handleTestConnection bedient POST /proxmox/clusters/{id}/test. // // Die Prüfung verändert nichts am Verbund und ist der erste Schritt nach dem // Einrichten: Sie sagt, ob Anmeldung und Zertifikatsbindung stimmen, bevor um // zwei Uhr nachts eine Sicherung daran scheitert. func (handler *hypervisorHandler) handleTestConnection(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) clusterIdentifier, parseError := uuid.Parse(request.PathValue("id")) if parseError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig.")) return } connectionError := handler.clusterStore.TestConnection(request.Context(), clusterIdentifier, handler.logger) if errors.Is(connectionError, hypervisor.ErrClusterNotFound) { WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der Verbund wurde nicht gefunden.")) return } // Der Zustand nach der Prüfung ist die eigentliche Antwort — auch im // Fehlerfall. Ein 500 verschwiege, dass die Prüfung ordnungsgemäß gelaufen // ist und ein Ergebnis hat. updatedCluster, readError := handler.clusterStore.GetCluster(request.Context(), clusterIdentifier) if readError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(readError)) return } responsePayload := map[string]any{ "cluster_id": clusterIdentifier, "status": updatedCluster.Status, "reachable": connectionError == nil, } if connectionError != nil { responsePayload["error"] = connectionError.Error() } WriteSuccess(responseWriter, request, http.StatusOK, responsePayload) } // handleDiscover bedient POST /proxmox/clusters/{id}/discover. func (handler *hypervisorHandler) handleDiscover(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) clusterIdentifier, parseError := uuid.Parse(request.PathValue("id")) if parseError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig.")) return } discoveryResult, discoveryError := handler.clusterStore.Discover(request.Context(), clusterIdentifier, handler.logger) if errors.Is(discoveryError, hypervisor.ErrClusterNotFound) { WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der Verbund wurde nicht gefunden.")) return } if discoveryError != nil { // Ein nicht erreichbarer Verbund ist kein Serverfehler, sondern eine // Auskunft über die Anlage. 503 statt 500, damit die Oberfläche den // Unterschied anzeigen kann. WriteError(responseWriter, request, requestLogger, NewServiceUnavailableError("Die Bestandsaufnahme schlug fehl: "+discoveryError.Error())) return } WriteSuccess(responseWriter, request, http.StatusOK, discoveryResult) } // handleDeleteCluster bedient DELETE /proxmox/clusters/{id}. func (handler *hypervisorHandler) handleDeleteCluster(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) actingUser, _ := AuthenticatedUserFromContext(request.Context()) clusterIdentifier, parseError := uuid.Parse(request.PathValue("id")) if parseError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig.")) return } deleteError := handler.clusterStore.DeleteCluster(request.Context(), clusterIdentifier) if errors.Is(deleteError, hypervisor.ErrClusterNotFound) { WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der Verbund wurde nicht gefunden.")) return } // Ein Verbund, auf den noch Sicherungsquellen verweisen, wird nicht // entfernt: Die Aufträge verlören ihre Grundlage, und zwar stillschweigend // bis zum nächsten Lauf. if errors.Is(deleteError, hypervisor.ErrClusterInUse) { WriteError(responseWriter, request, requestLogger, &APIError{ StatusCode: http.StatusConflict, Code: ErrorCodeConflict, Message: deleteError.Error(), }) return } if deleteError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(deleteError)) return } handler.recordAudit(request, actingUser.ID, actingUser.Username, audit.ActionHypervisorClusterDeleted, clusterIdentifier, nil) WriteSuccess(responseWriter, request, http.StatusOK, map[string]any{"deleted": true}) } // handleListVirtualMachines bedient GET /virtual-machines. func (handler *hypervisorHandler) handleListVirtualMachines(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) var clusterFilter *uuid.UUID if rawFilter := request.URL.Query().Get("cluster_id"); rawFilter != "" { parsedFilter, parseError := uuid.Parse(rawFilter) if parseError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig.")) return } clusterFilter = &parsedFilter } machineList, listError := handler.clusterStore.ListVirtualMachines(request.Context(), clusterFilter) if listError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(listError)) return } WriteSuccess(responseWriter, request, http.StatusOK, machineList) } // recordAudit schreibt einen Eintrag ins Auditprotokoll. // // Ein fehlgeschlagener Eintrag wird protokolliert, aber nicht geworfen: Die // Handlung ist geschehen, und ein Fehler an dieser Stelle machte sie nicht // ungeschehen — er verschwiege sie nur zusätzlich. func (handler *hypervisorHandler) recordAudit(request *http.Request, actingUserID uuid.UUID, actingUsername string, auditAction audit.Action, entityIdentifier uuid.UUID, auditDetails map[string]any) { if handler.auditRecorder == nil { return } correlationIdentifier, _ := logging.CorrelationIDFromContext(request.Context()) recordError := handler.auditRecorder.Record(request.Context(), audit.Event{ UserID: &actingUserID, ActorUsername: actingUsername, Action: auditAction, EntityType: "proxmox_cluster", EntityID: &entityIdentifier, Result: audit.ResultSuccess, IPAddress: clientIPAddress(request), UserAgent: request.UserAgent(), CorrelationID: correlationIdentifier, Details: auditDetails, }) if recordError != nil { logging.WithContext(request.Context(), handler.logger).Error( "der audit-eintrag liess sich nicht schreiben", slog.String("aktion", string(auditAction)), slog.String("grund", recordError.Error())) } } // handleListHosts bedient GET /proxmox/clusters/{id}/hosts. func (handler *hypervisorHandler) handleListHosts(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) clusterIdentifier, parseError := uuid.Parse(request.PathValue("id")) if parseError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig.")) return } hostList, listError := handler.clusterStore.ListHosts(request.Context(), clusterIdentifier) if listError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(listError)) return } WriteSuccess(responseWriter, request, http.StatusOK, hostList) } // handleListClusterMachines bedient GET /proxmox/clusters/{id}/vms. func (handler *hypervisorHandler) handleListClusterMachines(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) clusterIdentifier, parseError := uuid.Parse(request.PathValue("id")) if parseError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Verbunds ist ungültig.")) return } machineList, listError := handler.clusterStore.ListVirtualMachines(request.Context(), &clusterIdentifier) if listError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(listError)) return } WriteSuccess(responseWriter, request, http.StatusOK, machineList) } // handleGetVirtualMachine bedient GET /proxmox/vms/{id}. func (handler *hypervisorHandler) handleGetVirtualMachine(responseWriter http.ResponseWriter, request *http.Request) { requestLogger := logging.WithContext(request.Context(), handler.logger) machineIdentifier, parseError := uuid.Parse(request.PathValue("id")) if parseError != nil { WriteError(responseWriter, request, requestLogger, NewBadRequestError("Die Kennung des Gasts ist ungültig.")) return } foundMachine, readError := handler.clusterStore.GetVirtualMachine(request.Context(), machineIdentifier) if errors.Is(readError, hypervisor.ErrVirtualMachineNotFound) { WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der Gast wurde im Bestand nicht gefunden.")) return } if readError != nil { WriteError(responseWriter, request, requestLogger, NewInternalError(readError)) return } WriteSuccess(responseWriter, request, http.StatusOK, foundMachine) }