Pulse/internal/servicediscovery/service_test.go

1754 lines
54 KiB
Go

package servicediscovery
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
type stubAnalyzer struct {
mu sync.Mutex
calls int
response string
}
func (s *stubAnalyzer) AnalyzeForDiscovery(ctx context.Context, prompt string) (string, error) {
s.mu.Lock()
s.calls++
s.mu.Unlock()
return s.response, nil
}
type errorAnalyzer struct{}
func (errorAnalyzer) AnalyzeForDiscovery(ctx context.Context, prompt string) (string, error) {
return "", context.Canceled
}
type blockingAnalyzer struct {
started chan struct{}
once sync.Once
}
func (b *blockingAnalyzer) AnalyzeForDiscovery(ctx context.Context, prompt string) (string, error) {
b.once.Do(func() {
if b.started == nil {
return
}
close(b.started)
})
<-ctx.Done()
return "", ctx.Err()
}
func TestFilterSensitiveLabels(t *testing.T) {
tests := []struct {
name string
labels map[string]string
wantKeys map[string]string // expected values (use "[REDACTED]" for sensitive ones)
}{
{
name: "nil labels",
labels: nil,
wantKeys: nil,
},
{
name: "empty labels",
labels: map[string]string{},
wantKeys: map[string]string{},
},
{
name: "safe labels only",
labels: map[string]string{
"app": "myapp",
"version": "1.0.0",
"env": "production",
},
wantKeys: map[string]string{
"app": "myapp",
"version": "1.0.0",
"env": "production",
},
},
{
name: "redacts PASSWORD labels",
labels: map[string]string{
"app": "myapp",
"DB_PASSWORD": "super-secret",
"mysql_password": "another-secret",
"PASSWORD_FILE": "/secrets/pass",
},
wantKeys: map[string]string{
"app": "myapp",
"DB_PASSWORD": "[REDACTED]",
"mysql_password": "[REDACTED]",
"PASSWORD_FILE": "[REDACTED]",
},
},
{
name: "redacts SECRET labels",
labels: map[string]string{
"app": "myapp",
"AWS_SECRET_KEY": "secret123",
"client_secret": "xyz",
},
wantKeys: map[string]string{
"app": "myapp",
"AWS_SECRET_KEY": "[REDACTED]",
"client_secret": "[REDACTED]",
},
},
{
name: "redacts TOKEN labels",
labels: map[string]string{
"app": "myapp",
"ACCESS_TOKEN": "tok_123",
"oauth_token": "tok_456",
},
wantKeys: map[string]string{
"app": "myapp",
"ACCESS_TOKEN": "[REDACTED]",
"oauth_token": "[REDACTED]",
},
},
{
name: "redacts API KEY labels",
labels: map[string]string{
"app": "myapp",
"API_KEY": "key123",
"openai_apikey": "sk-123",
"stripe_api_key": "sk_live_123",
},
wantKeys: map[string]string{
"app": "myapp",
"API_KEY": "[REDACTED]",
"openai_apikey": "[REDACTED]",
"stripe_api_key": "[REDACTED]",
},
},
{
name: "redacts CREDENTIAL labels",
labels: map[string]string{
"app": "myapp",
"DB_CREDENTIALS": "user:pass",
"admin_cred": "admin123",
},
wantKeys: map[string]string{
"app": "myapp",
"DB_CREDENTIALS": "[REDACTED]",
"admin_cred": "[REDACTED]",
},
},
{
name: "redacts AUTH labels",
labels: map[string]string{
"app": "myapp",
"auth_code": "abc123",
"BASIC_AUTH": "dXNlcjpwYXNz",
},
wantKeys: map[string]string{
"app": "myapp",
"auth_code": "[REDACTED]",
"BASIC_AUTH": "[REDACTED]",
},
},
{
name: "mixed sensitive and safe labels",
labels: map[string]string{
"app": "myapp",
"version": "2.0",
"maintainer": "team@example.com",
"DB_PASSWORD": "secret",
"API_KEY": "key123",
"prometheus_port": "9090",
},
wantKeys: map[string]string{
"app": "myapp",
"version": "2.0",
"maintainer": "team@example.com",
"DB_PASSWORD": "[REDACTED]",
"API_KEY": "[REDACTED]",
"prometheus_port": "9090",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := filterSensitiveLabels(tt.labels)
if tt.wantKeys == nil {
if got != nil {
t.Errorf("filterSensitiveLabels() = %v, want nil", got)
}
return
}
if len(got) != len(tt.wantKeys) {
t.Errorf("filterSensitiveLabels() returned %d labels, want %d", len(got), len(tt.wantKeys))
}
for k, wantV := range tt.wantKeys {
gotV, ok := got[k]
if !ok {
t.Errorf("filterSensitiveLabels() missing key %q", k)
continue
}
if gotV != wantV {
t.Errorf("filterSensitiveLabels()[%q] = %q, want %q", k, gotV, wantV)
}
}
})
}
}
// readStateFromSnapshot builds a ReadState backed by a ResourceRegistry
// from a local servicediscovery StateSnapshot. This lets tests populate
// state using the familiar local types while exercising the ReadState path.
func readStateFromSnapshot(snap StateSnapshot) unifiedresources.ReadState {
ms := models.StateSnapshot{}
for _, vm := range snap.VMs {
ms.VMs = append(ms.VMs, models.VM{
ID: fmt.Sprintf("vm-%d", vm.VMID),
VMID: vm.VMID,
Name: vm.Name,
Node: vm.Node,
Status: vm.Status,
Instance: vm.Instance,
IPAddresses: vm.IPAddresses,
})
}
for _, ct := range snap.Containers {
ms.Containers = append(ms.Containers, models.Container{
ID: fmt.Sprintf("ct-%d", ct.VMID),
VMID: ct.VMID,
Name: ct.Name,
Node: ct.Node,
Status: ct.Status,
Instance: ct.Instance,
IPAddresses: ct.IPAddresses,
})
}
for _, dh := range snap.DockerHosts {
mh := models.DockerHost{
ID: dh.AgentID,
AgentID: dh.AgentID,
Hostname: dh.Hostname,
}
for _, dc := range dh.Containers {
containerID := strings.TrimSpace(dc.ID)
if containerID == "" {
containerID = fmt.Sprintf("%s:%s", strings.TrimSpace(dh.AgentID), strings.TrimSpace(dc.Name))
if strings.TrimSpace(dh.AgentID) == "" {
containerID = fmt.Sprintf("docker:%s", strings.TrimSpace(dc.Name))
}
}
mc := models.DockerContainer{
ID: containerID,
Name: dc.Name,
Image: dc.Image,
State: dc.Status, // servicediscovery.DockerContainer.Status maps to Docker engine state
Status: dc.Status,
Labels: dc.Labels,
}
for _, p := range dc.Ports {
mc.Ports = append(mc.Ports, models.DockerContainerPort{
PublicPort: p.PublicPort,
PrivatePort: p.PrivatePort,
Protocol: p.Protocol,
})
}
for _, m := range dc.Mounts {
mc.Mounts = append(mc.Mounts, models.DockerContainerMount{
Source: m.Source,
Destination: m.Destination,
})
}
mh.Containers = append(mh.Containers, mc)
}
ms.DockerHosts = append(ms.DockerHosts, mh)
}
for _, h := range snap.Hosts {
ms.Hosts = append(ms.Hosts, models.Host{
ID: h.ID,
Hostname: h.Hostname,
DisplayName: h.DisplayName,
Platform: h.Platform,
OSName: h.OSName,
OSVersion: h.OSVersion,
KernelVersion: h.KernelVersion,
Architecture: h.Architecture,
CPUCount: h.CPUCount,
Status: h.Status,
Tags: h.Tags,
})
}
for _, n := range snap.Nodes {
ms.Nodes = append(ms.Nodes, models.Node{
ID: n.ID,
Name: n.Name,
LinkedAgentID: n.LinkedAgentID,
})
}
for _, kc := range snap.KubernetesClusters {
mk := models.KubernetesCluster{
ID: kc.ID,
Name: kc.Name,
AgentID: kc.AgentID,
Status: kc.Status,
}
for _, pod := range kc.Pods {
mp := models.KubernetesPod{
UID: pod.UID,
Name: pod.Name,
Namespace: pod.Namespace,
NodeName: pod.NodeName,
Phase: pod.Phase,
Labels: pod.Labels,
OwnerKind: pod.OwnerKind,
OwnerName: pod.OwnerName,
}
mk.Pods = append(mk.Pods, mp)
}
ms.KubernetesClusters = append(ms.KubernetesClusters, mk)
}
rr := unifiedresources.NewRegistry(nil)
rr.IngestSnapshot(ms)
return rr
}
func TestEmptyStateSnapshot_NormalizesCollections(t *testing.T) {
snap := EmptyStateSnapshot()
if snap.VMs == nil || snap.Containers == nil || snap.DockerHosts == nil || snap.KubernetesClusters == nil || snap.Hosts == nil || snap.Nodes == nil {
t.Fatalf("expected empty servicediscovery snapshot slices to be initialized, got %#v", snap)
}
encoded, err := json.Marshal(snap)
if err != nil {
t.Fatalf("marshal snapshot: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(encoded, &payload); err != nil {
t.Fatalf("decode snapshot: %v", err)
}
for _, key := range []string{"VMs", "Containers", "DockerHosts", "KubernetesClusters", "Hosts", "Nodes"} {
values, ok := payload[key].([]any)
if !ok {
t.Fatalf("expected %s to serialize as an array, got %T (%v)", key, payload[key], payload[key])
}
if len(values) != 0 {
t.Fatalf("expected %s to serialize as an empty array, got %v", key, values)
}
}
}
func TestService_GetSnapshot_WithoutReadStateReturnsCanonicalEmptySnapshot(t *testing.T) {
service := NewService(nil, nil, DefaultConfig())
snap, ok := service.getSnapshot()
if ok {
t.Fatal("expected getSnapshot without ReadState to report no state access")
}
if snap.VMs == nil || snap.Containers == nil || snap.DockerHosts == nil || snap.KubernetesClusters == nil || snap.Hosts == nil || snap.Nodes == nil {
t.Fatalf("expected canonical empty snapshot on missing ReadState, got %#v", snap)
}
}
func TestService_parseAIResponse_Markdown(t *testing.T) {
service := &Service{}
response := "```json\n{\n \"service_type\": \"nginx\",\n \"service_name\": \"Nginx\",\n \"service_version\": \"1.2\",\n \"category\": \"web_server\",\n \"cli_access\": \"docker exec {container} bash\",\n \"facts\": [{\"category\": \"version\", \"key\": \"nginx\", \"value\": \"1.2\", \"source\": \"cmd\", \"confidence\": 0.9}],\n \"config_paths\": [\"/etc/nginx/nginx.conf\"],\n \"data_paths\": [\"/var/www\"],\n \"ports\": [{\"port\": 80, \"protocol\": \"tcp\", \"process\": \"nginx\", \"address\": \"0.0.0.0\"}],\n \"confidence\": 0.9,\n \"reasoning\": \"image name\"\n}\n```"
parsed := service.parseAIResponse(response)
if parsed == nil {
t.Fatalf("expected parsed response")
}
if parsed.ServiceType != "nginx" || parsed.ServiceName != "Nginx" {
t.Fatalf("unexpected parsed result: %#v", parsed)
}
if len(parsed.Facts) != 1 || parsed.Facts[0].DiscoveredAt.IsZero() {
t.Fatalf("expected fact timestamp set: %#v", parsed.Facts)
}
if service.parseAIResponse("not json") != nil {
t.Fatalf("expected nil for invalid json")
}
}
func TestService_analyzeDockerContainer_CacheAndPorts(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
service := NewService(store, nil, Config{CacheExpiry: time.Hour})
analyzer := &stubAnalyzer{
response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"docker exec {container} nginx -v","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"image"}`,
}
container := DockerContainer{
Name: "web",
Image: "nginx:latest",
Status: "running",
Ports: []DockerPort{
{PublicPort: 8080, PrivatePort: 80, Protocol: "tcp"},
},
}
host := DockerHost{
AgentID: "host1",
Hostname: "host1",
}
first := service.analyzeDockerContainer(context.Background(), analyzer, container, host)
if first == nil {
t.Fatalf("expected discovery")
}
if !strings.Contains(first.CLIAccess, "web") {
t.Fatalf("expected cli access to include container name, got %s", first.CLIAccess)
}
if len(first.Ports) != 1 || first.Ports[0].Port != 80 || first.Ports[0].Address != ":8080" {
t.Fatalf("unexpected ports: %#v", first.Ports)
}
second := service.analyzeDockerContainer(context.Background(), analyzer, container, host)
if second == nil {
t.Fatalf("expected cached discovery")
}
analyzer.mu.Lock()
calls := analyzer.calls
analyzer.mu.Unlock()
if calls != 1 {
t.Fatalf("expected analyzer called once, got %d", calls)
}
lowAnalyzer := &stubAnalyzer{
response: `{"service_type":"unknown","service_name":"","service_version":"","category":"unknown","cli_access":"","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.4,"reasoning":""}`,
}
lowContainer := DockerContainer{Name: "mystery", Image: "unknown:latest"}
if got := service.analyzeDockerContainer(context.Background(), lowAnalyzer, lowContainer, host); got != nil {
t.Fatalf("expected low confidence discovery to be skipped")
}
}
func TestService_DiscoverResource_RecentAndNoAnalyzer(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
service := NewService(store, nil, DefaultConfig())
req := DiscoveryRequest{
ResourceType: ResourceTypeDocker,
ResourceID: "nginx",
TargetID: "host1",
Hostname: "host1",
}
discovery := &ResourceDiscovery{
ID: MakeResourceID(req.ResourceType, req.TargetID, req.ResourceID),
ResourceType: req.ResourceType,
ResourceID: req.ResourceID,
TargetID: req.TargetID,
Hostname: req.Hostname,
ServiceName: "Existing",
}
if err := store.Save(discovery); err != nil {
t.Fatalf("Save error: %v", err)
}
found, err := service.DiscoverResource(context.Background(), req)
if err != nil {
t.Fatalf("DiscoverResource error: %v", err)
}
if found == nil || found.ServiceName != "Existing" {
t.Fatalf("unexpected discovery: %#v", found)
}
_, err = service.DiscoverResource(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeVM,
ResourceID: "101",
TargetID: "node1",
Hostname: "node1",
Force: true,
})
if err == nil || !strings.Contains(err.Error(), "AI analyzer") {
t.Fatalf("expected analyzer error, got %v", err)
}
service.SetAIAnalyzer(errorAnalyzer{})
_, err = service.DiscoverResource(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeVM,
ResourceID: "102",
TargetID: "node1",
Hostname: "node1",
Force: true,
})
if err == nil || !strings.Contains(err.Error(), "AI analysis failed") {
t.Fatalf("expected analysis error, got %v", err)
}
service.SetAIAnalyzer(&stubAnalyzer{response: "not json"})
_, err = service.DiscoverResource(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeVM,
ResourceID: "103",
TargetID: "node1",
Hostname: "node1",
Force: true,
})
if err == nil || !strings.Contains(err.Error(), "failed to parse") {
t.Fatalf("expected parse error, got %v", err)
}
}
func TestService_DiscoverResource_RejectsNonCanonicalResourceTypes(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
service := NewService(store, nil, DefaultConfig())
_, err = service.DiscoverResource(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeDockerVM,
TargetID: "node1",
ResourceID: "101:web",
Force: true,
})
if err == nil || !strings.Contains(err.Error(), "execution-only") {
t.Fatalf("expected execution-only resource type error, got %v", err)
}
got, err := store.Get(MakeResourceID(ResourceTypeDockerVM, "node1", "101:web"))
if err != nil {
t.Fatalf("unexpected store get error: %v", err)
}
if got != nil {
t.Fatalf("expected no persisted discovery for rejected execution-only type, got %#v", got)
}
_, err = service.DiscoverResource(context.Background(), DiscoveryRequest{
ResourceType: legacyHostAlias,
TargetID: "host1",
ResourceID: "host1",
Force: true,
})
if err == nil || !strings.Contains(err.Error(), "legacy alias") {
t.Fatalf("expected legacy alias resource type error, got %v", err)
}
}
func TestService_QueryByTypeRejectsNonCanonicalResourceTypes(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
service := NewService(store, nil, DefaultConfig())
_, err = service.GetDiscoveryByResource(ResourceTypeDockerSystemContainer, "node1", "101:web")
if err == nil || !strings.Contains(err.Error(), "execution-only") {
t.Fatalf("expected execution-only query error, got %v", err)
}
_, err = service.ListDiscoveriesByType(legacyResourceTypeLXC)
if err == nil || !strings.Contains(err.Error(), "legacy alias") {
t.Fatalf("expected legacy alias list error, got %v", err)
}
}
func TestService_getResourceMetadata(t *testing.T) {
state := StateSnapshot{
VMs: []VM{
{VMID: 101, Name: "vm1", Node: "node1", Status: "running"},
},
Containers: []Container{
{VMID: 201, Name: "lxc1", Node: "node2", Status: "stopped"},
},
DockerHosts: []DockerHost{
{
AgentID: "agent1",
Hostname: "dock1",
Containers: []DockerContainer{
{Name: "redis", Image: "redis:latest", Status: "running", Labels: map[string]string{"tier": "cache"}},
},
},
},
}
service := NewService(nil, nil, DefaultConfig())
service.SetReadState(readStateFromSnapshot(state))
vmMeta := service.getResourceMetadata(DiscoveryRequest{
ResourceType: ResourceTypeVM,
ResourceID: "101",
TargetID: "node1",
})
if vmMeta["name"] != "vm1" || vmMeta["vmid"] != 101 {
t.Fatalf("unexpected vm metadata: %#v", vmMeta)
}
lxcMeta := service.getResourceMetadata(DiscoveryRequest{
ResourceType: ResourceTypeSystemContainer,
ResourceID: "201",
TargetID: "node2",
})
if lxcMeta["name"] != "lxc1" || lxcMeta["status"] != "offline" {
t.Fatalf("unexpected lxc metadata: %#v", lxcMeta)
}
dockerMeta := service.getResourceMetadata(DiscoveryRequest{
ResourceType: ResourceTypeDocker,
ResourceID: "redis",
TargetID: "agent1",
})
if dockerMeta["image"] != "redis:latest" || dockerMeta["status"] != "online" {
t.Fatalf("unexpected docker metadata: %#v", dockerMeta)
}
dockerByHost := service.getResourceMetadata(DiscoveryRequest{
ResourceType: ResourceTypeDocker,
ResourceID: "redis",
TargetID: "dock1",
})
if dockerByHost["image"] != "redis:latest" {
t.Fatalf("unexpected docker hostname metadata: %#v", dockerByHost)
}
}
func TestService_getResourceExternalIP_Host(t *testing.T) {
state := StateSnapshot{
Hosts: []Host{
{ID: "agent-pve1", Hostname: "10.0.0.15"},
},
Nodes: []Node{
{ID: "node-1", Name: "pve-node-01"},
},
}
service := NewService(nil, nil, DefaultConfig())
service.SetReadState(readStateFromSnapshot(state))
got := service.getResourceExternalIP(DiscoveryRequest{
ResourceType: ResourceTypeAgent,
ResourceID: "agent-pve1",
TargetID: "agent-pve1",
Hostname: "ignored-hostname",
})
if got != "10.0.0.15" {
t.Fatalf("host lookup by ID = %q, want %q", got, "10.0.0.15")
}
got = service.getResourceExternalIP(DiscoveryRequest{
ResourceType: ResourceTypeAgent,
ResourceID: "node-1",
TargetID: "node-1",
Hostname: "ignored-hostname",
})
if got != "pve-node-01" {
t.Fatalf("node fallback lookup = %q, want %q", got, "pve-node-01")
}
got = service.getResourceExternalIP(DiscoveryRequest{
ResourceType: ResourceTypeAgent,
ResourceID: "missing-host",
TargetID: "missing-host",
Hostname: "valid-hostname.local",
})
if got != "valid-hostname.local" {
t.Fatalf("hostname fallback = %q, want %q", got, "valid-hostname.local")
}
got = service.getResourceExternalIP(DiscoveryRequest{
ResourceType: ResourceTypeAgent,
ResourceID: "missing-host",
TargetID: "host-id-fallback",
Hostname: "Display Name With Spaces",
})
if got != "" {
t.Fatalf("invalid host fallback = %q, want empty", got)
}
}
func TestService_formatCLIAccessAndStatus(t *testing.T) {
service := NewService(nil, nil, DefaultConfig())
formatted := service.formatCLIAccess(ResourceTypeDocker, "redis", "")
// New format is instructional, should mention the container name and pulse_control
if !strings.Contains(formatted, "redis") || !strings.Contains(formatted, "docker exec") {
t.Fatalf("unexpected cli access: %s", formatted)
}
service.analysisCache = map[string]*analysisCacheEntry{
"nginx:latest": {
result: &AIAnalysisResponse{ServiceType: "nginx"},
cachedAt: time.Now(),
},
}
service.running = true
status := service.GetStatus()
if status["running"] != true || status["cache_size"] != 1 {
t.Fatalf("unexpected status: %#v", status)
}
snapshot := service.GetStatusSnapshot()
if !snapshot.Running || snapshot.CacheSize != 1 {
t.Fatalf("unexpected typed status snapshot: %+v", snapshot)
}
service.ClearCache()
if len(service.analysisCache) != 0 {
t.Fatalf("expected cache cleared")
}
}
func TestService_DefaultsAndSetAnalyzer(t *testing.T) {
service := NewService(nil, nil, Config{})
if service.interval == 0 || service.cacheExpiry == 0 || service.aiAnalysisTimeout == 0 {
t.Fatalf("expected defaults for interval and cache expiry")
}
analyzer := &stubAnalyzer{response: `{}`}
service.SetAIAnalyzer(analyzer)
if service.aiAnalyzer == nil {
t.Fatalf("expected analyzer set")
}
if service.GetProgress("missing") != nil {
t.Fatalf("expected nil progress without scanner")
}
if service.getResourceMetadata(DiscoveryRequest{}) != nil {
t.Fatalf("expected nil metadata without state provider")
}
}
func TestService_AnalyzeDockerContainer_AITimeout(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
service := NewService(store, nil, Config{
CacheExpiry: time.Hour,
AIAnalysisTimeout: 20 * time.Millisecond,
})
start := time.Now()
discovery := service.analyzeDockerContainer(
context.Background(),
&blockingAnalyzer{started: make(chan struct{})},
DockerContainer{Name: "slow", Image: "slow:latest"},
DockerHost{AgentID: "host1", Hostname: "host1"},
)
elapsed := time.Since(start)
if discovery != nil {
t.Fatalf("expected nil discovery when AI analysis times out")
}
if elapsed > 500*time.Millisecond {
t.Fatalf("expected metadata analysis timeout to bound execution, took %v", elapsed)
}
}
func TestService_FingerprintCollectionAndDiscoveryWrappers(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
state := StateSnapshot{
DockerHosts: []DockerHost{
{
AgentID: "host1",
Hostname: "host1",
Containers: []DockerContainer{
{Name: "web", Image: "nginx:latest", Status: "running"},
},
},
},
}
service := NewService(store, nil, DefaultConfig())
service.SetReadState(readStateFromSnapshot(state))
service.SetAIAnalyzer(&stubAnalyzer{
response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"docker exec {container} nginx -v","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"image"}`,
})
// First, collect fingerprints (no AI calls)
service.collectFingerprints(context.Background())
// Verify fingerprint was collected (key format is type:host:id)
fp, err := store.GetFingerprint("docker:host1:web")
if err != nil {
t.Fatalf("GetFingerprint error: %v", err)
}
if fp == nil {
t.Fatalf("expected fingerprint to be collected")
}
// Now trigger on-demand discovery (this makes AI call)
id := MakeResourceID(ResourceTypeDocker, "host1", "web")
discovery, err := service.DiscoverResource(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeDocker,
ResourceID: "web",
TargetID: "host1",
Hostname: "host1",
})
if err != nil {
t.Fatalf("DiscoverResource error: %v", err)
}
if discovery == nil {
t.Fatalf("expected discovery result")
}
if got, err := service.GetDiscovery(id); err != nil || got == nil {
t.Fatalf("GetDiscovery error: %v", err)
}
if got, err := service.GetDiscoveryByResource(ResourceTypeDocker, "host1", "web"); err != nil || got == nil {
t.Fatalf("GetDiscoveryByResource error: %v", err)
}
if list, err := service.ListDiscoveries(); err != nil || len(list) != 1 {
t.Fatalf("ListDiscoveries unexpected: %v len=%d", err, len(list))
}
if list, err := service.ListDiscoveriesByType(ResourceTypeDocker); err != nil || len(list) != 1 {
t.Fatalf("ListDiscoveriesByType unexpected: %v len=%d", err, len(list))
}
if list, err := service.ListDiscoveriesByTarget("host1"); err != nil || len(list) != 1 {
t.Fatalf("ListDiscoveriesByTarget unexpected: %v len=%d", err, len(list))
}
if err := service.UpdateNotes(id, "note", map[string]string{"k": "v"}); err != nil {
t.Fatalf("UpdateNotes error: %v", err)
}
updated, err := service.GetDiscovery(id)
if err != nil || updated.UserNotes != "note" {
t.Fatalf("expected updated notes: %#v err=%v", updated, err)
}
scanner := NewDeepScanner(&stubExecutor{})
scanner.progress[id] = &DiscoveryProgress{ResourceID: id}
service.scanner = scanner
if service.GetProgress(id) == nil {
t.Fatalf("expected progress")
}
if err := service.DeleteDiscovery(id); err != nil {
t.Fatalf("DeleteDiscovery error: %v", err)
}
service.SetReadState(nil)
service.collectFingerprints(context.Background())
}
func TestService_PromptsAndDiscoveryLoop(t *testing.T) {
service := NewService(nil, nil, DefaultConfig())
container := DockerContainer{
Name: "web",
Image: "nginx:latest",
Status: "running",
Ports: []DockerPort{
{PublicPort: 8080, PrivatePort: 80, Protocol: "tcp"},
},
Labels: map[string]string{"app": "nginx"},
Mounts: []DockerMount{{Destination: "/etc/nginx"}},
}
host := DockerHost{Hostname: "host1"}
prompt := service.buildMetadataAnalysisPrompt(container, host)
if !strings.Contains(prompt, "\"ports\"") || !strings.Contains(prompt, "\"labels\"") || !strings.Contains(prompt, "\"mounts\"") {
t.Fatalf("unexpected metadata prompt: %s", prompt)
}
longOutput := strings.Repeat("a", 2100)
deepPrompt := service.buildDeepAnalysisPrompt(AIAnalysisRequest{
ResourceType: ResourceTypeDocker,
ResourceID: "web",
TargetID: "host1",
Hostname: "host1",
Metadata: map[string]any{"image": "nginx"},
CommandOutputs: map[string]string{
"ps": longOutput,
},
})
if !strings.Contains(deepPrompt, "(truncated)") || !strings.Contains(deepPrompt, "Metadata:") {
t.Fatalf("unexpected deep prompt")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
service.initialDelay = time.Millisecond
service.Start(ctx)
service.Start(ctx)
service.Stop()
service.stopCh = make(chan struct{})
close(service.stopCh)
service.discoveryLoop(context.Background())
service.initialDelay = 0
service.stopCh = make(chan struct{})
close(service.stopCh)
service.discoveryLoop(context.Background())
}
func TestService_FingerprintLoop_StopAndCancel(t *testing.T) {
state := StateSnapshot{
DockerHosts: []DockerHost{
{
AgentID: "host1",
Hostname: "host1",
Containers: []DockerContainer{
{Name: "web", Image: "nginx:latest", Status: "running"},
},
},
},
}
runLoop := func(stopWithCancel bool) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
service := NewService(store, nil, DefaultConfig())
service.SetReadState(readStateFromSnapshot(state))
// Analyzer should be called by automatic refresh for changed/new resources.
analyzer := &stubAnalyzer{
response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"docker exec {container} nginx -v","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"image"}`,
}
service.SetAIAnalyzer(analyzer)
service.initialDelay = time.Millisecond
service.interval = time.Millisecond
service.cacheExpiry = time.Nanosecond
done := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // Always cancel to prevent context leak
go func() {
service.discoveryLoop(ctx)
close(done)
}()
// Wait for at least one automatic refresh cycle to run.
calls := 0
deadline := time.Now().Add(200 * time.Millisecond)
for time.Now().Before(deadline) {
analyzer.mu.Lock()
calls = analyzer.calls
analyzer.mu.Unlock()
if calls > 0 {
break
}
time.Sleep(2 * time.Millisecond)
}
if calls == 0 {
t.Fatalf("expected automatic discovery refresh to invoke AI analyzer")
}
if stopWithCancel {
cancel()
} else {
close(service.stopCh)
}
select {
case <-done:
case <-time.After(50 * time.Millisecond):
t.Fatalf("discoveryLoop did not stop")
}
// Verify fingerprints were collected.
// Key format is type:host:id
fp, err := store.GetFingerprint("docker:host1:web")
if err != nil {
t.Fatalf("GetFingerprint error: %v", err)
}
if fp == nil {
t.Fatalf("expected fingerprint to be collected")
}
discovery, err := store.Get("docker:host1:web")
if err != nil {
t.Fatalf("Get discovery error: %v", err)
}
if discovery == nil {
t.Fatalf("expected automatic discovery refresh to persist discovery data")
}
}
runLoop(false)
runLoop(true)
}
func TestService_StartDuringStopDoesNotStartNewLoop(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
state := StateSnapshot{
DockerHosts: []DockerHost{
{
AgentID: "host1",
Hostname: "host1",
Containers: []DockerContainer{
{Name: "web", Image: "nginx:latest", Status: "running"},
},
},
},
}
service := NewService(store, nil, DefaultConfig())
service.SetReadState(readStateFromSnapshot(state))
service.initialDelay = time.Millisecond
service.interval = time.Hour
analyzer := &blockingAnalyzer{started: make(chan struct{})}
service.SetAIAnalyzer(analyzer)
service.Start(context.Background())
select {
case <-analyzer.started:
case <-time.After(250 * time.Millisecond):
t.Fatalf("expected analyzer to be invoked")
}
stopped := make(chan struct{})
go func() {
service.Stop()
close(stopped)
}()
select {
case <-stopped:
case <-time.After(250 * time.Millisecond):
t.Fatalf("Stop did not return after canceling in-flight discovery")
}
}
func TestService_DiscoverDockerContainersSkips(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
service := NewService(store, nil, DefaultConfig())
service.discoverDockerContainers(context.Background(), []DockerHost{{AgentID: "host1"}})
service.SetAIAnalyzer(&stubAnalyzer{
response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"docker exec {container} nginx -v","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"image"}`,
})
id := MakeResourceID(ResourceTypeDocker, "host1", "web")
if err := store.Save(&ResourceDiscovery{ID: id, ResourceType: ResourceTypeDocker}); err != nil {
t.Fatalf("Save error: %v", err)
}
service.cacheExpiry = time.Hour
service.discoverDockerContainers(context.Background(), []DockerHost{
{AgentID: "host1", Containers: []DockerContainer{{Name: "web", Image: "nginx:latest"}}},
})
badAnalyzer := &stubAnalyzer{response: "not json"}
if got := service.analyzeDockerContainer(context.Background(), badAnalyzer, DockerContainer{Name: "bad", Image: "bad"}, DockerHost{AgentID: "host1"}); got != nil {
t.Fatalf("expected nil for bad analysis")
}
canceled, cancel := context.WithCancel(context.Background())
cancel()
analyzer := &stubAnalyzer{response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"docker exec {container} nginx -v","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"image"}`}
service.SetAIAnalyzer(analyzer)
service.discoverDockerContainers(canceled, []DockerHost{
{AgentID: "host1", Containers: []DockerContainer{{Name: "web2", Image: "nginx:latest"}}},
})
analyzer.mu.Lock()
calls := analyzer.calls
analyzer.mu.Unlock()
if calls != 0 {
t.Fatalf("expected analyzer not called on canceled context")
}
errAnalyzer := errorAnalyzer{}
if got := service.analyzeDockerContainer(context.Background(), errAnalyzer, DockerContainer{Name: "err", Image: "err"}, DockerHost{AgentID: "host1"}); got != nil {
t.Fatalf("expected nil when analyzer returns error")
}
storePath := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(storePath, []byte("x"), 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
service.store.dataDir = storePath
service.discoverDockerContainers(context.Background(), []DockerHost{
{AgentID: "host1", Containers: []DockerContainer{{Name: "web3", Image: "nginx:latest"}}},
})
}
func TestService_CollectFingerprintsNoReadState(t *testing.T) {
// Verify collectFingerprints handles a nil ReadState gracefully.
service := NewService(nil, nil, DefaultConfig())
service.collectFingerprints(context.Background())
}
func TestService_DiscoverResource_SaveError(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
badPath := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(badPath, []byte("x"), 0600); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
store.dataDir = badPath
service := NewService(store, nil, DefaultConfig())
service.SetAIAnalyzer(&stubAnalyzer{
response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"docker exec {container} nginx -v","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"image"}`,
})
_, err = service.DiscoverResource(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeDocker,
ResourceID: "web",
TargetID: "host1",
Hostname: "host1",
Force: true,
})
if err == nil || !strings.Contains(err.Error(), "failed to save discovery") {
t.Fatalf("expected save error, got %v", err)
}
}
func TestService_DiscoverResource_AITimeout(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
service := NewService(store, nil, Config{
Interval: time.Minute,
CacheExpiry: time.Hour,
DeepScanTimeout: time.Minute,
AIAnalysisTimeout: 20 * time.Millisecond,
MaxDiscoveryAge: 30 * 24 * time.Hour,
})
service.SetAIAnalyzer(&blockingAnalyzer{})
start := time.Now()
_, err = service.DiscoverResource(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeDocker,
ResourceID: "web",
TargetID: "host1",
Hostname: "host1",
Force: true,
})
elapsed := time.Since(start)
if err == nil || !strings.Contains(err.Error(), "timed out") {
t.Fatalf("expected timeout error, got %v", err)
}
if elapsed > 500*time.Millisecond {
t.Fatalf("expected discovery timeout to bound execution, took %v", elapsed)
}
}
func TestService_DiscoverResource_ScanError(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
scanner := NewDeepScanner(nil)
service := NewService(store, scanner, DefaultConfig())
service.SetAIAnalyzer(&stubAnalyzer{
response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"docker exec {container} nginx -v","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"image"}`,
})
_, err = service.DiscoverResource(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeDocker,
ResourceID: "web",
TargetID: "host1",
Hostname: "host1",
Force: true,
})
if err != nil {
t.Fatalf("expected scan error to be tolerated, got %v", err)
}
}
func TestService_DiscoveryLoop_ContextDoneAtStart(t *testing.T) {
service := NewService(nil, nil, DefaultConfig())
service.initialDelay = time.Hour
service.stopCh = make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
cancel()
service.discoveryLoop(ctx)
}
func TestService_DiscoverResource_WithScanResult(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
exec := &stubExecutor{
agents: []ConnectedAgent{{AgentID: "host1", Hostname: "host1"}},
}
scanner := NewDeepScanner(exec)
scanner.maxParallel = 1
state := StateSnapshot{
DockerHosts: []DockerHost{
{
AgentID: "host1",
Hostname: "host1",
Containers: []DockerContainer{
{Name: "web", Image: "nginx:latest", Status: "running"},
},
},
},
}
service := NewService(store, scanner, DefaultConfig())
service.SetReadState(readStateFromSnapshot(state))
service.SetAIAnalyzer(&stubAnalyzer{
response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"docker exec {container} nginx -v","facts":[],"config_paths":[],"data_paths":[],"ports":[{"port":80,"protocol":"tcp","process":"nginx","address":"0.0.0.0"}],"confidence":0.9,"reasoning":"image"}`,
})
existing := &ResourceDiscovery{
ID: MakeResourceID(ResourceTypeDocker, "host1", "web"),
ResourceType: ResourceTypeDocker,
ResourceID: "web",
TargetID: "host1",
Hostname: "host1",
UserNotes: "keep",
UserSecrets: map[string]string{"token": "secret"},
DiscoveredAt: time.Now().Add(-2 * time.Hour),
}
if err := store.Save(existing); err != nil {
t.Fatalf("Save error: %v", err)
}
found, err := service.DiscoverResource(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeDocker,
ResourceID: "web",
TargetID: "host1",
Hostname: "host1",
Force: true,
})
if err != nil {
t.Fatalf("DiscoverResource error: %v", err)
}
if found.UserNotes != "keep" || found.UserSecrets["token"] != "secret" {
t.Fatalf("expected user fields preserved: %#v", found)
}
if len(found.RawCommandOutput) == 0 {
t.Fatalf("expected raw command output")
}
if found.DiscoveredAt.After(existing.DiscoveredAt) {
t.Fatalf("expected older discovered_at preserved")
}
}
func TestParseDockerMounts(t *testing.T) {
tests := []struct {
name string
input string
expected []DockerBindMount
}{
{
name: "empty input",
input: "",
expected: nil,
},
{
name: "no_docker_mounts marker",
input: "no_docker_mounts",
expected: nil,
},
{
name: "only done marker",
input: "docker_mounts_done",
expected: nil,
},
{
name: "single container with bind mount",
input: "CONTAINER:homepage\n/home/user/homepage/config|/app/config|bind\ndocker_mounts_done",
expected: []DockerBindMount{
{ContainerName: "homepage", Source: "/home/user/homepage/config", Destination: "/app/config", Type: "bind"},
},
},
{
name: "single container with volume",
input: "CONTAINER:nginx\nnginx_data|/usr/share/nginx/html|volume\ndocker_mounts_done",
expected: []DockerBindMount{
{ContainerName: "nginx", Source: "nginx_data", Destination: "/usr/share/nginx/html", Type: "volume"},
},
},
{
name: "multiple containers",
input: "CONTAINER:homepage\n/home/user/config|/app/config|bind\nCONTAINER:watchtower\n/var/run/docker.sock|/var/run/docker.sock|bind\ndocker_mounts_done",
expected: []DockerBindMount{
{ContainerName: "homepage", Source: "/home/user/config", Destination: "/app/config", Type: "bind"},
{ContainerName: "watchtower", Source: "/var/run/docker.sock", Destination: "/var/run/docker.sock", Type: "bind"},
},
},
{
name: "container with multiple mounts",
input: "CONTAINER:jellyfin\n/media/movies|/movies|bind\n/media/tv|/tv|bind\n/config/jellyfin|/config|bind\ndocker_mounts_done",
expected: []DockerBindMount{
{ContainerName: "jellyfin", Source: "/media/movies", Destination: "/movies", Type: "bind"},
{ContainerName: "jellyfin", Source: "/media/tv", Destination: "/tv", Type: "bind"},
{ContainerName: "jellyfin", Source: "/config/jellyfin", Destination: "/config", Type: "bind"},
},
},
{
name: "container with no mounts",
input: "CONTAINER:alpine\ndocker_mounts_done",
expected: nil,
},
{
name: "filters out tmpfs",
input: "CONTAINER:app\n/data|/data|bind\n||tmpfs\ndocker_mounts_done",
expected: []DockerBindMount{
{ContainerName: "app", Source: "/data", Destination: "/data", Type: "bind"},
},
},
{
name: "mount without type defaults to included",
input: "CONTAINER:app\n/config|/app/config\ndocker_mounts_done",
expected: []DockerBindMount{
{ContainerName: "app", Source: "/config", Destination: "/app/config", Type: ""},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseDockerMounts(tt.input)
if len(result) != len(tt.expected) {
t.Fatalf("expected %d mounts, got %d: %#v", len(tt.expected), len(result), result)
}
for i := range tt.expected {
if result[i].ContainerName != tt.expected[i].ContainerName {
t.Errorf("mount %d: expected container %q, got %q", i, tt.expected[i].ContainerName, result[i].ContainerName)
}
if result[i].Source != tt.expected[i].Source {
t.Errorf("mount %d: expected source %q, got %q", i, tt.expected[i].Source, result[i].Source)
}
if result[i].Destination != tt.expected[i].Destination {
t.Errorf("mount %d: expected destination %q, got %q", i, tt.expected[i].Destination, result[i].Destination)
}
if result[i].Type != tt.expected[i].Type {
t.Errorf("mount %d: expected type %q, got %q", i, tt.expected[i].Type, result[i].Type)
}
}
})
}
}
func TestService_Redirection(t *testing.T) {
// Setup store
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
// Setup state with a linked PVE node
state := StateSnapshot{
Nodes: []Node{
{
ID: "pve-id-1",
Name: "pve1",
LinkedAgentID: "agent-pve1",
},
},
Hosts: []Host{
{
ID: "agent-pve1",
Hostname: "pve1-host",
},
},
}
// Setup service
service := NewService(store, nil, DefaultConfig())
service.SetReadState(readStateFromSnapshot(state))
service.SetAIAnalyzer(&stubAnalyzer{
response: `{"service_type":"proxmox","service_name":"Proxmox VE","service_version":"8.0","category":"virtualizer","cli_access":"ssh root@pve1","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"test"}`,
})
ctx := context.Background()
// 1. Test DiscoverResource redirection
// Trigger discovery for the PVE node "pve1"
req := DiscoveryRequest{
ResourceType: ResourceTypeAgent,
TargetID: "pve1",
ResourceID: "pve1",
Force: true,
}
discovery, err := service.DiscoverResource(ctx, req)
if err != nil {
t.Fatalf("DiscoverResource error: %v", err)
}
// The discovery should be associated with the AGENT ID, not the NODE ID
expectedID := MakeResourceID(ResourceTypeAgent, "agent-pve1", "agent-pve1") // Host resources usually have TargetID == ResourceID
if discovery.ID != expectedID {
t.Errorf("DiscoverResource ID mismatch. Got %s, want %s (should have redirected to agent ID)", discovery.ID, expectedID)
}
if discovery.TargetID != "agent-pve1" {
t.Errorf("DiscoverResource TargetID mismatch. Got %s, want agent-pve1", discovery.TargetID)
}
// 1b. Hostname aliases should canonicalize to the same host agent ID and
// old alias records should be cleaned up so we keep one discovery per resource.
hostnameAliasID := MakeResourceID(ResourceTypeAgent, "pve1-host", "pve1-host")
if err := store.Save(&ResourceDiscovery{
ID: hostnameAliasID,
ResourceType: ResourceTypeAgent,
TargetID: "pve1-host",
ResourceID: "pve1-host",
ServiceName: "Alias Entry",
}); err != nil {
t.Fatalf("failed to seed hostname alias discovery: %v", err)
}
discoveryFromHostname, err := service.DiscoverResource(ctx, DiscoveryRequest{
ResourceType: ResourceTypeAgent,
TargetID: "pve1-host",
ResourceID: "pve1-host",
Hostname: "pve1-host",
Force: true,
})
if err != nil {
t.Fatalf("DiscoverResource hostname alias error: %v", err)
}
if discoveryFromHostname == nil {
t.Fatalf("DiscoverResource hostname alias returned nil")
}
if discoveryFromHostname.ID != expectedID {
t.Errorf("hostname alias canonicalization failed. Got %s, want %s", discoveryFromHostname.ID, expectedID)
}
aliasAfter, err := store.Get(hostnameAliasID)
if err != nil {
t.Fatalf("failed to load hostname alias after canonicalization: %v", err)
}
if aliasAfter != nil {
t.Errorf("expected hostname alias discovery to be cleaned up, still found %s", hostnameAliasID)
}
// 2. Test GetDiscoveryByResource redirection (standard case)
// Try to get discovery using the PVE node name
got, err := service.GetDiscoveryByResource(ResourceTypeAgent, "pve1", "pve1")
if err != nil {
t.Fatalf("GetDiscoveryByResource error: %v", err)
}
if got == nil {
t.Fatalf("GetDiscoveryByResource returned nil")
}
// It should return the discovery we just created (which is under agent-pve1)
if got.ID != expectedID {
t.Errorf("GetDiscoveryByResource returned wrong discovery. Got ID %s, want %s", got.ID, expectedID)
}
// 2b. Hostname lookups should resolve to the same canonical discovery.
gotByHostname, err := service.GetDiscoveryByResource(ResourceTypeAgent, "pve1-host", "pve1-host")
if err != nil {
t.Fatalf("GetDiscoveryByResource hostname lookup error: %v", err)
}
if gotByHostname == nil {
t.Fatalf("GetDiscoveryByResource hostname lookup returned nil")
}
if gotByHostname.ID != expectedID {
t.Errorf("GetDiscoveryByResource hostname lookup returned wrong discovery. Got ID %s, want %s", gotByHostname.ID, expectedID)
}
// 3. Test GetDiscoveryByResource fallback
// Create a "legacy" discovery that only exists under the node ID
legacyID := MakeResourceID(ResourceTypeAgent, "pve1", "pve1")
legacyDiscovery := &ResourceDiscovery{
ID: legacyID,
ResourceType: ResourceTypeAgent,
TargetID: "pve1",
ResourceID: "pve1",
ServiceName: "Legacy PVE",
}
if err := store.Save(legacyDiscovery); err != nil {
t.Fatalf("Failed to save legacy discovery: %v", err)
}
// Temporarily remove the "agent" discovery to force fallback
if err := store.Delete(expectedID); err != nil {
t.Fatalf("Failed to delete agent discovery: %v", err)
}
// Try to get "pve1" again. It should redirect to agent-pve1 (not found), then fallback to pve1 (found)
gotLegacy, err := service.GetDiscoveryByResource(ResourceTypeAgent, "pve1", "pve1")
if err != nil {
t.Fatalf("GetDiscoveryByResource fallback error: %v", err)
}
if gotLegacy == nil {
t.Fatalf("GetDiscoveryByResource fallback returned nil")
}
if gotLegacy.ID != legacyID {
t.Errorf("GetDiscoveryByResource fallback returned wrong ID. Got %s, want %s", gotLegacy.ID, legacyID)
}
// 4. Test Deduplication
// Restore the agent discovery, so we have BOTH legacy (pve1) and agent (agent-pve1)
if err := store.Save(discovery); err != nil {
t.Fatalf("Failed to restore agent discovery: %v", err)
}
list, err := service.ListDiscoveries()
if err != nil {
t.Fatalf("ListDiscoveries error: %v", err)
}
// We expect deduplication to remove the legacy pve1 entry because agent-pve1 exists
foundLegacy := false
foundAgent := false
for _, d := range list {
if d.ID == legacyID {
foundLegacy = true
}
if d.ID == expectedID {
foundAgent = true
}
}
if foundLegacy {
t.Errorf("Deduplication failed: Legacy PVE node discovery should have been filtered out")
}
if !foundAgent {
t.Errorf("Deduplication failed: Agent discovery should be present")
}
}
func TestService_DiscoverResource_HostSuggestedURLFallbackForLinkedNode(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
state := StateSnapshot{
Nodes: []Node{
{
ID: "pve-id-1",
Name: "pve1",
LinkedAgentID: "agent-pve1",
},
},
Hosts: []Host{
{
ID: "agent-pve1",
Hostname: "pve1-host",
},
},
}
service := NewService(store, nil, DefaultConfig())
service.SetReadState(readStateFromSnapshot(state))
service.SetAIAnalyzer(&stubAnalyzer{
response: `{"service_type":"unknown","service_name":"Linux Host","service_version":"","category":"unknown","cli_access":"ssh root@host","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.5,"reasoning":"metadata only"}`,
})
discovery, err := service.DiscoverResource(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeAgent,
TargetID: "pve1",
ResourceID: "pve1",
Force: true,
})
if err != nil {
t.Fatalf("DiscoverResource error: %v", err)
}
if discovery == nil {
t.Fatalf("DiscoverResource returned nil discovery")
}
if discovery.SuggestedURL != "https://pve1-host:8006" {
t.Fatalf("unexpected suggested URL. got %q want %q", discovery.SuggestedURL, "https://pve1-host:8006")
}
if discovery.SuggestedURLSourceCode == "" {
t.Fatalf("expected URL suggestion source code")
}
if discovery.SuggestedURLSourceDetail == "" {
t.Fatalf("expected URL suggestion source detail")
}
if discovery.SuggestedURLDiagnostic != "" {
t.Fatalf("expected no URL suggestion diagnostic, got %q", discovery.SuggestedURLDiagnostic)
}
}
func TestService_DiscoverResource_URLSuggestionDiagnostics(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
service := NewService(store, nil, DefaultConfig())
service.SetReadState(readStateFromSnapshot(StateSnapshot{}))
service.SetAIAnalyzer(&stubAnalyzer{
response: `{"service_type":"unknown","service_name":"Generic Host","service_version":"","category":"unknown","cli_access":"ssh root@host","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.6,"reasoning":"metadata-only identification"}`,
})
discovery, err := service.DiscoverResource(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeAgent,
TargetID: "missing-host",
ResourceID: "missing-host",
Hostname: "Display Name With Spaces",
Force: true,
})
if err != nil {
t.Fatalf("DiscoverResource error: %v", err)
}
if discovery == nil {
t.Fatalf("DiscoverResource returned nil discovery")
}
if discovery.SuggestedURL != "" {
t.Fatalf("expected no suggested URL, got %q", discovery.SuggestedURL)
}
if discovery.SuggestedURLDiagnostic != "no host or IP candidate available" {
t.Fatalf("expected URL suggestion diagnostic, got %q", discovery.SuggestedURLDiagnostic)
}
}
func TestService_DiscoverResource_URLSuggestionSource_Primary(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
state := StateSnapshot{
DockerHosts: []DockerHost{
{
AgentID: "host1",
Hostname: "10.0.0.22",
Containers: []DockerContainer{
{Name: "web", Image: "nginx:latest", Status: "running"},
},
},
},
}
service := NewService(store, nil, DefaultConfig())
service.SetReadState(readStateFromSnapshot(state))
service.SetAIAnalyzer(&stubAnalyzer{
response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"docker exec web nginx -v","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"identified from image"}`,
})
discovery, err := service.DiscoverResource(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeDocker,
ResourceID: "web",
TargetID: "host1",
Hostname: "10.0.0.22",
Force: true,
})
if err != nil {
t.Fatalf("DiscoverResource error: %v", err)
}
if discovery == nil {
t.Fatalf("DiscoverResource returned nil discovery")
}
if discovery.SuggestedURL != "http://10.0.0.22" {
t.Fatalf("expected nginx default suggested URL, got %q", discovery.SuggestedURL)
}
if discovery.SuggestedURLSourceCode != "service_default_match" {
t.Fatalf("expected primary URL suggestion source code, got %q", discovery.SuggestedURLSourceCode)
}
if discovery.SuggestedURLSourceDetail == "" {
t.Fatalf("expected primary URL suggestion source detail")
}
if discovery.SuggestedURLDiagnostic != "" {
t.Fatalf("expected no URL diagnostic for successful suggestion, got %q", discovery.SuggestedURLDiagnostic)
}
}
func TestParseLegacyURLSuggestionReasoning(t *testing.T) {
reasoning := `[URL suggestion source: service_default_match (service default: nginx)] [URL suggestion unavailable: no host or IP candidate available] metadata-only discovery`
cleaned, code, detail, diagnostic := parseLegacyURLSuggestionReasoning(reasoning)
if cleaned != "metadata-only discovery" {
t.Fatalf("unexpected cleaned reasoning: got %q", cleaned)
}
if code != "service_default_match" {
t.Fatalf("unexpected source code: got %q", code)
}
if detail != "service default: nginx" {
t.Fatalf("unexpected source detail: got %q", detail)
}
if diagnostic != "no host or IP candidate available" {
t.Fatalf("unexpected diagnostic: got %q", diagnostic)
}
}
func TestService_DiscoverResource_ReturnsUpgradedCachedDiscovery(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
id := MakeResourceID(ResourceTypeDocker, "host1", "web")
legacy := &ResourceDiscovery{
ID: id,
ResourceType: ResourceTypeDocker,
TargetID: "host1",
ResourceID: "web",
ServiceType: "nginx",
Category: CategoryWebServer,
CLIAccess: "docker exec web bash",
AIReasoning: `[URL suggestion source: service_default_match (service default: nginx)] previous discovery`,
DiscoveredAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := store.Save(legacy); err != nil {
t.Fatalf("save legacy discovery error: %v", err)
}
service := NewService(store, nil, DefaultConfig())
got, err := service.DiscoverResource(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeDocker,
TargetID: "host1",
ResourceID: "web",
})
if err != nil {
t.Fatalf("DiscoverResource cached error: %v", err)
}
if got == nil {
t.Fatalf("DiscoverResource returned nil")
}
if got.SuggestedURLSourceCode != "service_default_match" {
t.Fatalf("expected structured source code from legacy reasoning, got %q", got.SuggestedURLSourceCode)
}
if got.SuggestedURLSourceDetail != "service default: nginx" {
t.Fatalf("expected structured source detail from legacy reasoning, got %q", got.SuggestedURLSourceDetail)
}
if got.AIReasoning != "previous discovery" {
t.Fatalf("expected cleaned AI reasoning without legacy URL note, got %q", got.AIReasoning)
}
}