From 142236d797124bf02cda9961ab82f3c25f8497b8 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 20 May 2026 14:54:19 +0100 Subject: [PATCH] Seed mock Discovery fixtures --- .../v6/internal/subsystems/agent-lifecycle.md | 8 + .../v6/internal/subsystems/api-contracts.md | 10 + .../v6/internal/subsystems/monitoring.md | 12 + .../internal/subsystems/storage-recovery.md | 6 + .../internal/subsystems/unified-resources.md | 8 + .../ResourceDetailDrawer.discovery.test.ts | 36 +- internal/api/contract_test.go | 83 ++ internal/api/discovery_handlers.go | 321 ++++++-- internal/api/discovery_handlers_test.go | 60 +- internal/api/resources.go | 38 + internal/api/resources_test.go | 51 ++ internal/mock/demo_scenarios_test.go | 42 + internal/mock/discovery_fixtures.go | 747 ++++++++++++++++++ internal/mock/discovery_fixtures_test.go | 102 +++ internal/mock/fixture_graph.go | 3 + .../release_control/subsystem_lookup_test.py | 4 +- 16 files changed, 1463 insertions(+), 68 deletions(-) create mode 100644 internal/mock/discovery_fixtures.go create mode 100644 internal/mock/discovery_fixtures_test.go diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 9ef5be6e9..af5c5b9c9 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -165,6 +165,14 @@ text unless they also hold action execution scope. Lifecycle UI and agents may use those redacted events as doorbells or status summaries, but they must fetch governed detail through the approval/action surfaces and must not treat a monitoring-readable event stream as command disclosure or execution authority. +Discovery context exposed by `/api/discovery` is similarly API-owned +observability context, not lifecycle command authority. Lifecycle and +infrastructure setup surfaces may display service versions, config paths, +ports, bind mounts, suggested URLs, and mock-mode examples from the Discovery +API, but they must not widen install tokens, agent profiles, or command grants +because a Discovery record exists. Mock-mode Discovery records are demo +payloads for the same API contract and must not become a lifecycle-local +fixture or an implicit permission to run commands. The node setup modal boundary must keep guided setup and manual credential submission separate. For new PVE/PBS setup, API Inventory and Host Telemetry diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index a246e2050..550cce29e 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -27,6 +27,7 @@ product API routes free of maintainer commercial analytics. ## Canonical Files 1. `internal/api/contract_test.go` 2. `internal/api/resources.go` +3. `internal/api/discovery_handlers.go` 3. `internal/api/alerts.go` 4. `internal/api/activity_audit_handlers.go` 5. `internal/api/actions.go` @@ -115,6 +116,15 @@ transport-only. It may amortize polling and remount cost, but it must not change normalized response shape, bypass monitor or read-state availability checks, merge tenants, or become the source of truth for telemetry freshness. +Discovery read endpoints are a canonical API payload boundary even when Pulse +is running in mock mode. `/api/discovery`, typed discovery detail/progress +routes, type filters, agent filters, and status must expose mock-authored +Discovery records through the same `servicediscovery.ResourceDiscovery` and +summary response shapes used by the live discovery service. Live service data +remains primary when a service is configured; mock fixtures may supplement or +stand in for that data only in mock mode, and they must not expose raw command +output or bypass the normal non-admin redaction path. + 1. `frontend-modern/src/api/agentProfiles.ts` shared with `agent-lifecycle`: the agent profiles frontend client is both an agent lifecycle control surface and a canonical API payload contract boundary. 2. `frontend-modern/src/api/ai.ts` shared with `ai-runtime`: the AI frontend client is both an AI runtime control surface and a canonical API payload contract boundary. 3. `frontend-modern/src/api/nodes.ts` shared with `agent-lifecycle`: the shared Proxmox node client is both an agent lifecycle setup/install control surface and a canonical API payload contract boundary. diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index fd1fc6ef1..d9b322a90 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -55,6 +55,7 @@ truth for live infrastructure data. 31. `internal/monitoring/availability_poller.go` 32. `internal/monitoring/scheduler.go` 33. `internal/monitoring/docker_detection.go` +34. `internal/mock/fixture_graph.go` ## Shared Boundaries @@ -100,6 +101,12 @@ truth for live infrastructure data. reports into `ApplyDockerReport`, must skip guests with a linked online guest-local host agent, and must keep the command set to minimal read-only Docker summary and aggregate stats collection. +15. Add or change mock-mode Discovery context through the canonical mock + fixture graph. Mock Discovery records must be derived from the same authored + state graph as mock nodes, guests, Docker hosts, containers, and Kubernetes + workloads, then exposed through API-owned Discovery handlers. Monitoring + must not create a second frontend-only fixture path for service versions, + config paths, bind mounts, ports, or suggested URLs. ## Forbidden Paths @@ -153,6 +160,11 @@ Mock-mode availability targets must use that same provider vocabulary. The mock fixture graph may author ping/TCP/HTTP endpoint examples, but monitoring and API consumers must receive them through `SourceAvailability` supplemental records and probe-status projections, not through a mock-only monitoring type. +Mock-mode Discovery context follows the same fixture-graph rule. Demo service +details such as detected version, config/data/log paths, Docker bind mounts, +ports, and suggested web URLs may be authored in mock fixtures, but consumers +must receive them through the normal Discovery API contract rather than through +frontend-only demo data or a monitoring-only side channel. That same monitoring boundary also owns the escalation callback bridge into the alerts delivery layer. Monitor-owned escalation handling may still publish canonical escalation state to websocket consumers, but notification fan-out diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 5ebe61f36..aaba432fa 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -132,6 +132,12 @@ driver runs. Dry-run-only plans remain planning evidence only; storage and recovery surfaces must not present them as executable, dispatch them through provider-local restore/remediation paths, or bypass the API fail-closed execution gate. +Storage and recovery surfaces may consume Discovery context from the shared +API boundary when it helps explain protected workloads or storage-adjacent +services, including mock-mode config/data/log path examples. That context is +read-only evidence: it must not become a storage/recovery-owned command path, +secret source, restore entitlement, or frontend-only fixture separate from the +canonical `/api/discovery` payload. 1. Add or change recovery-point persistence, rollups, or series derivation through `internal/recovery/` 2. Add or change recovery page UX through `frontend-modern/src/components/Recovery/` and keep canonical route/query/filter state ownership in `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts` diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index d6fe3148a..0df1655a6 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -156,6 +156,14 @@ Swarm capability surfaces. normalization so client-side grouping behaves identically against mock fixtures and live backends. 17. `internal/api/resources.go` shared with `api-contracts`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary. + App-container Discovery targets are part of that shared payload contract. + Backend resources must expose frontend-facing `resourceType=app-container` + with the reporting Docker/Podman agent id and the stable container name as + the target resource id; browser Discovery clients are responsible for + translating that frontend resource type to the Discovery API's `docker` + route type. Docker host resources must likewise prefer the reported agent + id over host labels when building host-level Discovery targets, so detail + drawers, websocket hydration, and API lookups use the same identity. ## Extension Points 1. Add new resource types and identity fields in `internal/unifiedresources/types.go` diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts index 1554f558a..c97ccaf77 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.discovery.test.ts @@ -188,6 +188,41 @@ describe('toDiscoveryConfig', () => { }); }); + it('keeps backend app-container discoveryTarget identity for Docker workloads', () => { + const resource: Resource = { + ...baseResource(), + id: 'resource:app-container:customer-portal', + type: 'app-container', + name: 'customer-portal', + platformType: 'docker', + discoveryTarget: { + resourceType: 'app-container', + agentId: 'agent-edge-01', + resourceId: 'customer-portal', + hostname: 'edge-apps-01', + }, + platformData: { + sources: ['docker'], + docker: { + hostSourceId: 'agent-edge-01', + containerId: 'abc123def456', + hostname: 'edge-apps-01', + }, + }, + }; + + const config = toDiscoveryConfig(resource); + expect(config).toEqual({ + resourceType: 'app-container', + agentId: 'agent-edge-01', + resourceId: 'customer-portal', + hostname: 'edge-apps-01', + metadataKind: 'guest', + metadataId: 'customer-portal', + targetLabel: 'container', + }); + }); + it('prefers kubernetes cluster/pod IDs for pod fallback mapping', () => { const resource: Resource = { ...baseResource(), @@ -255,4 +290,3 @@ describe('toDiscoveryConfig', () => { }); }); }); - diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index b9e24ca32..6433337b9 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -103,6 +103,89 @@ func TestContract_MockAvailabilityTargetsUseSavedTargetPayloads(t *testing.T) { } } +func TestContract_MockDiscoveryEndpointsUseCanonicalPayloads(t *testing.T) { + previousMock := mock.IsMockEnabled() + if err := mock.SetEnabled(true); err != nil { + t.Fatalf("enable mock mode: %v", err) + } + t.Cleanup(func() { _ = mock.SetEnabled(previousMock) }) + + handlers := NewDiscoveryHandlers(nil, nil) + listReq := httptest.NewRequest(http.MethodGet, "/api/discovery", nil) + listRec := httptest.NewRecorder() + handlers.HandleListDiscoveries(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("/api/discovery status = %d, body=%s", listRec.Code, listRec.Body.String()) + } + + var listBody struct { + Discoveries []map[string]any `json:"discoveries"` + Total int `json:"total"` + } + if err := json.NewDecoder(listRec.Body).Decode(&listBody); err != nil { + t.Fatalf("decode discovery list: %v", err) + } + if listBody.Total == 0 || len(listBody.Discoveries) == 0 { + t.Fatal("expected mock discovery list to expose canonical discovery summaries") + } + + var dockerDetail map[string]any + for _, summary := range listBody.Discoveries { + if summary["resource_type"] != "docker" { + continue + } + resourceType, _ := summary["resource_type"].(string) + targetID, _ := summary["target_id"].(string) + resourceID, _ := summary["resource_id"].(string) + if resourceType == "" || targetID == "" || resourceID == "" { + t.Fatalf("mock discovery summary lost canonical identity: %#v", summary) + } + + detailReq := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/discovery/%s/%s/%s", resourceType, targetID, resourceID), nil) + detailRec := httptest.NewRecorder() + handlers.HandleGetDiscovery(detailRec, detailReq) + if detailRec.Code != http.StatusOK { + t.Fatalf("discovery detail status = %d, body=%s", detailRec.Code, detailRec.Body.String()) + } + + var detail map[string]any + if err := json.NewDecoder(detailRec.Body).Decode(&detail); err != nil { + t.Fatalf("decode discovery detail: %v", err) + } + if !discoveryContractValueEmpty(detail["suggested_url"]) { + dockerDetail = detail + break + } + } + if dockerDetail == nil { + t.Fatalf("expected mock discovery summaries to include Docker workload URL context: %#v", listBody.Discoveries) + } + for _, key := range []string{"service_name", "service_version", "config_paths", "ports", "suggested_url", "cli_access_version"} { + value, ok := dockerDetail[key] + if !ok || discoveryContractValueEmpty(value) { + t.Fatalf("mock discovery detail missing %s: %#v", key, dockerDetail) + } + } + if _, ok := dockerDetail["raw_command_output"]; ok { + t.Fatalf("mock discovery detail exposed raw command output: %#v", dockerDetail) + } +} + +func discoveryContractValueEmpty(value any) bool { + switch typed := value.(type) { + case nil: + return true + case string: + return strings.TrimSpace(typed) == "" + case []any: + return len(typed) == 0 + case float64: + return typed == 0 + default: + return false + } +} + func TestPatrolRemediationCommercialCopyUsesSafeRemediationWording(t *testing.T) { files := []string{"ai_handlers.go", "router_routes_ai_relay.go"} for _, file := range files { diff --git a/internal/api/discovery_handlers.go b/internal/api/discovery_handlers.go index cc9ec6e2b..35834c00e 100644 --- a/internal/api/discovery_handlers.go +++ b/internal/api/discovery_handlers.go @@ -8,6 +8,7 @@ import ( "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" internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/rs/zerolog/log" @@ -249,21 +250,125 @@ func discoveryDetailResponse(d *servicediscovery.ResourceDiscovery) *servicedisc return d } -// HandleListDiscoveries handles GET /api/discovery -func (h *DiscoveryHandlers) HandleListDiscoveries(w http.ResponseWriter, r *http.Request) { - if h.service == nil { - writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured") - return +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 } - discoveries, 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 + return &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, + } +} + +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 } - // Convert to summaries for list view + 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 { @@ -271,6 +376,83 @@ func (h *DiscoveryHandlers) HandleListDiscoveries(w http.ResponseWriter, r *http } 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, @@ -280,11 +462,6 @@ func (h *DiscoveryHandlers) HandleListDiscoveries(w http.ResponseWriter, r *http // HandleGetDiscovery handles GET /api/discovery/{type}/{target}/{id} func (h *DiscoveryHandlers) HandleGetDiscovery(w http.ResponseWriter, r *http.Request) { - if h.service == nil { - writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured") - return - } - // Parse path: /api/discovery/{type}/{target}/{id} path := strings.TrimPrefix(r.URL.Path, "/api/discovery/") parts := strings.SplitN(path, "/", 3) @@ -301,16 +478,26 @@ func (h *DiscoveryHandlers) HandleGetDiscovery(w http.ResponseWriter, r *http.Re targetID := parts[1] resourceID := parts[2] - discovery, 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") + 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 { - writeDiscoveryError(w, http.StatusNotFound, "Discovery not found") - return + discovery = mockDiscoveryFixtureByResource(resourceType, targetID, resourceID) + if discovery == nil { + writeDiscoveryError(w, http.StatusNotFound, "Discovery not found") + return + } } // Redact sensitive fields for non-admin users @@ -484,11 +671,6 @@ func (h *DiscoveryHandlers) HandleDeleteDiscovery(w http.ResponseWriter, r *http // HandleGetProgress handles GET /api/discovery/{type}/{target}/{id}/progress func (h *DiscoveryHandlers) HandleGetProgress(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, "/progress") @@ -508,11 +690,26 @@ func (h *DiscoveryHandlers) HandleGetProgress(w http.ResponseWriter, r *http.Req discoveryID := servicediscovery.MakeResourceID(resourceType, targetID, resourceID) - progress := h.service.GetProgress(discoveryID) + 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 - discovery, err := h.service.GetDiscovery(discoveryID) - if err == nil && discovery != nil { + 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, @@ -544,6 +741,10 @@ func (h *DiscoveryHandlers) HandleGetProgress(w http.ResponseWriter, r *http.Req // 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 } @@ -620,11 +821,6 @@ func (h *DiscoveryHandlers) HandleUpdateSettings(w http.ResponseWriter, r *http. // HandleListByType handles GET /api/discovery/type/{type} func (h *DiscoveryHandlers) HandleListByType(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/type/") resourceType, err := parseDiscoveryResourceType(path) @@ -633,20 +829,21 @@ func (h *DiscoveryHandlers) HandleListByType(w http.ResponseWriter, r *http.Requ return } - discoveries, 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") + 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 := make([]servicediscovery.DiscoverySummary, 0, len(discoveries)) - for _, d := range discoveries { - if d == nil { - continue - } - summaries = append(summaries, discoverySummaryResponse(d)) - } + summaries := discoverySummaryList(mergeDiscoveryRecords(discoveries, mockDiscoveryFixturesByType(resourceType))) writeDiscoveryJSON(w, map[string]any{ "discoveries": summaries, @@ -657,28 +854,24 @@ func (h *DiscoveryHandlers) HandleListByType(w http.ResponseWriter, r *http.Requ // HandleListByAgent handles GET /api/discovery/agent/{agentId} func (h *DiscoveryHandlers) HandleListByAgent(w http.ResponseWriter, r *http.Request) { - if h.service == nil { + // 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 } - // Parse path - agentID := strings.TrimPrefix(r.URL.Path, "/api/discovery/agent/") - - discoveries, 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 - } - - summaries := make([]servicediscovery.DiscoverySummary, 0, len(discoveries)) - for _, d := range discoveries { - if d == nil { - continue - } - summaries = append(summaries, discoverySummaryResponse(d)) - } + summaries := discoverySummaryList(mergeDiscoveryRecords(discoveries, mockDiscoveryFixturesByTarget(agentID))) writeDiscoveryJSON(w, map[string]any{ "discoveries": summaries, diff --git a/internal/api/discovery_handlers_test.go b/internal/api/discovery_handlers_test.go index f6e142b51..5e91adc4b 100644 --- a/internal/api/discovery_handlers_test.go +++ b/internal/api/discovery_handlers_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" @@ -11,6 +12,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/servicediscovery" + "github.com/rcourtman/pulse-go-rewrite/internal/testutil" internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -586,6 +588,62 @@ func TestHandleGetDiscovery_EmitsCanonicalAgentID(t *testing.T) { assert.NotContains(t, body, "host_id") } +func TestDiscoveryHandlers_MockModeServesFixturesWithoutService(t *testing.T) { + testutil.SetMockMode(t, true) + h := NewDiscoveryHandlers(nil, nil) + + listReq := httptest.NewRequest(http.MethodGet, "/api/discovery", nil) + listRec := httptest.NewRecorder() + h.HandleListDiscoveries(listRec, listReq) + + require.Equal(t, http.StatusOK, listRec.Code) + var listBody map[string]any + require.NoError(t, json.NewDecoder(listRec.Body).Decode(&listBody)) + require.Greater(t, listBody["total"].(float64), float64(0)) + discoveries := listBody["discoveries"].([]any) + first := discoveries[0].(map[string]any) + + resourceType := first["resource_type"].(string) + targetID := first["target_id"].(string) + resourceID := first["resource_id"].(string) + detailPath := fmt.Sprintf("/api/discovery/%s/%s/%s", resourceType, targetID, resourceID) + + detailReq := httptest.NewRequest(http.MethodGet, detailPath, nil) + detailRec := httptest.NewRecorder() + h.HandleGetDiscovery(detailRec, detailReq) + require.Equal(t, http.StatusOK, detailRec.Code) + var detailBody map[string]any + require.NoError(t, json.NewDecoder(detailRec.Body).Decode(&detailBody)) + require.Equal(t, first["id"], detailBody["id"]) + require.NotEmpty(t, detailBody["service_name"]) + require.NotContains(t, detailBody, "raw_command_output") + + typeReq := httptest.NewRequest(http.MethodGet, "/api/discovery/type/"+resourceType, nil) + typeRec := httptest.NewRecorder() + h.HandleListByType(typeRec, typeReq) + require.Equal(t, http.StatusOK, typeRec.Code) + var typeBody map[string]any + require.NoError(t, json.NewDecoder(typeRec.Body).Decode(&typeBody)) + require.Greater(t, typeBody["total"].(float64), float64(0)) + + progressReq := httptest.NewRequest(http.MethodGet, detailPath+"/progress", nil) + progressRec := httptest.NewRecorder() + h.HandleGetProgress(progressRec, progressReq) + require.Equal(t, http.StatusOK, progressRec.Code) + var progressBody map[string]any + require.NoError(t, json.NewDecoder(progressRec.Body).Decode(&progressBody)) + require.Equal(t, "completed", progressBody["status"]) + + statusReq := httptest.NewRequest(http.MethodGet, "/api/discovery/status", nil) + statusRec := httptest.NewRecorder() + h.HandleGetStatus(statusRec, statusReq) + require.Equal(t, http.StatusOK, statusRec.Code) + var statusBody map[string]any + require.NoError(t, json.NewDecoder(statusRec.Body).Decode(&statusBody)) + require.Equal(t, false, statusBody["running"]) + require.Equal(t, float64(len(discoveries)), statusBody["fingerprint_count"]) +} + // Additional test to cover service not configured case func TestHandlers_NoService(t *testing.T) { h := NewDiscoveryHandlers(nil, nil) @@ -596,7 +654,7 @@ func TestHandlers_NoService(t *testing.T) { assert.Equal(t, http.StatusServiceUnavailable, w.Code) w = httptest.NewRecorder() - h.HandleGetDiscovery(w, req) + h.HandleGetDiscovery(w, httptest.NewRequest("GET", "/api/discovery/vm/node1/100", nil)) assert.Equal(t, http.StatusServiceUnavailable, w.Code) w = httptest.NewRecorder() diff --git a/internal/api/resources.go b/internal/api/resources.go index ae788db64..7a7649b01 100644 --- a/internal/api/resources.go +++ b/internal/api/resources.go @@ -1936,6 +1936,8 @@ func buildDiscoveryTarget(resource unified.Resource) *unified.DiscoveryTarget { return proxmoxGuestDiscoveryTarget(resource, "vm") case unified.ResourceTypeSystemContainer: return proxmoxGuestDiscoveryTarget(resource, "system-container") + case unified.ResourceTypeAppContainer: + return dockerContainerDiscoveryTarget(resource) case unified.ResourceTypePBS: return hostDiscoveryTarget(resource) case unified.ResourceTypePMG: @@ -1989,6 +1991,7 @@ func hostDiscoveryTarget(resource unified.Resource) *unified.DiscoveryTarget { hostID := firstNonEmptyTrimmed( agentID(resource.Agent), linkedAgentID, + dockerAgentID(resource.Docker), proxmoxNodeName(resource.Proxmox), agentHostname(resource.Agent), dockerHostname(resource.Docker), @@ -2018,6 +2021,34 @@ func hostDiscoveryTarget(resource unified.Resource) *unified.DiscoveryTarget { } } +func dockerContainerDiscoveryTarget(resource unified.Resource) *unified.DiscoveryTarget { + if resource.Docker == nil { + return nil + } + hostID := strings.TrimSpace(resource.Docker.AgentID) + if hostID == "" { + return nil + } + resourceID := firstNonEmptyTrimmed( + resource.Name, + resource.Docker.ContainerID, + resource.ID, + ) + if resourceID == "" { + return nil + } + return &unified.DiscoveryTarget{ + ResourceType: "app-container", + AgentID: hostID, + ResourceID: resourceID, + Hostname: firstNonEmptyTrimmed( + resource.Docker.Hostname, + firstResourceHostname(resource), + resource.Name, + ), + } +} + func proxmoxGuestDiscoveryTarget(resource unified.Resource, resourceType string) *unified.DiscoveryTarget { if resource.Proxmox == nil || resource.Proxmox.NodeName == "" || resource.Proxmox.VMID == 0 { return nil @@ -2169,6 +2200,13 @@ func agentID(agent *unified.AgentData) string { return agent.AgentID } +func dockerAgentID(docker *unified.DockerData) string { + if docker == nil { + return "" + } + return docker.AgentID +} + func agentHostname(agent *unified.AgentData) string { if agent == nil { return "" diff --git a/internal/api/resources_test.go b/internal/api/resources_test.go index 2dafd532e..2d45614f4 100644 --- a/internal/api/resources_test.go +++ b/internal/api/resources_test.go @@ -1844,6 +1844,57 @@ func TestBuildDiscoveryTargetKubernetesPrefersAgentID(t *testing.T) { } } +func TestBuildDiscoveryTargetDockerAppContainerUsesAgentAndContainerName(t *testing.T) { + target := buildDiscoveryTarget(unified.Resource{ + ID: "app-container:source:abc123", + Type: unified.ResourceTypeAppContainer, + Name: "customer-portal", + Docker: &unified.DockerData{ + AgentID: "agent-edge-01", + ContainerID: "abc123def456", + Hostname: "edge-apps-01", + }, + }) + + if target == nil { + t.Fatal("expected discovery target") + } + if target.ResourceType != "app-container" { + t.Fatalf("resource type = %q, want app-container", target.ResourceType) + } + if target.AgentID != "agent-edge-01" { + t.Fatalf("agentID = %q, want agent-edge-01", target.AgentID) + } + if target.ResourceID != "customer-portal" { + t.Fatalf("resourceID = %q, want customer-portal", target.ResourceID) + } + if target.Hostname != "edge-apps-01" { + t.Fatalf("hostname = %q, want edge-apps-01", target.Hostname) + } +} + +func TestBuildDiscoveryTargetDockerHostUsesAgentID(t *testing.T) { + target := buildDiscoveryTarget(unified.Resource{ + ID: "agent:docker-host", + Type: unified.ResourceTypeAgent, + Name: "Edge Apps 01", + Docker: &unified.DockerData{ + AgentID: "agent-edge-01", + Hostname: "edge-apps-01", + }, + }) + + if target == nil { + t.Fatal("expected discovery target") + } + if target.ResourceType != "agent" { + t.Fatalf("resource type = %q, want agent", target.ResourceType) + } + if target.AgentID != "agent-edge-01" || target.ResourceID != "agent-edge-01" { + t.Fatalf("target = %+v, want agent-edge-01/agent-edge-01", target) + } +} + func TestK8sNamespacesEndpointAggregatesPodsAndDeployments(t *testing.T) { now := time.Now().UTC() snapshot := models.StateSnapshot{ diff --git a/internal/mock/demo_scenarios_test.go b/internal/mock/demo_scenarios_test.go index 9cd4bb4e6..2c65e4ec6 100644 --- a/internal/mock/demo_scenarios_test.go +++ b/internal/mock/demo_scenarios_test.go @@ -76,6 +76,48 @@ func TestBuildFixtureGraphAppliesCuratedDemoScenarioAcrossEstate(t *testing.T) { } } +func TestBuildFixtureGraphIncludesDiscoveryContextFixtures(t *testing.T) { + cfg := DefaultConfig + cfg.DockerHostCount = 3 + cfg.DockerContainersPerHost = 6 + cfg.VMsPerNode = 3 + cfg.LXCsPerNode = 3 + + graph := buildFixtureGraph(cfg, time.Date(2026, time.April, 10, 12, 0, 0, 0, time.UTC)) + if len(graph.DiscoveryFixtures) == 0 { + t.Fatal("expected discovery fixtures in canonical mock graph") + } + + var hasDockerURL bool + var hasVM bool + var hasAgent bool + for _, discovery := range graph.DiscoveryFixtures { + if discovery == nil { + t.Fatal("discovery fixture must not be nil") + } + if discovery.ID == "" || discovery.TargetID == "" || discovery.ResourceID == "" { + t.Fatalf("discovery fixture missing identity: %+v", discovery) + } + if discovery.ServiceName == "" || discovery.ServiceVersion == "" || len(discovery.ConfigPaths) == 0 { + t.Fatalf("discovery fixture missing operator context: %+v", discovery) + } + switch discovery.ResourceType { + case discoveryResourceTypeDocker: + hasDockerURL = hasDockerURL || discovery.SuggestedURL != "" && len(discovery.DockerMounts) > 0 + case discoveryResourceTypeVM: + hasVM = true + case discoveryResourceTypeAgent: + hasAgent = true + } + } + if !hasDockerURL { + t.Fatal("expected Docker discovery fixture with suggested URL and bind mount context") + } + if !hasVM || !hasAgent { + t.Fatalf("expected guest and host discovery fixtures, hasVM=%t hasAgent=%t", hasVM, hasAgent) + } +} + func TestDemoScenarioStorageNamingHandlesScaledNodeCount(t *testing.T) { cfg := DefaultConfig cfg.NodeCount = 6 diff --git a/internal/mock/discovery_fixtures.go b/internal/mock/discovery_fixtures.go new file mode 100644 index 000000000..f2875e6b0 --- /dev/null +++ b/internal/mock/discovery_fixtures.go @@ -0,0 +1,747 @@ +package mock + +import ( + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +const ( + maxMockVMDiscoveryFixtures = 4 + maxMockContainerDiscoveryFixtures = 4 + maxMockDockerDiscoveryFixtures = 10 + maxMockHostDiscoveryFixtures = 4 + maxMockK8sDiscoveryFixtures = 4 + + discoveryResourceTypeVM = "vm" + discoveryResourceTypeSystemContainer = "system-container" + discoveryResourceTypeDocker = "docker" + discoveryResourceTypeK8s = "k8s" + discoveryResourceTypeAgent = "agent" + + discoveryFactCategoryVersion = "version" + discoveryFactCategoryService = "service" + discoveryFactCategoryHardware = "hardware" + + discoveryCategoryDatabase = "database" + discoveryCategoryWebServer = "web_server" + discoveryCategoryCache = "cache" + discoveryCategoryMonitoring = "monitoring" + discoveryCategoryBackup = "backup" + discoveryCategoryStorage = "storage" + discoveryCategoryVirtualizer = "virtualizer" + discoveryCategoryNetwork = "network" + discoveryCategorySecurity = "security" + discoveryCategoryMedia = "media" + discoveryCategoryHomeAuto = "home_automation" + + mockFingerprintSchemaVersion = 3 + mockCLIAccessVersion = 2 +) + +type DiscoveryFixture struct { + ID string + ResourceType string + ResourceID string + TargetID string + AgentID string + Hostname string + ServiceType string + ServiceName string + ServiceVersion string + Category string + CLIAccess string + Facts []DiscoveryFact + ConfigPaths []string + DataPaths []string + LogPaths []string + Ports []DiscoveryPort + DockerMounts []DiscoveryDockerBindMount + UserNotes string + UserSecrets map[string]string + Confidence float64 + AIReasoning string + DiscoveredAt time.Time + UpdatedAt time.Time + ScanDuration int64 + Fingerprint string + FingerprintedAt time.Time + FingerprintSchemaVersion int + CLIAccessVersion int + RawCommandOutput map[string]string + SuggestedURL string + SuggestedURLSourceCode string + SuggestedURLSourceDetail string + SuggestedURLDiagnostic string +} + +type DiscoveryFact struct { + Category string + Key string + Value string + Source string + Confidence float64 + DiscoveredAt time.Time +} + +type DiscoveryPort struct { + Port int + Protocol string + Process string + Address string +} + +type DiscoveryDockerBindMount struct { + ContainerName string + Source string + Destination string + Type string + ReadOnly bool +} + +type mockDiscoveryProfile struct { + ServiceType string + ServiceName string + ServiceVersion string + Category string + ConfigPaths []string + DataPaths []string + LogPaths []string + Ports []DiscoveryPort + WebScheme string + WebPort int + WebPath string + SourceCode string + SourceDetail string + Diagnostic string + AdditionalFacts []DiscoveryFact +} + +func normalizeDiscoveryResourceType(resourceType string) string { + return strings.TrimSpace(resourceType) +} + +func makeDiscoveryResourceID(resourceType, targetID, resourceID string) string { + return fmt.Sprintf("%s:%s:%s", resourceType, targetID, resourceID) +} + +func getMockCLIAccessTemplate(resourceType string) string { + switch normalizeDiscoveryResourceType(resourceType) { + case discoveryResourceTypeSystemContainer: + return "Use pulse_control with target_host matching this container's hostname. Commands run directly inside the container." + case discoveryResourceTypeVM: + return "Use pulse_control with target_host matching this VM's hostname. Commands run directly inside the VM." + case discoveryResourceTypeDocker: + return "Use pulse_control targeting the Docker host with command: docker exec {container} " + case discoveryResourceTypeK8s: + return "Use kubectl exec -n {namespace} {pod} -- " + case discoveryResourceTypeAgent: + return "Use pulse_control with target_host matching this host. Commands run directly." + default: + return "Use pulse_control with target_host matching the resource hostname." + } +} + +// CurrentDiscoveryFixtures returns defensive copies of the mock-mode discovery +// records exposed through the normal Discovery API. +func CurrentDiscoveryFixtures() []*DiscoveryFixture { + if !IsMockEnabled() { + return nil + } + + dataMu.RLock() + defer dataMu.RUnlock() + + return cloneDiscoveryFixtures(mockGraph.DiscoveryFixtures) +} + +func CurrentDiscoveryFixtureByResource(resourceType string, targetID, resourceID string) *DiscoveryFixture { + for _, discovery := range CurrentDiscoveryFixtures() { + if discoveryFixtureMatchesResource(discovery, resourceType, targetID, resourceID) { + return cloneDiscoveryFixture(discovery) + } + } + return nil +} + +func CurrentDiscoveryFixturesByType(resourceType string) []*DiscoveryFixture { + matches := make([]*DiscoveryFixture, 0) + for _, discovery := range CurrentDiscoveryFixtures() { + if discovery == nil { + continue + } + if normalizeDiscoveryResourceType(discovery.ResourceType) == normalizeDiscoveryResourceType(resourceType) { + matches = append(matches, cloneDiscoveryFixture(discovery)) + } + } + return matches +} + +func CurrentDiscoveryFixturesByTarget(targetID string) []*DiscoveryFixture { + matches := make([]*DiscoveryFixture, 0) + for _, discovery := range CurrentDiscoveryFixtures() { + if discoveryFixtureMatchesTarget(discovery, targetID) { + matches = append(matches, cloneDiscoveryFixture(discovery)) + } + } + return matches +} + +func discoveryFixtureMatchesResource(discovery *DiscoveryFixture, resourceType string, targetID, resourceID string) bool { + if discovery == nil { + return false + } + if normalizeDiscoveryResourceType(discovery.ResourceType) != normalizeDiscoveryResourceType(resourceType) { + return false + } + if !discoveryFixtureMatchesTarget(discovery, targetID) { + return false + } + + resourceID = strings.TrimSpace(resourceID) + return resourceID != "" && strings.EqualFold(strings.TrimSpace(discovery.ResourceID), resourceID) +} + +func discoveryFixtureMatchesTarget(discovery *DiscoveryFixture, targetID string) bool { + if discovery == nil { + return false + } + + targetID = strings.TrimSpace(targetID) + if targetID == "" { + return false + } + + candidates := []string{ + discovery.TargetID, + discovery.AgentID, + discovery.Hostname, + } + if discovery.ResourceType == discoveryResourceTypeAgent { + candidates = append(candidates, discovery.ResourceID) + } + for _, candidate := range candidates { + if strings.EqualFold(strings.TrimSpace(candidate), targetID) { + return true + } + } + return false +} + +func buildDiscoveryFixtures(state models.StateSnapshot, now time.Time) []*DiscoveryFixture { + if now.IsZero() { + now = time.Now() + } + now = now.UTC() + + discoveries := make([]*DiscoveryFixture, 0, 24) + discoveries = append(discoveries, buildHostDiscoveryFixtures(state, now)...) + discoveries = append(discoveries, buildVMDiscoveryFixtures(state.VMs, now)...) + discoveries = append(discoveries, buildContainerDiscoveryFixtures(state.Containers, now)...) + discoveries = append(discoveries, buildDockerDiscoveryFixtures(state.DockerHosts, now)...) + discoveries = append(discoveries, buildK8sDiscoveryFixtures(state.KubernetesClusters, now)...) + + sort.Slice(discoveries, func(i, j int) bool { + return discoveries[i].ID < discoveries[j].ID + }) + return discoveries +} + +func buildHostDiscoveryFixtures(state models.StateSnapshot, now time.Time) []*DiscoveryFixture { + discoveries := make([]*DiscoveryFixture, 0, maxMockHostDiscoveryFixtures) + + hosts := append([]models.Host(nil), state.Hosts...) + sort.Slice(hosts, func(i, j int) bool { + return firstNonEmpty(hosts[i].DisplayName, hosts[i].Hostname, hosts[i].ID) < firstNonEmpty(hosts[j].DisplayName, hosts[j].Hostname, hosts[j].ID) + }) + for _, host := range hosts { + if len(discoveries) >= maxMockHostDiscoveryFixtures/2 { + break + } + targetID := firstNonEmpty(host.ID, host.Hostname) + if targetID == "" { + continue + } + profile := mockHostDiscoveryProfile(host.OSName, host.OSVersion, host.AgentVersion) + discoveries = append(discoveries, newMockDiscovery(discoveryResourceTypeAgent, targetID, targetID, host.Hostname, profile, now, len(discoveries))) + } + + dockerHosts := append([]models.DockerHost(nil), state.DockerHosts...) + sort.Slice(dockerHosts, func(i, j int) bool { + return firstNonEmpty(dockerHosts[i].DisplayName, dockerHosts[i].Hostname, dockerHosts[i].ID) < firstNonEmpty(dockerHosts[j].DisplayName, dockerHosts[j].Hostname, dockerHosts[j].ID) + }) + for _, host := range dockerHosts { + if len(discoveries) >= maxMockHostDiscoveryFixtures { + break + } + targetID := firstNonEmpty(host.AgentID, host.Hostname, host.ID) + if targetID == "" { + continue + } + profile := mockDockerHostDiscoveryProfile(host.Runtime, firstNonEmpty(host.RuntimeVersion, host.DockerVersion), host.AgentVersion) + discoveries = append(discoveries, newMockDiscovery(discoveryResourceTypeAgent, targetID, targetID, host.Hostname, profile, now, len(discoveries))) + } + + return discoveries +} + +func buildVMDiscoveryFixtures(vms []models.VM, now time.Time) []*DiscoveryFixture { + vms = append([]models.VM(nil), vms...) + sort.Slice(vms, func(i, j int) bool { + return vms[i].VMID < vms[j].VMID + }) + + discoveries := make([]*DiscoveryFixture, 0, maxMockVMDiscoveryFixtures) + for _, vm := range vms { + if len(discoveries) >= maxMockVMDiscoveryFixtures { + break + } + if !strings.EqualFold(strings.TrimSpace(vm.Status), "running") || vm.Node == "" || vm.VMID <= 0 { + continue + } + profile := mockServiceDiscoveryProfile(vm.Name, "", "vm") + discovery := newMockDiscovery(discoveryResourceTypeVM, vm.Node, strconv.Itoa(vm.VMID), vm.Name, profile, now, len(discoveries)) + discovery.SuggestedURL = mockSuggestedURL(profile, firstIPOrHost(vm.IPAddresses, vm.Name)) + discoveries = append(discoveries, discovery) + } + return discoveries +} + +func buildContainerDiscoveryFixtures(containers []models.Container, now time.Time) []*DiscoveryFixture { + containers = append([]models.Container(nil), containers...) + sort.Slice(containers, func(i, j int) bool { + return containers[i].VMID < containers[j].VMID + }) + + discoveries := make([]*DiscoveryFixture, 0, maxMockContainerDiscoveryFixtures) + for _, ct := range containers { + if len(discoveries) >= maxMockContainerDiscoveryFixtures { + break + } + if !strings.EqualFold(strings.TrimSpace(ct.Status), "running") || ct.Node == "" || ct.VMID <= 0 { + continue + } + profile := mockServiceDiscoveryProfile(ct.Name, "", "system-container") + discovery := newMockDiscovery(discoveryResourceTypeSystemContainer, ct.Node, strconv.Itoa(ct.VMID), ct.Name, profile, now, len(discoveries)) + discovery.SuggestedURL = mockSuggestedURL(profile, firstIPOrHost(ct.IPAddresses, ct.Name)) + discoveries = append(discoveries, discovery) + } + return discoveries +} + +func buildDockerDiscoveryFixtures(hosts []models.DockerHost, now time.Time) []*DiscoveryFixture { + hosts = append([]models.DockerHost(nil), hosts...) + sort.Slice(hosts, func(i, j int) bool { + return hosts[i].Hostname < hosts[j].Hostname + }) + + discoveries := make([]*DiscoveryFixture, 0, maxMockDockerDiscoveryFixtures) + for _, host := range hosts { + targetID := firstNonEmpty(host.AgentID, host.Hostname, host.ID) + if targetID == "" || !strings.EqualFold(strings.TrimSpace(host.Status), "online") { + continue + } + + containers := append([]models.DockerContainer(nil), host.Containers...) + sort.Slice(containers, func(i, j int) bool { + return containers[i].Name < containers[j].Name + }) + for _, container := range containers { + if len(discoveries) >= maxMockDockerDiscoveryFixtures { + return discoveries + } + if !strings.EqualFold(strings.TrimSpace(container.State), "running") || container.Name == "" { + continue + } + profile := mockServiceDiscoveryProfile(container.Name, container.Image, "docker") + discovery := newMockDiscovery(discoveryResourceTypeDocker, targetID, container.Name, host.Hostname, profile, now, len(discoveries)) + discovery.AgentID = targetID + discovery.SuggestedURL = mockSuggestedURL(profile, host.Hostname) + discovery.Facts = append(discovery.Facts, mockDiscoveryFact(discoveryFactCategoryService, "container_image", container.Image, "docker inspect", 0.95, discovery.DiscoveredAt)) + discovery.DockerMounts = mockDockerMounts(host, container, profile) + discoveries = append(discoveries, discovery) + } + } + return discoveries +} + +func buildK8sDiscoveryFixtures(clusters []models.KubernetesCluster, now time.Time) []*DiscoveryFixture { + clusters = append([]models.KubernetesCluster(nil), clusters...) + sort.Slice(clusters, func(i, j int) bool { + return clusters[i].Name < clusters[j].Name + }) + + discoveries := make([]*DiscoveryFixture, 0, maxMockK8sDiscoveryFixtures) + for _, cluster := range clusters { + targetID := firstNonEmpty(cluster.AgentID, cluster.ID, cluster.Name) + if targetID == "" { + continue + } + pods := append([]models.KubernetesPod(nil), cluster.Pods...) + sort.Slice(pods, func(i, j int) bool { + return pods[i].Name < pods[j].Name + }) + for _, pod := range pods { + if len(discoveries) >= maxMockK8sDiscoveryFixtures { + return discoveries + } + if !strings.EqualFold(strings.TrimSpace(pod.Phase), "running") { + continue + } + resourceID := firstNonEmpty(pod.UID, namespacedName(pod.Namespace, pod.Name), pod.Name) + if resourceID == "" { + continue + } + image := "" + if len(pod.Containers) > 0 { + image = pod.Containers[0].Image + } + profile := mockServiceDiscoveryProfile(firstNonEmpty(pod.OwnerName, pod.Name), image, "k8s") + discovery := newMockDiscovery(discoveryResourceTypeK8s, targetID, resourceID, firstNonEmpty(cluster.Name, pod.NodeName, pod.Name), profile, now, len(discoveries)) + discovery.AgentID = targetID + discovery.SuggestedURL = mockSuggestedURL(profile, firstNonEmpty(cluster.Name, pod.Name)) + discovery.Facts = append(discovery.Facts, + mockDiscoveryFact(discoveryFactCategoryService, "namespace", pod.Namespace, "kubectl get pod -o json", 0.98, discovery.DiscoveredAt), + mockDiscoveryFact(discoveryFactCategoryService, "pod", pod.Name, "kubectl get pod -o json", 0.98, discovery.DiscoveredAt), + ) + discoveries = append(discoveries, discovery) + } + } + return discoveries +} + +func newMockDiscovery(resourceType string, targetID, resourceID, hostname string, profile mockDiscoveryProfile, now time.Time, ordinal int) *DiscoveryFixture { + discoveredAt := now.Add(-time.Duration(ordinal%9+1) * time.Hour) + updatedAt := now.Add(-time.Duration(ordinal%17+3) * time.Minute) + id := makeDiscoveryResourceID(resourceType, targetID, resourceID) + if profile.ServiceVersion == "" { + profile.ServiceVersion = "detected" + } + + discovery := &DiscoveryFixture{ + ID: id, + ResourceType: resourceType, + ResourceID: resourceID, + TargetID: targetID, + AgentID: targetID, + Hostname: firstNonEmpty(hostname, targetID), + ServiceType: profile.ServiceType, + ServiceName: profile.ServiceName, + ServiceVersion: profile.ServiceVersion, + Category: profile.Category, + CLIAccess: getMockCLIAccessTemplate(resourceType), + Facts: mockBaseFacts(profile, discoveredAt), + ConfigPaths: append([]string(nil), profile.ConfigPaths...), + DataPaths: append([]string(nil), profile.DataPaths...), + LogPaths: append([]string(nil), profile.LogPaths...), + Ports: append([]DiscoveryPort(nil), profile.Ports...), + UserSecrets: map[string]string{}, + Confidence: 0.86 + float64(ordinal%8)/100, + AIReasoning: "Discovery identified the service from runtime metadata, exposed ports, and well-known configuration paths in the demo fixture.", + DiscoveredAt: discoveredAt, + UpdatedAt: updatedAt, + ScanDuration: int64(1200 + ordinal*140), + Fingerprint: mockStableHexString(16, id, "discovery-fingerprint"), + FingerprintedAt: discoveredAt.Add(-2 * time.Minute), + FingerprintSchemaVersion: mockFingerprintSchemaVersion, + CLIAccessVersion: mockCLIAccessVersion, + SuggestedURLSourceCode: profile.SourceCode, + SuggestedURLSourceDetail: profile.SourceDetail, + SuggestedURLDiagnostic: profile.Diagnostic, + } + for _, fact := range profile.AdditionalFacts { + if fact.DiscoveredAt.IsZero() { + fact.DiscoveredAt = discoveredAt + } + discovery.Facts = append(discovery.Facts, fact) + } + return discovery +} + +func mockBaseFacts(profile mockDiscoveryProfile, discoveredAt time.Time) []DiscoveryFact { + facts := []DiscoveryFact{ + mockDiscoveryFact(discoveryFactCategoryService, "service", profile.ServiceName, "service inventory", 0.94, discoveredAt), + mockDiscoveryFact(discoveryFactCategoryVersion, "version", profile.ServiceVersion, "version probe", 0.88, discoveredAt), + } + if len(profile.ConfigPaths) > 0 { + facts = append(facts, mockDiscoveryFact(discoveryFactCategoryService, "primary_config", profile.ConfigPaths[0], "filesystem probe", 0.89, discoveredAt)) + } + return facts +} + +func mockDiscoveryFact(category string, key, value, source string, confidence float64, discoveredAt time.Time) DiscoveryFact { + return DiscoveryFact{ + Category: category, + Key: key, + Value: value, + Source: source, + Confidence: confidence, + DiscoveredAt: discoveredAt, + } +} + +func mockServiceDiscoveryProfile(name, image, runtime string) mockDiscoveryProfile { + token := strings.ToLower(strings.TrimSpace(name + " " + image)) + version := imageTag(image) + if version == "" || version == "latest" { + version = "detected" + } + + switch { + case strings.Contains(token, "postgres"): + return mockProfile("postgres", "PostgreSQL", firstNonEmpty(version, "16.4"), discoveryCategoryDatabase, 5432, "postgres", "", "", "No web interface was suggested because PostgreSQL exposes a database port, not an HTTP UI.", []string{"/var/lib/postgresql/data/postgresql.conf", "/etc/postgresql/postgresql.conf"}, []string{"/var/lib/postgresql/data"}, []string{"/var/log/postgresql/postgresql.log"}) + case strings.Contains(token, "redis"): + return mockProfile("redis", "Redis", firstNonEmpty(version, "7.2"), discoveryCategoryCache, 6379, "redis-server", "", "", "No web interface was suggested because Redis exposes a cache port, not an HTTP UI.", []string{"/usr/local/etc/redis/redis.conf", "/etc/redis/redis.conf"}, []string{"/data"}, []string{"/var/log/redis/redis-server.log"}) + case strings.Contains(token, "traefik") || strings.Contains(token, "edge-proxy"): + return mockProfile("traefik", "Traefik", firstNonEmpty(version, "3.1"), discoveryCategoryWebServer, 443, "traefik", "https", "/", "", []string{"/etc/traefik/traefik.yml", "/etc/traefik/dynamic"}, []string{"/letsencrypt"}, []string{"/var/log/traefik/traefik.log"}) + case strings.Contains(token, "vaultwarden"): + return mockProfile("vaultwarden", "Vaultwarden", firstNonEmpty(version, "1.32.7"), discoveryCategorySecurity, 80, "vaultwarden", "http", "/", "", []string{"/data/config.json"}, []string{"/data"}, []string{"/data/vaultwarden.log"}) + case strings.Contains(token, "uptime-kuma"): + return mockProfile("uptime-kuma", "Uptime Kuma", firstNonEmpty(version, "1.23.16"), discoveryCategoryMonitoring, 3001, "node", "http", "/", "", []string{"/app/data/kuma.db"}, []string{"/app/data"}, []string{"/app/data/error.log"}) + case strings.Contains(token, "prometheus"): + return mockProfile("prometheus", "Prometheus", firstNonEmpty(version, "2.54.1"), discoveryCategoryMonitoring, 9090, "prometheus", "http", "/", "", []string{"/etc/prometheus/prometheus.yml"}, []string{"/prometheus"}, []string{"/var/log/prometheus/prometheus.log"}) + case strings.Contains(token, "grafana"): + return mockProfile("grafana-agent", "Grafana Agent", firstNonEmpty(version, "0.42.0"), discoveryCategoryMonitoring, 12345, "grafana-agent", "http", "/", "", []string{"/etc/grafana-agent/config.river"}, []string{"/var/lib/grafana-agent"}, []string{"/var/log/grafana-agent.log"}) + case strings.Contains(token, "backup"): + return mockProfile("backup-coordinator", "Backup Coordinator", firstNonEmpty(version, "2026.04"), discoveryCategoryBackup, 8080, "backup-coordinator", "http", "/health", "", []string{"/etc/pulse-demo/backup-coordinator.yaml"}, []string{"/var/lib/pulse-demo/backups"}, []string{"/var/log/pulse-demo/backup-coordinator.log"}) + case strings.Contains(token, "sftp"): + return mockProfile("sftp", "SFTP Ingest", version, discoveryCategoryStorage, 22, "sshd", "", "", "No web interface was suggested because the detected service exposes SSH/SFTP only.", []string{"/etc/ssh/sshd_config", "/etc/sftp/users.conf"}, []string{"/home"}, []string{"/var/log/auth.log"}) + case strings.Contains(token, "minio"): + return mockProfile("minio", "MinIO", firstNonEmpty(version, "RELEASE.2026-04"), discoveryCategoryStorage, 9000, "minio", "http", "/", "", []string{"/etc/minio/config.env"}, []string{"/data"}, []string{"/var/log/minio/minio.log"}) + case strings.Contains(token, "homeassistant"): + return mockProfile("home-assistant", "Home Assistant", firstNonEmpty(version, "2026.4"), discoveryCategoryHomeAuto, 8123, "python3", "http", "/", "", []string{"/config/configuration.yaml"}, []string{"/config"}, []string{"/config/home-assistant.log"}) + case strings.Contains(token, "pihole"): + return mockProfile("pihole", "Pi-hole", firstNonEmpty(version, "2026.03"), discoveryCategoryNetwork, 80, "lighttpd", "http", "/admin", "", []string{"/etc/pihole/setupVars.conf", "/etc/dnsmasq.d"}, []string{"/etc/pihole"}, []string{"/var/log/pihole/pihole.log"}) + case strings.Contains(token, "jellyfin"): + return mockProfile("jellyfin", "Jellyfin", firstNonEmpty(version, "10.10"), discoveryCategoryMedia, 8096, "jellyfin", "http", "/", "", []string{"/config/system.xml"}, []string{"/config", "/media"}, []string{"/config/log"}) + case strings.Contains(token, "nextcloud"): + return mockProfile("nextcloud", "Nextcloud", firstNonEmpty(version, "30.0"), discoveryCategoryStorage, 80, "apache2", "http", "/", "", []string{"/var/www/html/config/config.php"}, []string{"/var/www/html/data"}, []string{"/var/www/html/data/nextcloud.log"}) + default: + serviceType := strings.Trim(strings.ToLower(strings.ReplaceAll(name, "_", "-")), "-") + if serviceType == "" { + serviceType = strings.TrimSpace(runtime) + } + if serviceType == "" { + serviceType = "application" + } + return mockProfile(serviceType, humanizeServiceName(serviceType), firstNonEmpty(version, "2026.04"), discoveryCategoryWebServer, 8080, serviceType, "http", "/", "", []string{fmt.Sprintf("/etc/%s/config.yaml", serviceType)}, []string{fmt.Sprintf("/var/lib/%s", serviceType)}, []string{fmt.Sprintf("/var/log/%s.log", serviceType)}) + } +} + +func mockProfile(serviceType, serviceName, version string, category string, port int, process, scheme, path, diagnostic string, configPaths, dataPaths, logPaths []string) mockDiscoveryProfile { + sourceCode := "" + sourceDetail := "" + ports := []DiscoveryPort{} + if scheme != "" && port > 0 { + sourceCode = "listening-port" + sourceDetail = fmt.Sprintf("Detected %s listener on %d/tcp", serviceName, port) + } + if port > 0 { + ports = append(ports, DiscoveryPort{Port: port, Protocol: "tcp", Process: process, Address: "0.0.0.0"}) + } + return mockDiscoveryProfile{ + ServiceType: serviceType, + ServiceName: serviceName, + ServiceVersion: version, + Category: category, + ConfigPaths: configPaths, + DataPaths: dataPaths, + LogPaths: logPaths, + Ports: ports, + WebScheme: scheme, + WebPort: port, + WebPath: path, + SourceCode: sourceCode, + SourceDetail: sourceDetail, + Diagnostic: diagnostic, + AdditionalFacts: []DiscoveryFact{}, + } +} + +func mockHostDiscoveryProfile(osName, osVersion, agentVersion string) mockDiscoveryProfile { + serviceName := "Pulse Unified Agent" + version := firstNonEmpty(agentVersion, osVersion, "detected") + profile := mockProfile("pulse-agent", serviceName, version, discoveryCategoryMonitoring, 0, "pulse-agent", "", "", "No web interface was suggested for this host-level agent discovery.", []string{"/etc/pulse-agent/config.yaml"}, []string{"/var/lib/pulse-agent"}, []string{"/var/log/pulse-agent.log"}) + if osName != "" { + profile.AdditionalFacts = append(profile.AdditionalFacts, DiscoveryFact{ + Category: discoveryFactCategoryHardware, + Key: "os", + Value: strings.TrimSpace(osName + " " + osVersion), + Source: "agent host facts", + Confidence: 0.98, + }) + } + return profile +} + +func mockDockerHostDiscoveryProfile(runtime, runtimeVersion, agentVersion string) mockDiscoveryProfile { + serviceName := "Docker Host" + if strings.EqualFold(strings.TrimSpace(runtime), "podman") { + serviceName = "Podman Host" + } + profile := mockProfile(strings.ToLower(strings.ReplaceAll(serviceName, " ", "-")), serviceName, firstNonEmpty(runtimeVersion, agentVersion, "detected"), discoveryCategoryVirtualizer, 0, runtime, "", "", "No workload web interface was suggested for the host runtime itself.", []string{"/etc/docker/daemon.json", "/etc/containers/containers.conf"}, []string{"/var/lib/docker", "/var/lib/containers"}, []string{"/var/log/docker.log", "/var/log/podman.log"}) + profile.AdditionalFacts = append(profile.AdditionalFacts, DiscoveryFact{ + Category: discoveryFactCategoryService, + Key: "runtime", + Value: firstNonEmpty(runtime, "container runtime"), + Source: "agent runtime facts", + Confidence: 0.98, + }) + return profile +} + +func mockDockerMounts(host models.DockerHost, container models.DockerContainer, profile mockDiscoveryProfile) []DiscoveryDockerBindMount { + base := fmt.Sprintf("/srv/%s/%s", sanitizePathToken(firstNonEmpty(host.Hostname, host.ID, "docker-host")), sanitizePathToken(container.Name)) + mounts := make([]DiscoveryDockerBindMount, 0, 2) + if len(profile.ConfigPaths) > 0 { + mounts = append(mounts, DiscoveryDockerBindMount{ + ContainerName: container.Name, + Source: base + "/config", + Destination: containerPathRoot(profile.ConfigPaths[0]), + Type: "bind", + }) + } + if len(profile.DataPaths) > 0 { + mounts = append(mounts, DiscoveryDockerBindMount{ + ContainerName: container.Name, + Source: base + "/data", + Destination: profile.DataPaths[0], + Type: "bind", + }) + } + return mounts +} + +func mockSuggestedURL(profile mockDiscoveryProfile, host string) string { + if profile.WebScheme == "" || profile.WebPort <= 0 || host == "" { + return "" + } + path := profile.WebPath + if path == "" { + path = "/" + } + return fmt.Sprintf("%s://%s:%d%s", profile.WebScheme, strings.Trim(host, "[]"), profile.WebPort, path) +} + +func imageTag(image string) string { + image = strings.TrimSpace(image) + if image == "" { + return "" + } + if idx := strings.LastIndex(image, ":"); idx >= 0 && idx < len(image)-1 && !strings.Contains(image[idx+1:], "/") { + return strings.TrimSpace(image[idx+1:]) + } + return "" +} + +func firstIPOrHost(ips []string, fallback string) string { + for _, ip := range ips { + ip = strings.TrimSpace(ip) + if ip == "" { + continue + } + if before, _, found := strings.Cut(ip, "/"); found { + ip = before + } + return ip + } + return strings.TrimSpace(fallback) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func namespacedName(namespace, name string) string { + namespace = strings.TrimSpace(namespace) + name = strings.TrimSpace(name) + if namespace == "" { + return name + } + if name == "" { + return namespace + } + return namespace + "/" + name +} + +func humanizeServiceName(serviceType string) string { + parts := strings.FieldsFunc(serviceType, func(r rune) bool { + return r == '-' || r == '_' || r == '.' + }) + for i := range parts { + if parts[i] == "" { + continue + } + parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:] + } + return strings.Join(parts, " ") +} + +func sanitizePathToken(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + replacer := strings.NewReplacer(" ", "-", "/", "-", ":", "-", "_", "-") + value = replacer.Replace(value) + value = strings.Trim(value, "-") + if value == "" { + return "service" + } + return value +} + +func containerPathRoot(path string) string { + path = strings.TrimSpace(path) + if path == "" || path == "/" { + return "/config" + } + parts := strings.Split(strings.TrimPrefix(path, "/"), "/") + if len(parts) == 0 || parts[0] == "" { + return "/config" + } + return "/" + parts[0] +} + +func cloneDiscoveryFixtures(in []*DiscoveryFixture) []*DiscoveryFixture { + if in == nil { + return nil + } + out := make([]*DiscoveryFixture, 0, len(in)) + for _, discovery := range in { + out = append(out, cloneDiscoveryFixture(discovery)) + } + return out +} + +func cloneDiscoveryFixture(in *DiscoveryFixture) *DiscoveryFixture { + if in == nil { + return nil + } + out := *in + out.Facts = append([]DiscoveryFact(nil), in.Facts...) + out.ConfigPaths = append([]string(nil), in.ConfigPaths...) + out.DataPaths = append([]string(nil), in.DataPaths...) + out.LogPaths = append([]string(nil), in.LogPaths...) + out.Ports = append([]DiscoveryPort(nil), in.Ports...) + out.DockerMounts = append([]DiscoveryDockerBindMount(nil), in.DockerMounts...) + if in.UserSecrets != nil { + out.UserSecrets = make(map[string]string, len(in.UserSecrets)) + for key, value := range in.UserSecrets { + out.UserSecrets[key] = value + } + } + if in.RawCommandOutput != nil { + out.RawCommandOutput = make(map[string]string, len(in.RawCommandOutput)) + for key, value := range in.RawCommandOutput { + out.RawCommandOutput[key] = value + } + } + return &out +} diff --git a/internal/mock/discovery_fixtures_test.go b/internal/mock/discovery_fixtures_test.go new file mode 100644 index 000000000..a5c151596 --- /dev/null +++ b/internal/mock/discovery_fixtures_test.go @@ -0,0 +1,102 @@ +package mock + +import ( + "testing" + "time" +) + +func TestBuildFixtureGraphIncludesDiscoveryFixtures(t *testing.T) { + cfg := DefaultConfig + cfg.DockerHostCount = 3 + cfg.DockerContainersPerHost = 6 + cfg.VMsPerNode = 3 + cfg.LXCsPerNode = 3 + + graph := buildFixtureGraph(cfg, time.Date(2026, time.April, 10, 12, 0, 0, 0, time.UTC)) + + if len(graph.DiscoveryFixtures) == 0 { + t.Fatal("expected discovery fixtures") + } + + var dockerFixture *DiscoveryFixture + var vmFixture *DiscoveryFixture + var agentFixture *DiscoveryFixture + for _, discovery := range graph.DiscoveryFixtures { + if discovery == nil { + t.Fatal("discovery fixture must not be nil") + } + if discovery.ID == "" || discovery.TargetID == "" || discovery.ResourceID == "" { + t.Fatalf("discovery fixture missing identity: %+v", discovery) + } + if discovery.ServiceName == "" || discovery.ServiceVersion == "" { + t.Fatalf("discovery fixture missing service details: %+v", discovery) + } + if discovery.CLIAccessVersion != mockCLIAccessVersion { + t.Fatalf("discovery fixture CLI version = %d, want %d", discovery.CLIAccessVersion, mockCLIAccessVersion) + } + switch discovery.ResourceType { + case discoveryResourceTypeDocker: + if dockerFixture == nil { + dockerFixture = discovery + } + case discoveryResourceTypeVM: + if vmFixture == nil { + vmFixture = discovery + } + case discoveryResourceTypeAgent: + if agentFixture == nil { + agentFixture = discovery + } + } + } + + if dockerFixture == nil { + t.Fatal("expected at least one Docker discovery fixture") + } + if dockerFixture.SuggestedURL == "" { + t.Fatalf("expected Docker discovery fixture to include suggested URL: %+v", dockerFixture) + } + if len(dockerFixture.DockerMounts) == 0 { + t.Fatalf("expected Docker discovery fixture to include bind mount context: %+v", dockerFixture) + } + if vmFixture == nil { + t.Fatal("expected at least one VM discovery fixture") + } + if agentFixture == nil { + t.Fatal("expected at least one agent discovery fixture") + } +} + +func TestCurrentDiscoveryFixtureLookupReturnsDefensiveCopies(t *testing.T) { + previous := IsMockEnabled() + if err := SetEnabled(true); err != nil { + t.Fatalf("enable mock mode: %v", err) + } + t.Cleanup(func() { + if err := SetEnabled(previous); err != nil { + t.Fatalf("restore mock mode: %v", err) + } + }) + + fixtures := CurrentDiscoveryFixtures() + if len(fixtures) == 0 { + t.Fatal("expected current discovery fixtures") + } + + first := fixtures[0] + first.ServiceName = "mutated" + if len(first.ConfigPaths) > 0 { + first.ConfigPaths[0] = "/mutated" + } + + again := CurrentDiscoveryFixtureByResource(first.ResourceType, first.TargetID, first.ResourceID) + if again == nil { + t.Fatalf("expected fixture lookup for %s", first.ID) + } + if again.ServiceName == "mutated" { + t.Fatal("fixture lookup returned shared pointer") + } + if len(again.ConfigPaths) > 0 && again.ConfigPaths[0] == "/mutated" { + t.Fatal("fixture lookup returned shared slice") + } +} diff --git a/internal/mock/fixture_graph.go b/internal/mock/fixture_graph.go index 95d49c0a8..ac3c8f572 100644 --- a/internal/mock/fixture_graph.go +++ b/internal/mock/fixture_graph.go @@ -17,6 +17,7 @@ type FixtureGraph struct { AlertHistory []models.Alert PlatformFixtures PlatformFixtures AvailabilityFixtures []AvailabilityFixture + DiscoveryFixtures []*DiscoveryFixture } func emptyFixtureGraph() FixtureGraph { @@ -46,6 +47,7 @@ func cloneFixtureGraph(in FixtureGraph) FixtureGraph { AlertHistory: append([]models.Alert(nil), in.AlertHistory...), PlatformFixtures: clonePlatformFixtures(in.PlatformFixtures), AvailabilityFixtures: cloneAvailabilityFixtures(in.AvailabilityFixtures), + DiscoveryFixtures: cloneDiscoveryFixtures(in.DiscoveryFixtures), } } @@ -61,6 +63,7 @@ func (g *FixtureGraph) UpdateMetrics(cfg MockConfig, now time.Time) { g.PlatformFixtures = rebasePlatformFixtures(g.PlatformFixtures, now) g.AvailabilityFixtures = rebaseAvailabilityFixtures(g.AvailabilityFixtures, now) applyDemoScenarioGraph(g, now) + g.DiscoveryFixtures = buildDiscoveryFixtures(g.State, now) syncMetricRoleRegistryFromGraph(*g) } diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 1c12bc42d..76ef312d6 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -3589,8 +3589,8 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 214, - "heading_line": 108, + "line": 224, + "heading_line": 109, } ], )