Pulse/internal/api/router_routes_monitoring.go
Richard Courtman ccea5ba761 Fail actions stranded without agent completion evidence and add force-fail (#1649)
An action that reached executing had exactly one way out: a terminal
operation receipt from the agent. Expiry skips executing rows on purpose,
and reconciliation bailed for every non-terminal query answer, so a Docker
update whose agent restarted mid-pull sat in executing forever with no
operator route out of it.

The agent-side receipt store rewrites accepted and started receipts to
interrupted on every Open(), and nothing can move an interrupted or
tombstoned receipt back to terminal. An identity-correlated answer of
either kind is therefore proof the operation will never report, and the
three executors now settle the action immediately as inconclusive with a
message telling the operator Pulse cannot confirm the effect and the
resource needs checking by hand.

A not_found answer, or a receipt still merely accepted or started, may
still be completed, so those keep waiting and only settle once the
dispatch attempt is older than one hour, the same threshold the
pulse-intelligence telemetry already uses to call an executing action
stuck. Every typed operation timeout is far shorter than that, so an
in-flight mutation is never cut short. A transport error answering the
query is still not evidence and preserves receipt_pending unchanged.

RecoverExecutingActions drives all of this on the existing two-minute
recovery loop, so rows already wedged before the upgrade heal themselves
without anyone touching them.

For the residue that reconciliation cannot reach, an agent that was
reinstalled or a legacy executing row with no dispatch attempt, POST
/api/actions/{id}/force-fail writes the same inconclusive terminal truth
under an operator attribution. It never touches the transport, refuses
anything already terminal, and is gated on admin plus settings:write on
top of the execute capability check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:14:28 +01:00

438 lines
24 KiB
Go

package api
import (
"net/http"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
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.mux.HandleFunc("/api/recovery/postures", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.recoveryHandlers.HandleListProtectionPostures)))
r.registerReplicationRoutes()
r.registerPVEBackupsRoutes()
r.registerPBSBackupsRoutes()
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/timeline", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleListResourceTimeline)))
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
}
r.recordExternalAgentCapabilityActivity(req, agentcapabilities.GetOperatorStateCapabilityName)
case http.MethodPut, http.MethodDelete:
if !ensureScope(w, req, config.ScopeMonitoringWrite) {
return
}
if req.Method == http.MethodPut {
r.recordExternalAgentCapabilityActivity(req, agentcapabilities.SetOperatorStateCapabilityName)
} else {
r.recordExternalAgentCapabilityActivity(req, agentcapabilities.ClearOperatorStateCapabilityName)
}
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.withExternalAgentCapabilityActivity(
agentcapabilities.ResourceContextCapabilityName,
r.agentContextHandler.HandleResourceContext,
))))
// Agent-consumable structured capability surface for a single resource.
// Companion to the resource-context bundle above: that renders capabilities
// as count-limited prose facts; this returns the structured list (names +
// parameter schemas) an agent needs to populate plan_action.capabilityName
// and params without guessing.
r.mux.HandleFunc("/api/agent/resource-capabilities/{id}", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.withExternalAgentCapabilityActivity(
agentcapabilities.ListResourceCapabilitiesCapabilityName,
r.agentContextHandler.HandleResourceCapabilities,
))))
// 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.withExternalAgentCapabilityActivity(
agentcapabilities.FleetContextCapabilityName,
r.agentContextHandler.HandleFleetContext,
))))
// Content-safe Patrol control status: the shared Patrol, Assistant,
// governed action, verification, Patrol control proof, and optional MCP
// readiness projection for native UI and external agents.
r.mux.HandleFunc(agentcapabilities.PatrolControlStatusCapabilityPath, RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.withExternalAgentCapabilityActivity(
agentcapabilities.PatrolControlStatusCapabilityName,
r.agentContextHandler.HandleOperationsLoopStatus,
))))
// Legacy operations-loop route kept for older external-agent clients. New
// clients should discover the Patrol control route from the manifest.
r.mux.HandleFunc(agentcapabilities.OperationsLoopStatusCompatibilityPath, RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.withExternalAgentCapabilityActivity(
agentcapabilities.OperationsLoopStatusCapabilityName,
r.agentContextHandler.HandleOperationsLoopStatus,
))))
// 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)
r.mux.HandleFunc("POST /api/agent/workflow-prompt-activity", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.HandleAgentWorkflowPromptActivity)))
// 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.withExternalAgentCapabilityActivity(
agentcapabilities.EventSubscriptionCapabilityName,
r.agentEventBroadcaster.HandleAgentEvents,
))))
}
r.mux.HandleFunc("POST /api/actions/plan", RequireAuth(r.config, RequireAnyScope([]string{config.ScopeActionsPlan, config.ScopeAIExecute}, requireActionCapability(r.authorizer, auth.ActionPlan, r.withExternalAgentCapabilityActivity(
agentcapabilities.PlanActionCapabilityName,
r.resourceHandlers.HandlePlanAction,
)))))
r.mux.HandleFunc("GET /api/actions/pending", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRoutePendingActions,
requireActionCapability(r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleListPendingActions),
)))
r.mux.HandleFunc("GET /api/actions", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteActionsList,
requireActionCapability(r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleListActions),
)))
r.mux.HandleFunc("GET /api/actions/{id}", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteActionDetail,
requireActionCapability(r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleGetAction),
)))
r.mux.HandleFunc("POST /api/actions/{id}/decision", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteActionDecision, r.withExternalAgentCapabilityActivity(
agentcapabilities.DecideActionCapabilityName,
requireActionCapability(r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleDecideAction),
))))
r.mux.HandleFunc("POST /api/actions/{id}/execute", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteActionExecute, r.withExternalAgentCapabilityActivity(
agentcapabilities.ExecuteActionCapabilityName,
requireActionCapability(r.authorizer, auth.ActionExecute, r.resourceHandlers.HandleExecuteAction),
))))
// Operator override for an action wedged in executing without agent
// completion evidence. It is deliberately not an agent capability and not
// a relay-mobile route: it is a local admin repair, gated harder than
// execute (admin plus settings:write on top of the execute capability)
// because it writes terminal audit truth Pulse could not observe.
r.mux.HandleFunc("POST /api/actions/{id}/force-fail", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite,
requireActionCapability(r.authorizer, auth.ActionExecute, r.resourceHandlers.HandleForceFailAction),
)))
// 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.withExternalAgentCapabilityActivity(
agentcapabilities.DiscoverLANCapabilityName,
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)
}
}))
}