Files
pulse/internal/api/discovery_handlers.go
2026-08-31 00:43:42 +01:00

1007 lines
33 KiB
Go

package api
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
mockfixtures "github.com/rcourtman/pulse-go-rewrite/internal/mock"
"github.com/rcourtman/pulse-go-rewrite/internal/servicediscovery"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rs/zerolog/log"
)
// AIConfigProvider provides access to the current AI configuration.
// This allows discovery handlers to show AI provider info without tight coupling.
type AIConfigProvider interface {
GetAIConfig() *config.AIConfig
}
// Note: adminBypassEnabled() is defined in auth.go
// DiscoveryHandlers handles AI-powered infrastructure discovery endpoints.
type DiscoveryHandlers struct {
service *servicediscovery.Service
config *config.Config // For admin status checks
aiConfigProvider AIConfigProvider
}
// NewDiscoveryHandlers creates new discovery handlers.
func NewDiscoveryHandlers(service *servicediscovery.Service, cfg *config.Config) *DiscoveryHandlers {
return &DiscoveryHandlers{
service: service,
config: cfg,
}
}
// SetService sets the discovery service (used for late initialization after routes are registered).
func (h *DiscoveryHandlers) SetService(service *servicediscovery.Service) {
h.service = service
}
// DiscoveryReadinessForResource implements the unified resource readiness
// provider used by resource and agent-context endpoints.
func (h *DiscoveryHandlers) DiscoveryReadinessForResource(resource unified.Resource, now time.Time) unified.ResourceDiscoveryReadiness {
target := resource.DiscoveryTarget
if h == nil {
return servicediscovery.DiscoveryReadinessUnavailableForTarget(target, now, "Discovery handlers are not configured.")
}
if h.service != nil {
return h.service.DiscoveryReadinessForResource(resource, now)
}
if target == nil {
return servicediscovery.DiscoveryReadinessForTarget(nil, nil, nil, servicediscovery.DefaultConfig().MaxDiscoveryAge, now)
}
if _, ok := servicediscovery.DiscoveryResourceTypeForTarget(target); !ok {
return servicediscovery.DiscoveryReadinessForTarget(target, nil, nil, servicediscovery.DefaultConfig().MaxDiscoveryAge, now)
}
if mockfixtures.IsMockEnabled() {
resourceType, ok := servicediscovery.DiscoveryResourceTypeForTarget(target)
if !ok {
return servicediscovery.DiscoveryReadinessForTarget(target, nil, nil, servicediscovery.DefaultConfig().MaxDiscoveryAge, now)
}
discovery := mockDiscoveryFixtureByResource(resourceType, target.AgentID, target.ResourceID)
return servicediscovery.DiscoveryReadinessForTarget(target, discovery, nil, servicediscovery.DefaultConfig().MaxDiscoveryAge, now)
}
return servicediscovery.DiscoveryReadinessUnavailableForTarget(target, now, "Discovery service is not configured.")
}
// SetAIConfigProvider sets the AI config provider for showing AI provider info.
func (h *DiscoveryHandlers) SetAIConfigProvider(provider AIConfigProvider) {
h.aiConfigProvider = provider
}
// getAIProviderInfo returns info about the current AI provider for discovery.
func (h *DiscoveryHandlers) getAIProviderInfo() *servicediscovery.AIProviderInfo {
if h.aiConfigProvider == nil {
return nil
}
aiCfg := h.aiConfigProvider.GetAIConfig()
if aiCfg == nil || !aiCfg.Enabled {
return nil
}
// Get the discovery model
model := aiCfg.GetDiscoveryModel()
if model == "" {
return nil
}
// Parse the model to get provider
provider, modelName := config.ParseModelString(model)
// Determine if local
isLocal := provider == config.AIProviderOllama
// Build human-readable label
var label string
switch provider {
case config.AIProviderOllama:
label = "Local (Ollama)"
case config.AIProviderAnthropic:
label = "Cloud (Anthropic)"
case config.AIProviderOpenAI:
label = "Cloud (OpenAI)"
case config.AIProviderOpenRouter:
label = "Cloud (OpenRouter)"
case config.AIProviderDeepSeek:
label = "Cloud (DeepSeek)"
case config.AIProviderGemini:
label = "Cloud (Google Gemini)"
default:
label = "Cloud (" + provider + ")"
}
return &servicediscovery.AIProviderInfo{
Provider: provider,
Model: modelName,
IsLocal: isLocal,
Label: label,
}
}
// writeDiscoveryJSON writes a JSON response.
func writeDiscoveryJSON(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
// writeDiscoveryError writes a JSON error response.
func writeDiscoveryError(w http.ResponseWriter, statusCode int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(map[string]any{
"error": true,
"message": message,
})
}
func parseDiscoveryResourceType(raw string) (servicediscovery.ResourceType, error) {
trimmed := strings.ToLower(strings.TrimSpace(raw))
if trimmed == "" {
return "", fmt.Errorf("resource type is required")
}
normalized := servicediscovery.NormalizeResourceType(servicediscovery.ResourceType(trimmed))
if !isSupportedDiscoveryResourceType(normalized) {
return "", fmt.Errorf("unsupported resource type %q", trimmed)
}
return normalized, nil
}
func isSupportedDiscoveryResourceType(resourceType servicediscovery.ResourceType) bool {
switch resourceType {
case servicediscovery.ResourceTypeVM,
servicediscovery.ResourceTypeSystemContainer,
servicediscovery.ResourceTypeDocker,
servicediscovery.ResourceTypeK8s,
servicediscovery.ResourceTypeAgent,
servicediscovery.ResourceTypeDockerVM,
servicediscovery.ResourceTypeDockerSystemContainer:
return true
default:
return false
}
}
// isAdminRequest checks whether the request has privileged admin access for
// discovery secret operations. This is intentionally stricter than general
// authentication: session users must match configured admin identity and
// API tokens must include settings:write.
func (h *DiscoveryHandlers) isAdminRequest(r *http.Request) bool {
// Dev mode bypass - treat all requests as admin when enabled
if adminBypassEnabled() {
return true
}
if h.config == nil {
return false // Default to non-admin if no config
}
// 1. If using proxy auth, check the admin role
if h.config.ProxyAuthSecret != "" {
if valid, _, isAdmin := CheckProxyAuth(h.config, r); valid {
return isAdmin
}
return false
}
// 2. Check for basic auth (Pulse single-user admin credential)
if username, password, ok := r.BasicAuth(); ok {
configuredUser := strings.TrimSpace(h.config.AuthUser)
configuredHash := strings.TrimSpace(h.config.AuthPass)
if configuredUser != "" && configuredHash != "" &&
username == configuredUser &&
internalauth.CheckPasswordHash(password, configuredHash) {
return true
}
}
// 3. Check for an admin session (OIDC/SAML/local session). The admin test is
// sessionUserCarriesAdminPrivileges, the same one the settings routes apply,
// so both the configured local administrator and an explicit RBAC admin
// grant count. Comparing against h.config.AuthUser alone cannot recognize an
// explicitly authorized SSO administrator.
if cookie, err := readSessionCookie(r); err == nil && cookie.Value != "" {
if ValidateSession(cookie.Value) {
sessionUser := strings.TrimSpace(GetSessionUsername(cookie.Value))
if sessionUserCarriesAdminPrivileges(h.config, sessionUser) {
return true
}
}
}
// 4. Check API tokens; only settings:write tokens are admin-capable here.
if tokenRecord := getAPITokenRecordFromRequest(r); tokenRecord != nil {
return tokenRecord.HasScope(config.ScopeSettingsWrite)
}
validateTokenAsAdmin := func(raw string) bool {
if strings.TrimSpace(raw) == "" {
return false
}
config.Mu.Lock()
record, ok := h.config.ValidateAPIToken(raw)
config.Mu.Unlock()
return ok && record != nil && record.HasScope(config.ScopeSettingsWrite)
}
if token := strings.TrimSpace(r.Header.Get("X-API-Token")); token != "" {
if validateTokenAsAdmin(token) {
return true
}
}
if bearer := extractBearerToken(r.Header.Get("Authorization")); bearer != "" {
if validateTokenAsAdmin(bearer) {
return true
}
}
return false
}
func (h *DiscoveryHandlers) requireCommandScanningAccess(w http.ResponseWriter, r *http.Request) bool {
if !h.isAdminRequest(r) {
writeDiscoveryError(w, http.StatusForbidden, "Admin privileges required")
return false
}
if h.service == nil || !h.service.IsCommandScanningEnabled() {
writeDiscoveryError(w, http.StatusForbidden, "Discovery command scanning is disabled")
return false
}
return true
}
// redactSensitiveFields removes sensitive data from a discovery for non-admin users.
// This creates a copy to avoid modifying the original.
func redactSensitiveFields(d *servicediscovery.ResourceDiscovery) *servicediscovery.ResourceDiscovery {
if d == nil {
return nil
}
// Create a shallow copy
redacted := *d
// Redact sensitive fields
redacted.UserSecrets = nil // Never expose to non-admins
redacted.RawCommandOutput = nil // May contain sensitive output
return &redacted
}
func discoverySummaryResponse(d *servicediscovery.ResourceDiscovery) servicediscovery.DiscoverySummary {
if d == nil {
return servicediscovery.DiscoverySummary{}
}
return d.ToSummary()
}
func discoveryDetailResponse(d *servicediscovery.ResourceDiscovery) *servicediscovery.ResourceDiscovery {
return d
}
func mockDiscoveryFixtureToService(fixture *mockfixtures.DiscoveryFixture) *servicediscovery.ResourceDiscovery {
if fixture == nil {
return nil
}
facts := make([]servicediscovery.DiscoveryFact, 0, len(fixture.Facts))
for _, fact := range fixture.Facts {
facts = append(facts, servicediscovery.DiscoveryFact{
Category: servicediscovery.FactCategory(fact.Category),
Key: fact.Key,
Value: fact.Value,
Source: fact.Source,
Confidence: fact.Confidence,
DiscoveredAt: fact.DiscoveredAt,
})
}
ports := make([]servicediscovery.PortInfo, 0, len(fixture.Ports))
for _, port := range fixture.Ports {
ports = append(ports, servicediscovery.PortInfo{
Port: port.Port,
Protocol: port.Protocol,
Process: port.Process,
Address: port.Address,
})
}
mounts := make([]servicediscovery.DockerBindMount, 0, len(fixture.DockerMounts))
for _, mount := range fixture.DockerMounts {
mounts = append(mounts, servicediscovery.DockerBindMount{
ContainerName: mount.ContainerName,
Source: mount.Source,
Destination: mount.Destination,
Type: mount.Type,
ReadOnly: mount.ReadOnly,
})
}
userSecrets := map[string]string{}
for key, value := range fixture.UserSecrets {
userSecrets[key] = value
}
discovery := &servicediscovery.ResourceDiscovery{
ID: fixture.ID,
ResourceType: servicediscovery.ResourceType(fixture.ResourceType),
ResourceID: fixture.ResourceID,
TargetID: fixture.TargetID,
AgentID: fixture.AgentID,
Hostname: fixture.Hostname,
ServiceType: fixture.ServiceType,
ServiceName: fixture.ServiceName,
ServiceVersion: fixture.ServiceVersion,
Category: servicediscovery.ServiceCategory(fixture.Category),
CLIAccess: fixture.CLIAccess,
Facts: facts,
ConfigPaths: append([]string(nil), fixture.ConfigPaths...),
DataPaths: append([]string(nil), fixture.DataPaths...),
LogPaths: append([]string(nil), fixture.LogPaths...),
Ports: ports,
DockerMounts: mounts,
UserNotes: fixture.UserNotes,
UserSecrets: userSecrets,
Confidence: fixture.Confidence,
AIReasoning: fixture.AIReasoning,
DiscoveredAt: fixture.DiscoveredAt,
UpdatedAt: fixture.UpdatedAt,
ScanDuration: fixture.ScanDuration,
Fingerprint: fixture.Fingerprint,
FingerprintedAt: fixture.FingerprintedAt,
FingerprintSchemaVersion: fixture.FingerprintSchemaVersion,
CLIAccessVersion: fixture.CLIAccessVersion,
SuggestedURL: fixture.SuggestedURL,
SuggestedURLSourceCode: fixture.SuggestedURLSourceCode,
SuggestedURLSourceDetail: fixture.SuggestedURLSourceDetail,
SuggestedURLDiagnostic: fixture.SuggestedURLDiagnostic,
}
discovery.SuggestedAvailabilityProbe = servicediscovery.SuggestAvailabilityProbe(discovery, fixture.Hostname)
return discovery
}
func mockDiscoveryFixturesToService(fixtures []*mockfixtures.DiscoveryFixture) []*servicediscovery.ResourceDiscovery {
converted := make([]*servicediscovery.ResourceDiscovery, 0, len(fixtures))
for _, fixture := range fixtures {
if discovery := mockDiscoveryFixtureToService(fixture); discovery != nil {
converted = append(converted, discovery)
}
}
return converted
}
func mergeDiscoveryRecords(primary, fixtures []*servicediscovery.ResourceDiscovery) []*servicediscovery.ResourceDiscovery {
if len(primary) == 0 {
return fixtures
}
if len(fixtures) == 0 {
return primary
}
merged := make([]*servicediscovery.ResourceDiscovery, 0, len(primary)+len(fixtures))
seen := make(map[string]struct{}, len(primary)+len(fixtures))
add := func(discovery *servicediscovery.ResourceDiscovery) {
if discovery == nil {
return
}
key := strings.TrimSpace(discovery.ID)
if key == "" {
key = servicediscovery.MakeResourceID(discovery.ResourceType, discovery.TargetID, discovery.ResourceID)
}
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
merged = append(merged, discovery)
}
for _, discovery := range primary {
add(discovery)
}
for _, discovery := range fixtures {
add(discovery)
}
return merged
}
func discoverySummaryList(discoveries []*servicediscovery.ResourceDiscovery) []servicediscovery.DiscoverySummary {
summaries := make([]servicediscovery.DiscoverySummary, 0, len(discoveries))
for _, d := range discoveries {
if d == nil {
continue
}
summaries = append(summaries, discoverySummaryResponse(d))
}
return summaries
}
func mockDiscoveryFixtures() []*servicediscovery.ResourceDiscovery {
if !mockfixtures.IsMockEnabled() {
return nil
}
return mockDiscoveryFixturesToService(mockfixtures.CurrentDiscoveryFixtures())
}
func mockDiscoveryFixtureByResource(resourceType servicediscovery.ResourceType, targetID, resourceID string) *servicediscovery.ResourceDiscovery {
if !mockfixtures.IsMockEnabled() {
return nil
}
return mockDiscoveryFixtureToService(mockfixtures.CurrentDiscoveryFixtureByResource(string(resourceType), targetID, resourceID))
}
func mockDiscoveryFixturesByType(resourceType servicediscovery.ResourceType) []*servicediscovery.ResourceDiscovery {
if !mockfixtures.IsMockEnabled() {
return nil
}
return mockDiscoveryFixturesToService(mockfixtures.CurrentDiscoveryFixturesByType(string(resourceType)))
}
func mockDiscoveryFixturesByTarget(targetID string) []*servicediscovery.ResourceDiscovery {
if !mockfixtures.IsMockEnabled() {
return nil
}
return mockDiscoveryFixturesToService(mockfixtures.CurrentDiscoveryFixturesByTarget(targetID))
}
func mockDiscoveryStatus() map[string]any {
fixtures := mockDiscoveryFixtures()
var latest time.Time
for _, discovery := range fixtures {
if discovery != nil && discovery.UpdatedAt.After(latest) {
latest = discovery.UpdatedAt
}
}
return map[string]any{
"running": false,
"last_run": latest,
"interval": "mock",
"cache_size": len(fixtures),
"ai_analyzer_set": false,
"scanner_set": false,
"store_set": true,
"command_scanning": false,
"deep_scan_timeout": "",
"ai_analysis_timeout": "",
"max_discovery_age": "",
"fingerprint_count": len(fixtures),
"last_fingerprint_scan": latest,
"changed_count": 0,
"stale_count": 0,
}
}
// HandleListDiscoveries handles GET /api/discovery
func (h *DiscoveryHandlers) HandleListDiscoveries(w http.ResponseWriter, r *http.Request) {
if h.service == nil && !mockfixtures.IsMockEnabled() {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
var discoveries []*servicediscovery.ResourceDiscovery
if h.service != nil {
serviceDiscoveries, err := h.service.ListDiscoveries()
if err != nil {
log.Error().Err(err).Msg("Failed to list discoveries")
writeDiscoveryError(w, http.StatusInternalServerError, "Failed to list discoveries")
return
}
discoveries = serviceDiscoveries
}
summaries := discoverySummaryList(mergeDiscoveryRecords(discoveries, mockDiscoveryFixtures()))
writeDiscoveryJSON(w, map[string]any{
"discoveries": summaries,
"total": len(summaries),
})
}
// HandleGetDiscovery handles GET /api/discovery/{type}/{target}/{id}
func (h *DiscoveryHandlers) HandleGetDiscovery(w http.ResponseWriter, r *http.Request) {
// Parse path: /api/discovery/{type}/{target}/{id}
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/")
parts := strings.SplitN(path, "/", 3)
if len(parts) < 3 {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid path: expected /api/discovery/{type}/{target}/{id}")
return
}
resourceType, err := parseDiscoveryResourceType(parts[0])
if err != nil {
writeDiscoveryError(w, http.StatusBadRequest, err.Error())
return
}
targetID := parts[1]
resourceID := parts[2]
var discovery *servicediscovery.ResourceDiscovery
if h.service != nil {
serviceDiscovery, err := h.service.GetDiscoveryByResource(resourceType, targetID, resourceID)
if err != nil {
log.Error().Err(err).Str("type", string(resourceType)).Str("target", targetID).Str("id", resourceID).Msg("Failed to get discovery")
writeDiscoveryError(w, http.StatusInternalServerError, "Failed to get discovery")
return
}
discovery = serviceDiscovery
} else if !mockfixtures.IsMockEnabled() {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
if discovery == nil {
discovery = mockDiscoveryFixtureByResource(resourceType, targetID, resourceID)
if discovery == nil {
writeDiscoveryError(w, http.StatusNotFound, "Discovery not found")
return
}
}
// Redact sensitive fields for non-admin users
if !h.isAdminRequest(r) {
discovery = redactSensitiveFields(discovery)
}
writeDiscoveryJSON(w, discoveryDetailResponse(discovery))
}
// HandleTriggerDiscovery handles POST /api/discovery/{type}/{target}/{id}
func (h *DiscoveryHandlers) HandleTriggerDiscovery(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
if !h.requireCommandScanningAccess(w, r) {
return
}
// Parse path
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/")
parts := strings.SplitN(path, "/", 3)
if len(parts) < 3 {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid path: expected /api/discovery/{type}/{target}/{id}")
return
}
resourceType, err := parseDiscoveryResourceType(parts[0])
if err != nil {
writeDiscoveryError(w, http.StatusBadRequest, err.Error())
return
}
targetID := parts[1]
resourceID := parts[2]
// Parse optional request body for force flag and hostname
var reqBody struct {
Force bool `json:"force"`
Hostname string `json:"hostname"`
}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&reqBody)
}
// Build discovery request
req := servicediscovery.DiscoveryRequest{
ResourceType: resourceType,
ResourceID: resourceID,
TargetID: targetID,
Hostname: reqBody.Hostname,
Force: reqBody.Force,
}
// If hostname not provided for host-agent discovery, use target ID as a
// fallback. Workloads should resolve their own display name from state so a
// parent node is not mistaken for the workload endpoint.
if req.Hostname == "" && resourceType == servicediscovery.ResourceTypeAgent {
req.Hostname = targetID
}
discovery, err := h.service.DiscoverResource(r.Context(), req)
if err != nil {
log.Error().Err(err).
Str("type", string(resourceType)).
Str("target", targetID).
Str("id", resourceID).
Msg("Failed to trigger discovery")
writeDiscoveryError(w, http.StatusInternalServerError, "Discovery failed: "+err.Error())
return
}
// Redact sensitive fields for non-admin users
if !h.isAdminRequest(r) {
discovery = redactSensitiveFields(discovery)
}
writeDiscoveryJSON(w, discoveryDetailResponse(discovery))
}
// HandleUpdateNotes handles PUT /api/discovery/{type}/{target}/{id}/notes
func (h *DiscoveryHandlers) HandleUpdateNotes(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
// Parse path
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/")
path = strings.TrimSuffix(path, "/notes")
parts := strings.SplitN(path, "/", 3)
if len(parts) < 3 {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid path")
return
}
resourceType, err := parseDiscoveryResourceType(parts[0])
if err != nil {
writeDiscoveryError(w, http.StatusBadRequest, err.Error())
return
}
targetID := parts[1]
resourceID := parts[2]
// Build the full ID
discoveryID := servicediscovery.MakeResourceID(resourceType, targetID, resourceID)
// Parse request body
var req servicediscovery.UpdateNotesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Only admins can set user_secrets (contains sensitive data like API tokens)
isAdmin := h.isAdminRequest(r)
if !isAdmin && len(req.UserSecrets) > 0 {
writeDiscoveryError(w, http.StatusForbidden, "Only admins can set user_secrets")
return
}
if err := h.service.UpdateNotes(discoveryID, req.UserNotes, req.UserSecrets); err != nil {
log.Error().Err(err).Str("id", discoveryID).Msg("Failed to update notes")
writeDiscoveryError(w, http.StatusInternalServerError, "Failed to update notes: "+err.Error())
return
}
// Return updated discovery
discovery, err := h.service.GetDiscovery(discoveryID)
if err != nil {
writeDiscoveryError(w, http.StatusInternalServerError, "Notes updated but failed to fetch result")
return
}
// Redact sensitive fields for non-admin users
if !isAdmin {
discovery = redactSensitiveFields(discovery)
}
writeDiscoveryJSON(w, discoveryDetailResponse(discovery))
}
// HandleUpdateAvailabilityProposal handles
// PUT /api/discovery/{type}/{target}/{id}/availability-proposal. It records a
// review disposition only; canonical checks are still created explicitly via
// POST /api/availability-targets.
func (h *DiscoveryHandlers) HandleUpdateAvailabilityProposal(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/")
path = strings.TrimSuffix(path, "/availability-proposal")
parts := strings.SplitN(path, "/", 3)
if len(parts) < 3 {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid path")
return
}
resourceType, err := parseDiscoveryResourceType(parts[0])
if err != nil {
writeDiscoveryError(w, http.StatusBadRequest, err.Error())
return
}
discoveryID := servicediscovery.MakeResourceID(resourceType, parts[1], parts[2])
var req servicediscovery.UpdateAvailabilityProposalRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4*1024))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid request body")
return
}
status := strings.ToLower(strings.TrimSpace(req.Status))
if status != "dismissed" && status != "reviewable" {
writeDiscoveryError(w, http.StatusBadRequest, "status must be dismissed or reviewable")
return
}
if err := h.service.UpdateAvailabilityProposalDisposition(
discoveryID,
strings.TrimSpace(req.EvidenceFingerprint),
status == "dismissed",
); err != nil {
switch {
case errors.Is(err, servicediscovery.ErrAvailabilityProposalNotFound):
writeDiscoveryError(w, http.StatusNotFound, err.Error())
case errors.Is(err, servicediscovery.ErrAvailabilityProposalEvidenceChanged):
writeDiscoveryError(w, http.StatusConflict, err.Error())
default:
log.Error().Err(err).Str("id", discoveryID).Msg("Failed to update availability proposal")
writeDiscoveryError(w, http.StatusInternalServerError, "Failed to update availability proposal")
}
return
}
discovery, err := h.service.GetDiscovery(discoveryID)
if err != nil || discovery == nil {
writeDiscoveryError(w, http.StatusInternalServerError, "Proposal updated but failed to fetch result")
return
}
if !h.isAdminRequest(r) {
discovery = redactSensitiveFields(discovery)
}
writeDiscoveryJSON(w, discoveryDetailResponse(discovery))
}
// HandleDeleteDiscovery handles DELETE /api/discovery/{type}/{target}/{id}
func (h *DiscoveryHandlers) HandleDeleteDiscovery(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
// Parse path
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/")
parts := strings.SplitN(path, "/", 3)
if len(parts) < 3 {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid path")
return
}
resourceType, err := parseDiscoveryResourceType(parts[0])
if err != nil {
writeDiscoveryError(w, http.StatusBadRequest, err.Error())
return
}
targetID := parts[1]
resourceID := parts[2]
discoveryID := servicediscovery.MakeResourceID(resourceType, targetID, resourceID)
if err := h.service.DeleteDiscovery(discoveryID); err != nil {
log.Error().Err(err).Str("id", discoveryID).Msg("Failed to delete discovery")
writeDiscoveryError(w, http.StatusInternalServerError, "Failed to delete discovery")
return
}
writeDiscoveryJSON(w, map[string]any{"success": true, "id": discoveryID})
}
// HandleGetProgress handles GET /api/discovery/{type}/{target}/{id}/progress
func (h *DiscoveryHandlers) HandleGetProgress(w http.ResponseWriter, r *http.Request) {
// Parse path
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/")
path = strings.TrimSuffix(path, "/progress")
parts := strings.SplitN(path, "/", 3)
if len(parts) < 3 {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid path")
return
}
resourceType, err := parseDiscoveryResourceType(parts[0])
if err != nil {
writeDiscoveryError(w, http.StatusBadRequest, err.Error())
return
}
targetID := parts[1]
resourceID := parts[2]
discoveryID := servicediscovery.MakeResourceID(resourceType, targetID, resourceID)
var progress *servicediscovery.DiscoveryProgress
if h.service != nil {
progress = h.service.GetProgress(discoveryID)
} else if !mockfixtures.IsMockEnabled() {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
if progress == nil {
// Not currently scanning - check if we have a discovery
var discovery *servicediscovery.ResourceDiscovery
if h.service != nil {
serviceDiscovery, err := h.service.GetDiscovery(discoveryID)
if err == nil {
discovery = serviceDiscovery
}
}
if discovery == nil {
discovery = mockDiscoveryFixtureByResource(resourceType, targetID, resourceID)
}
if discovery != nil {
// Return completed status with all fields for frontend compatibility
writeDiscoveryJSON(w, map[string]any{
"resource_id": discoveryID,
"status": "completed",
"current_step": "",
"total_steps": 0,
"completed_steps": 0,
"started_at": discovery.DiscoveredAt,
"updated_at": discovery.UpdatedAt,
})
return
}
// Return not_started status with all fields for frontend compatibility
writeDiscoveryJSON(w, map[string]any{
"resource_id": discoveryID,
"status": "not_started",
"current_step": "",
"total_steps": 0,
"completed_steps": 0,
"started_at": "",
})
return
}
writeDiscoveryJSON(w, progress)
}
// HandleGetStatus handles GET /api/discovery/status
func (h *DiscoveryHandlers) HandleGetStatus(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
if mockfixtures.IsMockEnabled() {
writeDiscoveryJSON(w, mockDiscoveryStatus())
return
}
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
status := h.service.GetStatus()
// Add fingerprint change/stale stats
changedCount, _ := h.service.GetChangedResourceCount()
staleCount, _ := h.service.GetStaleResourceCount()
status["changed_count"] = changedCount // Containers with changed fingerprints
status["stale_count"] = staleCount // Discoveries > 30 days old
writeDiscoveryJSON(w, status)
}
// HandleRunDiscovery handles POST /api/discovery/run.
func (h *DiscoveryHandlers) HandleRunDiscovery(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
if !h.requireCommandScanningAccess(w, r) {
return
}
summary, err := h.service.RunManualDiscoveryRefresh(r.Context())
if err != nil {
log.Error().Err(err).Msg("Failed to run manual discovery refresh")
writeDiscoveryError(w, http.StatusInternalServerError, "Discovery refresh failed: "+err.Error())
return
}
writeDiscoveryJSON(w, summary)
}
// HandleUpdateSettings handles PUT /api/discovery/settings
// Allows updating discovery settings like the staleness threshold.
func (h *DiscoveryHandlers) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
// Require admin privileges
if !h.isAdminRequest(r) {
writeDiscoveryError(w, http.StatusForbidden, "Admin privileges required")
return
}
var req struct {
MaxDiscoveryAgeDays int `json:"max_discovery_age_days"` // Days before rediscovery
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Update settings
if req.MaxDiscoveryAgeDays > 0 {
h.service.SetMaxDiscoveryAge(time.Duration(req.MaxDiscoveryAgeDays) * 24 * time.Hour)
log.Info().Int("days", req.MaxDiscoveryAgeDays).Msg("Max discovery age updated via API")
}
// Return updated status
status := h.service.GetStatus()
changedCount, _ := h.service.GetChangedResourceCount()
staleCount, _ := h.service.GetStaleResourceCount()
status["changed_count"] = changedCount
status["stale_count"] = staleCount
writeDiscoveryJSON(w, status)
}
// HandleListByType handles GET /api/discovery/type/{type}
func (h *DiscoveryHandlers) HandleListByType(w http.ResponseWriter, r *http.Request) {
// Parse path
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/type/")
resourceType, err := parseDiscoveryResourceType(path)
if err != nil {
writeDiscoveryError(w, http.StatusBadRequest, err.Error())
return
}
var discoveries []*servicediscovery.ResourceDiscovery
if h.service != nil {
serviceDiscoveries, err := h.service.ListDiscoveriesByType(resourceType)
if err != nil {
log.Error().Err(err).Str("type", string(resourceType)).Msg("Failed to list discoveries by type")
writeDiscoveryError(w, http.StatusInternalServerError, "Failed to list discoveries")
return
}
discoveries = serviceDiscoveries
} else if !mockfixtures.IsMockEnabled() {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
summaries := discoverySummaryList(mergeDiscoveryRecords(discoveries, mockDiscoveryFixturesByType(resourceType)))
writeDiscoveryJSON(w, map[string]any{
"discoveries": summaries,
"total": len(summaries),
"type": resourceType,
})
}
// HandleListByAgent handles GET /api/discovery/agent/{agentId}
func (h *DiscoveryHandlers) HandleListByAgent(w http.ResponseWriter, r *http.Request) {
// Parse path
agentID := strings.TrimPrefix(r.URL.Path, "/api/discovery/agent/")
var discoveries []*servicediscovery.ResourceDiscovery
if h.service != nil {
serviceDiscoveries, err := h.service.ListDiscoveriesByTarget(agentID)
if err != nil {
log.Error().Err(err).Str("agentId", agentID).Msg("Failed to list discoveries by agent")
writeDiscoveryError(w, http.StatusInternalServerError, "Failed to list discoveries")
return
}
discoveries = serviceDiscoveries
} else if !mockfixtures.IsMockEnabled() {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
summaries := discoverySummaryList(mergeDiscoveryRecords(discoveries, mockDiscoveryFixturesByTarget(agentID)))
writeDiscoveryJSON(w, map[string]any{
"discoveries": summaries,
"total": len(summaries),
"agentId": agentID,
})
}
// HandleGetInfo handles GET /api/discovery/info/{type}
// Returns metadata about the discovery process: AI provider info and commands that will run.
func (h *DiscoveryHandlers) HandleGetInfo(w http.ResponseWriter, r *http.Request) {
// Parse resource type from path
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/info/")
resourceType, err := parseDiscoveryResourceType(path)
if err != nil {
writeDiscoveryError(w, http.StatusBadRequest, err.Error())
return
}
// Get commands for this resource type
commands := servicediscovery.GetCommandsForResource(resourceType)
categories := servicediscovery.GetCommandCategories(resourceType)
// Get AI provider info
aiProvider := h.getAIProviderInfo()
info := servicediscovery.DiscoveryInfo{
AIProvider: aiProvider,
Commands: commands,
CommandCategories: categories,
}
writeDiscoveryJSON(w, info)
}