Files
pulse/internal/api/router_routes_monitoring.go
T
2026-05-20 12:40:04 +01:00

367 lines
19 KiB
Go

package api
import (
"net/http"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func (r *Router) registerMonitoringResourceRoutes(
guestMetadataHandler *GuestMetadataHandler,
dockerMetadataHandler *DockerMetadataHandler,
hostMetadataHandler *HostMetadataHandler,
infraUpdateHandlers *UpdateDetectionHandlers,
) {
r.mux.HandleFunc("/api/monitoring/scheduler/health", RequireAuth(r.config, r.handleSchedulerHealth))
r.mux.HandleFunc("/api/storage/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleStorage)))
r.mux.HandleFunc("/api/storage-charts", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleStorageCharts)))
r.mux.HandleFunc("/api/charts", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleCharts)))
r.mux.HandleFunc("/api/charts/workloads", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleWorkloadCharts)))
r.mux.HandleFunc("/api/charts/infrastructure", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleInfrastructureCharts)))
r.mux.HandleFunc("/api/charts/storage-summary", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleStorageSummaryCharts)))
r.mux.HandleFunc("/api/charts/workloads-summary", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleWorkloadsSummaryCharts)))
r.mux.HandleFunc("/api/metrics-store/stats", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleMetricsStoreStats)))
r.mux.HandleFunc("/api/metrics-store/history", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.handleMetricsHistory)))
r.mux.HandleFunc("/api/recovery/points", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.recoveryHandlers.HandleListPoints)))
r.mux.HandleFunc("/api/recovery/series", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.recoveryHandlers.HandleListSeries)))
r.mux.HandleFunc("/api/recovery/facets", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.recoveryHandlers.HandleListFacets)))
r.mux.HandleFunc("/api/recovery/rollups", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.recoveryHandlers.HandleListRollups)))
r.registerReplicationRoutes()
r.registerPVEBackupsRoutes()
r.registerPMGRoutes()
// Unified resources API
r.mux.HandleFunc("/api/resources", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleListResources)))
r.mux.HandleFunc("/api/resources/storage-incidents", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleStorageIncidents)))
r.mux.HandleFunc("/api/resources/storage-summary", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleStorageSummary)))
r.mux.HandleFunc("/api/resources/stats", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleStats)))
r.mux.HandleFunc("/api/resources/k8s/namespaces", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleK8sNamespaces)))
r.mux.HandleFunc("/api/resources/{id}/facets", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleGetResourceFacets)))
r.mux.HandleFunc("/api/resources/{id}/timeline", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleGetResourceTimeline)))
// Per-resource operator-set state. GET is read; PUT/DELETE are
// governance-shaping writes (suppress findings, refuse remediation,
// schedule maintenance) so they require the write scope. Method-keyed
// scope wrap mirrors the guests-metadata and docker-metadata pattern
// elsewhere in this file.
r.mux.HandleFunc("/api/resources/{id}/operator-state", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
if !ensureScope(w, req, config.ScopeMonitoringRead) {
return
}
case http.MethodPut, http.MethodDelete:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
r.resourceHandlers.HandleResourceOperatorState(w, req)
}))
// Per-resource Maintenance Verification Reports. Listing and reading
// are monitoring-read; rerun is monitoring-write because it produces
// a new persisted record. Review (mark-reviewed) is monitoring-write
// for the same reason. Wired before the broad `/api/resources/`
// catch-all so the more specific paths route here.
if r.maintenanceVerificationHandlers != nil {
r.mux.HandleFunc("/api/resources/{id}/maintenance-verifications", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.maintenanceVerificationHandlers.HandleListForResource)))
r.mux.HandleFunc("POST /api/resources/{id}/maintenance-verifications/rerun", RequireAuth(r.config, RequireScope(config.ScopeMonitoringWrite, r.maintenanceVerificationHandlers.HandleRerun)))
r.mux.HandleFunc("/api/maintenance-verifications/{reportId}", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.maintenanceVerificationHandlers.HandleGet)))
r.mux.HandleFunc("POST /api/maintenance-verifications/{reportId}/review", RequireAuth(r.config, RequireScope(config.ScopeMonitoringWrite, r.maintenanceVerificationHandlers.HandleReview)))
}
r.mux.HandleFunc("/api/resources/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleResourceRoutes)))
// Agent-consumable bundled context — substrate for any LLM agent
// (in-process Patrol/Assistant or external) that needs the full
// situated picture of a resource in one read instead of chaining
// resource + operator-state + findings + audit calls.
r.mux.HandleFunc("/api/agent/resource-context/{id}", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.agentContextHandler.HandleResourceContext)))
// Agent-consumable fleet triage view — thin per-resource rollups
// across the org so an agent can pick a focus in one read instead
// of walking every resource id and bundling each. Companion to the
// per-resource context endpoint: this is "where do I look?", that
// is "tell me everything about this one."
r.mux.HandleFunc("/api/agent/fleet-context", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.agentContextHandler.HandleFleetContext)))
// Agent capabilities manifest — discovery document. Unauthenticated
// because it only describes the agent surface; the capabilities
// themselves keep their own auth scopes. Agents fetch this once at
// startup to learn what's available.
r.mux.HandleFunc("/api/agent/capabilities", HandleAgentCapabilitiesManifest)
// Agent SSE event stream — agents subscribe once and receive
// real-time notifications instead of polling. Auth at
// monitoring:read because the stream surfaces the same
// information already available via the read endpoints.
if r.agentEventBroadcaster != nil {
r.mux.HandleFunc("/api/agent/events", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.agentEventBroadcaster.HandleAgentEvents)))
}
r.mux.HandleFunc("POST /api/actions/plan", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.resourceHandlers.HandlePlanAction)))
r.mux.HandleFunc("POST /api/actions/{id}/decision", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.resourceHandlers.HandleDecideAction)))
r.mux.HandleFunc("POST /api/actions/{id}/execute", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.resourceHandlers.HandleExecuteAction)))
// Guest metadata routes
r.mux.HandleFunc("/api/guests/metadata", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, guestMetadataHandler.HandleGetMetadata)))
r.mux.HandleFunc("/api/guests/metadata/", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
if !ensureScope(w, req, config.ScopeMonitoringRead) {
return
}
guestMetadataHandler.HandleGetMetadata(w, req)
case http.MethodPut, http.MethodPost:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
guestMetadataHandler.HandleUpdateMetadata(w, req)
case http.MethodDelete:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
guestMetadataHandler.HandleDeleteMetadata(w, req)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}))
// Docker metadata routes
r.mux.HandleFunc("/api/docker/metadata", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, dockerMetadataHandler.HandleGetMetadata)))
r.mux.HandleFunc("/api/docker/metadata/", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
if !ensureScope(w, req, config.ScopeMonitoringRead) {
return
}
dockerMetadataHandler.HandleGetMetadata(w, req)
case http.MethodPut, http.MethodPost:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
dockerMetadataHandler.HandleUpdateMetadata(w, req)
case http.MethodDelete:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
dockerMetadataHandler.HandleDeleteMetadata(w, req)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}))
// Docker runtime metadata routes (for custom display names, URLs, notes).
r.mux.HandleFunc("/api/docker/runtimes/metadata", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, dockerMetadataHandler.HandleGetRuntimeMetadata)))
r.mux.HandleFunc("/api/docker/runtimes/metadata/", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
if !ensureScope(w, req, config.ScopeMonitoringRead) {
return
}
dockerMetadataHandler.HandleGetRuntimeMetadata(w, req)
case http.MethodPut, http.MethodPost:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
dockerMetadataHandler.HandleUpdateRuntimeMetadata(w, req)
case http.MethodDelete:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
dockerMetadataHandler.HandleDeleteRuntimeMetadata(w, req)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}))
// Agent metadata routes (v6 canonical naming).
handleAgentMetadataWriteRoute := func(w http.ResponseWriter, req *http.Request) {
switch req.Method {
case http.MethodGet:
if !ensureScope(w, req, config.ScopeMonitoringRead) {
return
}
hostMetadataHandler.HandleGetMetadata(w, req)
case http.MethodPut, http.MethodPost:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
hostMetadataHandler.HandleUpdateMetadata(w, req)
case http.MethodDelete:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
hostMetadataHandler.HandleDeleteMetadata(w, req)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
r.mux.HandleFunc("/api/agents/metadata", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, hostMetadataHandler.HandleGetMetadata)))
r.mux.HandleFunc("/api/agents/metadata/", RequireAuth(r.config, handleAgentMetadataWriteRoute))
// Infrastructure update detection routes (Docker containers, packages, etc.)
r.mux.HandleFunc("/api/infra-updates", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, infraUpdateHandlers.HandleGetInfraUpdates)))
r.mux.HandleFunc("/api/infra-updates/summary", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, infraUpdateHandlers.HandleGetInfraUpdatesSummary)))
r.mux.HandleFunc("/api/infra-updates/check", RequireAuth(r.config, RequireScope(config.ScopeMonitoringWrite, infraUpdateHandlers.HandleTriggerInfraUpdateCheck)))
r.mux.HandleFunc("/api/infra-updates/agent/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, func(w http.ResponseWriter, req *http.Request) {
// Extract agent ID from path: /api/infra-updates/agent/{agentId}
agentID := strings.TrimPrefix(req.URL.Path, "/api/infra-updates/agent/")
agentID = strings.TrimSuffix(agentID, "/")
if agentID == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_agent_id", "Agent ID is required", nil)
return
}
infraUpdateHandlers.HandleGetInfraUpdatesForAgent(w, req, agentID)
})))
r.mux.HandleFunc("/api/infra-updates/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, func(w http.ResponseWriter, req *http.Request) {
// Extract resource ID from path: /api/infra-updates/{resourceId}
resourceID := strings.TrimPrefix(req.URL.Path, "/api/infra-updates/")
resourceID = strings.TrimSuffix(resourceID, "/")
if resourceID == "" || resourceID == "summary" || resourceID == "check" || strings.HasPrefix(resourceID, "agent/") {
// Let specific handlers deal with these
http.NotFound(w, req)
return
}
infraUpdateHandlers.HandleGetInfraUpdateForResource(w, req, resourceID)
})))
r.mux.HandleFunc("/api/discover", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleDiscoverServers)))
// Alert routes enforce read/write scopes inside HandleAlerts per endpoint method.
r.mux.HandleFunc("/api/alerts/", RequireAuth(r.config, r.alertHandlers.HandleAlerts))
// Notification routes
r.mux.HandleFunc("/api/notifications/", RequireAdmin(r.config, r.notificationHandlers.HandleNotifications))
// Notification queue/DLQ routes
// Security tokens are handled later in the setup with RBAC
// SECURITY: DLQ endpoints require settings:read/write scope because DLQ entries may contain
// notification configs with webhook URLs, SMTP credentials, or other sensitive data
r.mux.HandleFunc("/api/notifications/dlq", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodGet {
r.notificationQueueHandlers.GetDLQ(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})))
r.mux.HandleFunc("/api/notifications/queue/stats", RequireAdmin(r.config, RequireScope(config.ScopeSettingsRead, func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodGet {
r.notificationQueueHandlers.GetQueueStats(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})))
r.mux.HandleFunc("/api/notifications/dlq/retry", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {
r.notificationQueueHandlers.RetryDLQItem(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})))
r.mux.HandleFunc("/api/notifications/dlq/delete", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost || req.Method == http.MethodDelete {
r.notificationQueueHandlers.DeleteDLQItem(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})))
// AI-powered infrastructure discovery endpoints
r.mux.HandleFunc("/api/discovery", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.discoveryHandlers.HandleListDiscoveries)))
r.mux.HandleFunc("/api/discovery/status", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.discoveryHandlers.HandleGetStatus)))
r.mux.HandleFunc("/api/discovery/run", RequireAuth(r.config, RequireScope(config.ScopeSettingsWrite, func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {
r.discoveryHandlers.HandleRunDiscovery(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})))
r.mux.HandleFunc("/api/discovery/settings", RequireAuth(r.config, RequireScope(config.ScopeSettingsWrite, r.discoveryHandlers.HandleUpdateSettings)))
r.mux.HandleFunc("/api/discovery/info/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.discoveryHandlers.HandleGetInfo)))
r.mux.HandleFunc("/api/discovery/type/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.discoveryHandlers.HandleListByType)))
handleDiscoveryAgentRoute := func(pathPrefix string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
// Route based on method and path depth:
// GET /api/discovery/agent/{agentId} → list discoveries for an agent
// GET /api/discovery/agent/{agentId}/{resourceId} → get specific discovery
// GET /api/discovery/agent/{agentId}/{resourceId}/progress → get scan progress
// POST /api/discovery/agent/{agentId}/{resourceId} → trigger discovery
// PUT /api/discovery/agent/{agentId}/{resourceId}/notes → update notes
// DELETE /api/discovery/agent/{agentId}/{resourceId} → delete discovery
path := strings.TrimPrefix(req.URL.Path, pathPrefix)
pathParts := strings.Split(strings.TrimSuffix(path, "/"), "/")
switch req.Method {
case http.MethodGet:
if !ensureScope(w, req, config.ScopeMonitoringRead) {
return
}
if len(pathParts) == 1 && pathParts[0] != "" {
// GET /api/discovery/agent/{id} → list by agent
r.discoveryHandlers.HandleListByAgent(w, req)
} else if len(pathParts) >= 2 {
if strings.HasSuffix(req.URL.Path, "/progress") {
r.discoveryHandlers.HandleGetProgress(w, req)
} else {
// GET /api/discovery/agent/{agentId}/{resourceId} → get specific discovery
r.discoveryHandlers.HandleGetDiscovery(w, req)
}
} else {
http.Error(w, "Invalid path", http.StatusBadRequest)
}
case http.MethodPost:
if !ensureScope(w, req, config.ScopeSettingsWrite) {
return
}
// POST /api/discovery/agent/{agentId}/{resourceId} → trigger discovery
r.discoveryHandlers.HandleTriggerDiscovery(w, req)
case http.MethodPut:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
if strings.HasSuffix(req.URL.Path, "/notes") {
r.discoveryHandlers.HandleUpdateNotes(w, req)
} else {
http.Error(w, "Not found", http.StatusNotFound)
}
case http.MethodDelete:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
r.discoveryHandlers.HandleDeleteDiscovery(w, req)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
}
r.mux.HandleFunc("/api/discovery/agent/", RequireAuth(r.config, handleDiscoveryAgentRoute("/api/discovery/agent/")))
r.mux.HandleFunc("/api/discovery/", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
path := req.URL.Path
switch req.Method {
case http.MethodGet:
if !ensureScope(w, req, config.ScopeMonitoringRead) {
return
}
if strings.HasSuffix(path, "/progress") {
r.discoveryHandlers.HandleGetProgress(w, req)
} else {
r.discoveryHandlers.HandleGetDiscovery(w, req)
}
case http.MethodPost:
if !ensureScope(w, req, config.ScopeSettingsWrite) {
return
}
r.discoveryHandlers.HandleTriggerDiscovery(w, req)
case http.MethodPut:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
if strings.HasSuffix(path, "/notes") {
r.discoveryHandlers.HandleUpdateNotes(w, req)
} else {
http.Error(w, "Not found", http.StatusNotFound)
}
case http.MethodDelete:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
r.discoveryHandlers.HandleDeleteDiscovery(w, req)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}))
}