mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Seed mock Discovery fixtures
This commit is contained in:
parent
7b56ef17d1
commit
142236d797
16 changed files with 1463 additions and 68 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
747
internal/mock/discovery_fixtures.go
Normal file
747
internal/mock/discovery_fixtures.go
Normal file
|
|
@ -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} <your-command>"
|
||||
case discoveryResourceTypeK8s:
|
||||
return "Use kubectl exec -n {namespace} {pod} -- <your-command>"
|
||||
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
|
||||
}
|
||||
102
internal/mock/discovery_fixtures_test.go
Normal file
102
internal/mock/discovery_fixtures_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue