Pulse/internal/servicediscovery/service_test.go
rcourtman 23a930e849 fix(discovery): bring hosts into the fingerprint model so they auto-discover (#1479)
Hosts (PVE nodes and host agents) were entirely outside the discovery
fingerprint model. Two consequences, one root:

1. collectFingerprints only covered docker/lxc/vm/k8s, so a host never
   appeared in GetChangedResources and automatic discovery never ran for
   it. Only the per-resource manual trigger worked, which is exactly the
   v6.0.5-rc.3 behaviour reported in #1479.

2. cleanupOrphanedData built its keep-set from the same four types, so
   CleanupOrphanedDiscoveries swept every agent:* discovery as an orphan
   on the next fingerprint cycle (default 5 minutes). Manually discovered
   hosts silently reverted to undiscovered.

Fix: add GenerateHostFingerprint (identity, OS, kernel, arch, tags;
status excluded so online/offline flapping cannot trigger rediscovery),
fingerprint snap.Hosts under the canonical agent:<id>:<id> key, and keep
host/node discovery keys (canonical, hostname-alias, and legacy host:
prefix forms) out of orphan cleanup. Also purge the store's in-memory
cache when an orphaned discovery file is removed, so deletions are not
masked for the cache TTL.
2026-07-07 22:55:04 +01:00

2396 lines
77 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()
}
type captureProgressHub struct {
mu sync.Mutex
progress []*DiscoveryProgress
}
func (h *captureProgressHub) BroadcastDiscoveryProgress(progress *DiscoveryProgress) {
if progress == nil {
return
}
progressCopy := *progress
h.mu.Lock()
defer h.mu.Unlock()
h.progress = append(h.progress, &progressCopy)
}
func (h *captureProgressHub) snapshots() []*DiscoveryProgress {
h.mu.Lock()
defer h.mu.Unlock()
out := make([]*DiscoveryProgress, len(h.progress))
copy(out, h.progress)
return out
}
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)
}
}
})
}
}
func TestService_DiscoverResource_ProgressDescribesModelAnalysisNotAssistantChat(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.SetAIAnalyzer(&stubAnalyzer{
response: `{"service_type":"postgresql","service_name":"PostgreSQL","service_version":"16","category":"database","cli_access":"ssh root@host","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"metadata-only identification"}`,
})
hub := &captureProgressHub{}
service.SetWSHub(hub)
_, err = service.DiscoverResource(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeAgent,
TargetID: "agent-1",
ResourceID: "agent-1",
Hostname: "agent-1",
Force: true,
})
if err != nil {
t.Fatalf("DiscoverResource error: %v", err)
}
var analysisProgress *DiscoveryProgress
for _, progress := range hub.snapshots() {
if progress.Status == DiscoveryStatusRunning {
analysisProgress = progress
break
}
}
if analysisProgress == nil {
t.Fatalf("expected running analysis progress")
}
if strings.Contains(analysisProgress.CurrentStep, "Pulse Assistant") {
t.Fatalf("progress step must not imply a live Assistant chat, got %q", analysisProgress.CurrentStep)
}
if analysisProgress.CurrentStep != "Analyzing discovery evidence with the selected model..." {
t.Fatalf("unexpected progress step: %q", analysisProgress.CurrentStep)
}
if analysisProgress.PercentComplete != 75 {
t.Fatalf("expected analysis progress at 75%%, got %v", analysisProgress.PercentComplete)
}
}
func TestService_DiscoverResource_AbstainsWithoutCommandEvidence(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
// Command scanning is enabled with a scanner, but the scan yields nothing
// (nil executor → no command output) — mirroring a host agent that rejects
// exec. The stub returns a fully fabricated Pi-hole identity. The service
// must abstain: the analyzer must NOT be called and the fabrication must NOT
// be adopted. Guards the metadata-only hallucination bug where an ESPHome LXC
// was "identified" as Pi-hole at 0.95 confidence.
service := NewService(store, NewDeepScanner(nil), DefaultConfig())
service.SetCommandScanningEnabled(true)
analyzer := &stubAnalyzer{
response: `{"service_type":"pi-hole","service_name":"Pi-hole","confidence":0.95,"facts":[{"key":"core_version","value":"5.18.2","source":"pihole -v"}],"config_paths":["/etc/pihole/"],"reasoning":"identified Pi-hole"}`,
}
service.SetAIAnalyzer(analyzer)
d, err := service.DiscoverResource(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeSystemContainer,
TargetID: "delly",
ResourceID: "102",
// Generic name that matches no known service, so the deterministic
// surface fast-path does NOT fire — this exercises the pure abstention
// path (no command evidence, nothing to identify from).
Hostname: "ct-200",
Force: true,
})
if err != nil {
t.Fatalf("DiscoverResource error: %v", err)
}
if d == nil {
t.Fatal("expected a discovery result")
}
if analyzer.calls != 0 {
t.Fatalf("analyzer must not be called without command evidence; got %d calls", analyzer.calls)
}
if d.ServiceName == "Pi-hole" || d.ServiceType == "pi-hole" {
t.Fatalf("must not adopt fabricated identity, got type=%q name=%q", d.ServiceType, d.ServiceName)
}
if !strings.Contains(d.AIReasoning, "Pulse Commands") {
t.Fatalf("reasoning should guide enabling Pulse Commands, got %q", d.AIReasoning)
}
}
// 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,
OSName: vm.OSName,
OSVersion: vm.OSVersion,
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,
OSTemplate: ct.OSTemplate,
OSName: ct.OSName,
IsOCI: ct.IsOCI,
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{}
tests := []struct {
name string
response string
wantService string
wantName string
}{
{
name: "markdown block",
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```",
wantService: "nginx",
wantName: "Nginx",
},
{
name: "inline language fence",
response: "```json {\"service_type\":\"debian\",\"service_name\":\"Debian 13 (Trixie) Server\",\"service_version\":\"13.5\",\"category\":\"virtualizer\",\"cli_access\":\"ssh user@pve-qdev\",\"facts\":[{\"category\":\"version\",\"key\":\"OS Codename\",\"value\":\"trixie\",\"source\":\"os_release\",\"confidence\":0.95}],\"config_paths\":[],\"data_paths\":[],\"ports\":[],\"confidence\":0.95,\"reasoning\":\"os release\"} ```",
wantService: "debian",
wantName: "Debian 13 (Trixie) Server",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parsed := service.parseAIResponse(tt.response)
if parsed == nil {
t.Fatalf("expected parsed response")
}
if parsed.ServiceType != tt.wantService || parsed.ServiceName != tt.wantName {
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_RunManualDiscoveryRefreshDiscoversNewFingerprints(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"},
},
},
},
}
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":["/etc/nginx"],"data_paths":[],"log_paths":[],"ports":[{"port":80,"protocol":"tcp","process":"nginx","address":"0.0.0.0"}],"confidence":0.9,"reasoning":"image"}`,
}
service := NewService(store, nil, DefaultConfig())
service.SetCommandScanningEnabled(true)
service.SetReadState(readStateFromSnapshot(state))
service.SetAIAnalyzer(analyzer)
summary, err := service.RunManualDiscoveryRefresh(context.Background())
if err != nil {
t.Fatalf("RunManualDiscoveryRefresh error: %v", err)
}
if summary.Mode != "manual" || summary.CandidateCount != 1 || summary.DiscoveredCount != 1 || summary.FailedCount != 0 {
t.Fatalf("unexpected summary: %+v", summary)
}
if summary.FingerprintCount != 1 || summary.ChangedCount != 1 {
t.Fatalf("expected one new changed fingerprint, got %+v", summary)
}
if analyzer.calls != 1 {
t.Fatalf("expected one analyzer call, got %d", analyzer.calls)
}
if got, err := service.GetDiscovery("docker:host1:web"); err != nil || got == nil || got.ServiceName != "Nginx" {
t.Fatalf("expected stored Nginx discovery, got %#v err=%v", got, err)
}
}
// Regression: collectFingerprints used to panic-and-recover for every LXC/VM
// because processFingerprint went through reflect.Value.Interface(), losing
// addressability so reflect.Call hit a Container-vs-*Container type mismatch.
// Fingerprints for LXC and VMs silently never landed in the store. This test
// fails (no rows under the expected keys) if anyone reintroduces that.
func TestService_CollectFingerprints_LXCAndVM(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
state := StateSnapshot{
Containers: []Container{
{VMID: 100, Name: "lxc-one", Node: "pve1", Status: "running", OSTemplate: "debian-12", CPUs: 2, MaxMemory: 2 << 30},
{VMID: 101, Name: "lxc-two", Node: "pve1", Status: "running", OSTemplate: "ubuntu-22", CPUs: 1, MaxMemory: 1 << 30},
},
VMs: []VM{
{VMID: 200, Name: "vm-one", Node: "pve1", Status: "running", OSName: "ubuntu", CPUs: 4, MaxMemory: 4 << 30},
},
}
service := NewService(store, nil, DefaultConfig())
service.SetReadState(readStateFromSnapshot(state))
service.collectFingerprints(context.Background())
for _, key := range []string{
"system-container:pve1:100",
"system-container:pve1:101",
"vm:pve1:200",
} {
fp, err := store.GetFingerprint(key)
if err != nil {
t.Fatalf("GetFingerprint(%q) error: %v", key, err)
}
if fp == nil {
t.Fatalf("expected fingerprint at %q, got nil — processFingerprint silently dropped it", key)
}
if fp.Hash == "" {
t.Fatalf("fingerprint %q has empty hash", key)
}
}
}
// Regression for #1479: hosts never entered the fingerprint model, so a new
// PVE host / host agent never appeared in GetChangedResources and automatic
// discovery never ran for it — only per-resource manual triggers worked.
func TestService_CollectFingerprints_HostsBecomeAutoDiscoveryCandidates(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
state := StateSnapshot{
Hosts: []Host{
{ID: "agent-uuid-1", Hostname: "pve-one", Platform: "linux", OSName: "Debian", OSVersion: "13", KernelVersion: "6.8.0", Architecture: "amd64", Status: "online"},
},
Containers: []Container{
{VMID: 100, Name: "lxc-one", Node: "pve-one", Status: "running", OSTemplate: "debian-12", CPUs: 2, MaxMemory: 2 << 30},
},
}
service := NewService(store, nil, DefaultConfig())
service.SetReadState(readStateFromSnapshot(state))
service.collectFingerprints(context.Background())
hostKey := "agent:agent-uuid-1:agent-uuid-1"
fp, err := store.GetFingerprint(hostKey)
if err != nil {
t.Fatalf("GetFingerprint(%q) error: %v", hostKey, err)
}
if fp == nil || fp.Hash == "" {
t.Fatalf("expected host fingerprint at %q, got %#v", hostKey, fp)
}
changed, err := store.GetChangedResources()
if err != nil {
t.Fatalf("GetChangedResources error: %v", err)
}
found := false
for _, id := range changed {
if id == hostKey {
found = true
break
}
}
if !found {
t.Fatalf("expected undiscovered host %q in changed resources, got %v", hostKey, changed)
}
}
// Regression for #1479: cleanupOrphanedData built its keep-set only from
// docker/system-container/vm/k8s IDs, so every host discovery (agent:*) was
// deleted as an orphan on the next fingerprint cycle — a manually discovered
// host reverted to undiscovered within minutes.
func TestService_CleanupPreservesLiveHostDiscoveries(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
live := &ResourceDiscovery{
ID: "agent:agent-uuid-1:agent-uuid-1",
ResourceType: ResourceTypeAgent,
TargetID: "agent-uuid-1",
ResourceID: "agent-uuid-1",
Hostname: "pve-one",
ServiceType: "proxmox",
}
orphan := &ResourceDiscovery{
ID: "agent:gone-uuid:gone-uuid",
ResourceType: ResourceTypeAgent,
TargetID: "gone-uuid",
ResourceID: "gone-uuid",
Hostname: "decommissioned",
ServiceType: "debian",
}
for _, d := range []*ResourceDiscovery{live, orphan} {
if err := store.Save(d); err != nil {
t.Fatalf("Save(%s) error: %v", d.ID, err)
}
}
state := StateSnapshot{
Hosts: []Host{
{ID: "agent-uuid-1", Hostname: "pve-one", Platform: "linux", Status: "online"},
},
Containers: []Container{
{VMID: 100, Name: "lxc-one", Node: "pve-one", Status: "running"},
},
}
service := NewService(store, nil, DefaultConfig())
service.SetReadState(readStateFromSnapshot(state))
service.cleanupOrphanedData(state)
if got, err := store.Get(live.ID); err != nil || got == nil {
t.Fatalf("live host discovery was swept as an orphan: got %#v err=%v", got, err)
}
if got, err := store.Get(orphan.ID); err != nil {
t.Fatalf("Get(orphan) error: %v", err)
} else if got != nil {
t.Fatalf("expected decommissioned host discovery to be removed, still present: %#v", got)
}
}
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")
}
if !strings.Contains(deepPrompt, "Limit output to at most 12 facts") {
t.Fatalf("expected deep prompt to limit response size")
}
// The analyzer must capture what the context pack now surfaces (iter 5):
// how to restart/reload the service, and the specific key files a user edits.
if !strings.Contains(deepPrompt, "systemd unit") {
t.Fatalf("expected deep prompt to ask how the service is restarted/reloaded")
}
if !strings.Contains(deepPrompt, "specific key files") {
t.Fatalf("expected deep prompt to ask for specific key files, not just config dirs")
}
hostPrompt := service.buildDeepAnalysisPrompt(AIAnalysisRequest{
ResourceType: ResourceTypeAgent,
ResourceID: "host1",
TargetID: "host1",
Hostname: "pve01",
Metadata: map[string]any{"platform": "proxmox"},
})
if !strings.Contains(hostPrompt, "HOST OS") {
t.Fatalf("expected host prompt variant")
}
if !strings.Contains(hostPrompt, "Limit output to at most 12 facts") {
t.Fatalf("expected host prompt to limit response size")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
service.initialDelay = time.Millisecond
service.SetCommandScanningEnabled(true)
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))
service.SetCommandScanningEnabled(true)
// 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
service.SetCommandScanningEnabled(true)
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)
cfg := DefaultConfig()
cfg.CommandScanning = true
service := NewService(store, scanner, cfg)
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_DiscoverResource_CommandScanningDisabledSkipsScanner(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)
service := NewService(store, scanner, DefaultConfig())
service.SetAIAnalyzer(&stubAnalyzer{
response: `{"service_type":"nginx","service_name":"Nginx","service_version":"1.2","category":"web_server","cli_access":"","facts":[],"config_paths":[],"data_paths":[],"ports":[],"confidence":0.9,"reasoning":"metadata"}`,
})
_, 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)
}
exec.mu.Lock()
defer exec.mu.Unlock()
if len(exec.commands) != 0 {
t.Fatalf("expected command scanning disabled to skip executor, got commands: %v", exec.commands)
}
}
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"},
},
},
},
}
cfg := DefaultConfig()
cfg.CommandScanning = true
service := NewService(store, scanner, cfg)
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_ProxmoxGuestDiscoveryCanonicalizesToNodeKey(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
// A Proxmox node "delly" whose linked agent is a UUID, hosting CT 101.
const nodeName = "delly"
const agentUUID = "7d465a78-80b8-4255-999c-88224ae57b4f"
state := StateSnapshot{
Nodes: []Node{{ID: "delly-id", Name: nodeName, LinkedAgentID: agentUUID}},
Hosts: []Host{{ID: agentUUID, Hostname: nodeName}},
}
service := NewService(store, nil, DefaultConfig())
service.SetReadState(readStateFromSnapshot(state))
// The background loop stored the discovery under the canonical node-name key.
canonicalID := MakeResourceID(ResourceTypeSystemContainer, nodeName, "101")
if err := store.Save(&ResourceDiscovery{
ID: canonicalID,
ResourceType: ResourceTypeSystemContainer,
TargetID: nodeName,
ResourceID: "101",
ServiceName: "Home Assistant",
}); err != nil {
t.Fatalf("seed discovery: %v", err)
}
// The drawer/readiness path looks the record up by the linked agent UUID (the
// discoveryTarget.agentId action target). It must canonicalize to the
// node-name key and find the record, not report "not discovered".
got, err := service.GetDiscoveryByResource(ResourceTypeSystemContainer, agentUUID, "101")
if err != nil {
t.Fatalf("GetDiscoveryByResource(agent-uuid) error: %v", err)
}
if got == nil {
t.Fatal("expected the node-name discovery record to be found via the agent-UUID target")
}
if got.ID != canonicalID {
t.Fatalf("found discovery ID = %q, want canonical node-name key %q", got.ID, canonicalID)
}
// The node-name target (background loop / Assistant) still finds it directly.
if got2, err := service.GetDiscoveryByResource(ResourceTypeSystemContainer, nodeName, "101"); err != nil || got2 == nil {
t.Fatalf("node-name lookup failed: got=%v err=%v", got2, err)
}
}
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_InfersESPHomeLXCFromStateName(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
state := StateSnapshot{
Containers: []Container{
{
VMID: 102,
Name: "esphome",
Node: "pve1",
Status: "running",
OSTemplate: "debian-12-standard",
},
},
}
service := NewService(store, nil, DefaultConfig())
service.SetReadState(readStateFromSnapshot(state))
service.SetAIAnalyzer(&stubAnalyzer{
response: `{"service_type":"unknown","service_name":"Unknown Container","service_version":"","category":"unknown","cli_access":"pct exec 102 -- /bin/bash","facts":[],"config_paths":[],"data_paths":[],"log_paths":[],"ports":[],"confidence":0,"reasoning":"metadata was inconclusive"}`,
})
discovery, err := service.DiscoverResource(context.Background(), DiscoveryRequest{
ResourceType: ResourceTypeSystemContainer,
TargetID: "pve1",
ResourceID: "102",
Force: true,
})
if err != nil {
t.Fatalf("DiscoverResource error: %v", err)
}
if discovery.ServiceType != "esphome" {
t.Fatalf("expected service type esphome, got %q", discovery.ServiceType)
}
if discovery.ServiceName != "ESPHome" {
t.Fatalf("expected service name ESPHome, got %q", discovery.ServiceName)
}
if discovery.Category != CategoryHomeAuto {
t.Fatalf("expected home automation category, got %q", discovery.Category)
}
if discovery.Hostname != "esphome" {
t.Fatalf("expected hostname from LXC state name, got %q", discovery.Hostname)
}
if discovery.SuggestedURL != "http://esphome:6052" {
t.Fatalf("expected ESPHome dashboard suggestion, got %q", discovery.SuggestedURL)
}
if discovery.SuggestedURLDiagnostic != "" {
t.Fatalf("expected no URL diagnostic, got %q", discovery.SuggestedURLDiagnostic)
}
if discovery.Confidence < 0.85 {
t.Fatalf("expected deterministic confidence floor, got %v", discovery.Confidence)
}
}
func TestService_GetDiscovery_RepairsCachedESPHomeUnknownResult(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
state := StateSnapshot{
Containers: []Container{
{VMID: 102, Name: "esphome", Node: "pve1", Status: "running"},
},
}
id := MakeResourceID(ResourceTypeSystemContainer, "pve1", "102")
if err := store.Save(&ResourceDiscovery{
ID: id,
ResourceType: ResourceTypeSystemContainer,
TargetID: "pve1",
ResourceID: "102",
ServiceType: "unknown",
ServiceName: "Unknown Service",
Category: CategoryUnknown,
Confidence: 0,
SuggestedURLDiagnostic: "no host or IP candidate available",
CLIAccessVersion: CLIAccessVersion,
FingerprintSchemaVersion: FingerprintSchemaVersion,
}); err != nil {
t.Fatalf("Save error: %v", err)
}
service := NewService(store, nil, DefaultConfig())
service.SetReadState(readStateFromSnapshot(state))
discovery, err := service.GetDiscoveryByResource(ResourceTypeSystemContainer, "pve1", "102")
if err != nil {
t.Fatalf("GetDiscoveryByResource error: %v", err)
}
if discovery == nil {
t.Fatalf("expected cached discovery")
}
if discovery.ServiceType != "esphome" || discovery.ServiceName != "ESPHome" {
t.Fatalf("expected repaired ESPHome identity, got type=%q name=%q", discovery.ServiceType, discovery.ServiceName)
}
if discovery.SuggestedURL != "http://esphome:6052" {
t.Fatalf("expected repaired ESPHome URL, got %q", discovery.SuggestedURL)
}
if discovery.SuggestedURLDiagnostic != "" {
t.Fatalf("expected repaired URL diagnostic to be cleared, got %q", discovery.SuggestedURLDiagnostic)
}
}
func TestService_RunManualDiscoveryRefreshRepairsFreshUnknownKnownService(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatalf("NewStore error: %v", err)
}
store.crypto = nil
state := StateSnapshot{
Containers: []Container{
{VMID: 102, Name: "esphome", Node: "pve1", Status: "running"},
},
}
service := NewService(store, nil, DefaultConfig())
service.SetReadState(readStateFromSnapshot(state))
service.SetCommandScanningEnabled(true)
analyzer := &stubAnalyzer{
response: `{"service_type":"unknown","service_name":"Unknown Service","service_version":"","category":"unknown","cli_access":"pct exec 102 -- /bin/bash","facts":[],"config_paths":[],"data_paths":[],"log_paths":[],"ports":[],"confidence":0,"reasoning":"metadata was inconclusive"}`,
}
service.SetAIAnalyzer(analyzer)
service.collectFingerprints(context.Background())
id := MakeResourceID(ResourceTypeSystemContainer, "pve1", "102")
fp, err := store.GetFingerprint(id)
if err != nil {
t.Fatalf("GetFingerprint error: %v", err)
}
if fp == nil {
t.Fatalf("expected LXC fingerprint")
}
if err := store.Save(&ResourceDiscovery{
ID: id,
ResourceType: ResourceTypeSystemContainer,
TargetID: "pve1",
ResourceID: "102",
Hostname: "esphome",
ServiceType: "unknown",
ServiceName: "Unknown Service",
Category: CategoryUnknown,
Confidence: 0,
Fingerprint: fp.Hash,
FingerprintedAt: fp.GeneratedAt,
FingerprintSchemaVersion: fp.SchemaVersion,
CLIAccessVersion: CLIAccessVersion,
}); err != nil {
t.Fatalf("Save discovery error: %v", err)
}
summary, err := service.RunManualDiscoveryRefresh(context.Background())
if err != nil {
t.Fatalf("RunManualDiscoveryRefresh error: %v", err)
}
if summary.CandidateCount != 1 || summary.DiscoveredCount != 1 || summary.FailedCount != 0 {
t.Fatalf("expected one repaired candidate, got %+v", summary)
}
// The resource is named "esphome", so the deterministic surface fast-path
// identifies it before the model — the repair happens with no analyzer call.
if analyzer.calls != 0 {
t.Fatalf("expected zero analyzer calls (fast-path identity), got %d", analyzer.calls)
}
discovery, err := store.Get(id)
if err != nil {
t.Fatalf("Get discovery error: %v", err)
}
if discovery.ServiceType != "esphome" || discovery.ServiceName != "ESPHome" {
t.Fatalf("expected stored repaired ESPHome discovery, got type=%q name=%q", discovery.ServiceType, discovery.ServiceName)
}
if discovery.SuggestedURL != "http://esphome:6052" {
t.Fatalf("expected stored ESPHome URL, got %q", discovery.SuggestedURL)
}
// A freshly produced discovery carries the current engine version so the panel
// can flag pre-upgrade (stale) data even within the time-based freshness window.
if discovery.DiscoveryEngineVersion != DiscoveryEngineVersion {
t.Fatalf("expected engine version %d stamped, got %d", DiscoveryEngineVersion, discovery.DiscoveryEngineVersion)
}
}
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",
// Complete, confident identity so the known-service improver sees nothing
// to upgrade and the cached record is returned as-is (this test covers
// cache return + URL-source parsing, not re-identification).
ServiceName: "Nginx",
Category: CategoryWebServer,
CLIAccess: "docker exec web bash",
Confidence: 0.9,
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)
}
}