Pulse/internal/resources/store_test.go
rcourtman 5f000b7974 feat: Complete Unified Resource Architecture (Phases 1-3)
This commit implements the Unified Resource Architecture for AI-first
infrastructure management. Key features:

Phase 1 - Backend Unification:
- New unified Resource type with 9 resource types, 7 platforms, 7 statuses
- Resource store with identity-based deduplication (hostname, machineID, IP)
- 8 converter functions (FromNode, FromVM, FromContainer, etc.)
- REST API endpoints: /api/resources, /api/resources/stats, /api/resources/{id}
- 28 comprehensive unit tests

Phase 2 - AI Context Enhancement:
- Unified context builder for AI system prompts
- Cross-platform query methods: GetTopByCPU, GetTopByMemory, GetTopByDisk
- Resource correlation: GetRelated (parent, children, siblings, cluster)
- Infrastructure summary: GetResourceSummary with health status counts
- AI context now includes top consumers and infrastructure overview

Phase 3 - Agent Preference & Hybrid Mode:
- Polling optimization methods in resource store
- ResourceStoreInterface added to Monitor
- SetResourceStore() and shouldSkipNodeMetrics() helper methods
- Store automatically wired into Monitor via Router.SetMonitor()
- Foundation ready for reduced API polling when agents are active

Files added:
- internal/resources/resource.go - Core Resource type
- internal/resources/store.go - Store with deduplication
- internal/resources/converters.go - Type converters
- internal/resources/platform_data.go - Platform-specific data
- internal/resources/store_test.go - 28 tests
- internal/resources/converters_test.go - Converter tests
- internal/api/resource_handlers.go - REST API handlers
- internal/ai/resource_context.go - AI context builder
- .gemini/docs/unified-resource-architecture.md - Architecture docs

All tests pass.
2025-12-07 13:49:00 +00:00

740 lines
19 KiB
Go

package resources
import (
"testing"
"time"
)
func TestStoreUpsertAndGet(t *testing.T) {
store := NewStore()
r := Resource{
ID: "test-1",
Type: ResourceTypeNode,
Name: "node1",
PlatformType: PlatformProxmoxPVE,
SourceType: SourceAPI,
Status: StatusOnline,
LastSeen: time.Now(),
}
id := store.Upsert(r)
if id != "test-1" {
t.Errorf("Expected ID test-1, got %s", id)
}
retrieved, ok := store.Get("test-1")
if !ok {
t.Fatal("Failed to retrieve resource")
}
if retrieved.Name != "node1" {
t.Errorf("Expected name node1, got %s", retrieved.Name)
}
}
func TestStoreGetAll(t *testing.T) {
store := NewStore()
store.Upsert(Resource{ID: "1", Type: ResourceTypeNode, LastSeen: time.Now()})
store.Upsert(Resource{ID: "2", Type: ResourceTypeVM, LastSeen: time.Now()})
store.Upsert(Resource{ID: "3", Type: ResourceTypeContainer, LastSeen: time.Now()})
all := store.GetAll()
if len(all) != 3 {
t.Errorf("Expected 3 resources, got %d", len(all))
}
}
func TestStoreGetByType(t *testing.T) {
store := NewStore()
store.Upsert(Resource{ID: "node1", Type: ResourceTypeNode, LastSeen: time.Now()})
store.Upsert(Resource{ID: "node2", Type: ResourceTypeNode, LastSeen: time.Now()})
store.Upsert(Resource{ID: "vm1", Type: ResourceTypeVM, LastSeen: time.Now()})
nodes := store.GetByType(ResourceTypeNode)
if len(nodes) != 2 {
t.Errorf("Expected 2 nodes, got %d", len(nodes))
}
vms := store.GetByType(ResourceTypeVM)
if len(vms) != 1 {
t.Errorf("Expected 1 VM, got %d", len(vms))
}
}
func TestStoreGetByPlatform(t *testing.T) {
store := NewStore()
store.Upsert(Resource{ID: "1", PlatformType: PlatformProxmoxPVE, LastSeen: time.Now()})
store.Upsert(Resource{ID: "2", PlatformType: PlatformProxmoxPVE, LastSeen: time.Now()})
store.Upsert(Resource{ID: "3", PlatformType: PlatformDocker, LastSeen: time.Now()})
pve := store.GetByPlatform(PlatformProxmoxPVE)
if len(pve) != 2 {
t.Errorf("Expected 2 PVE resources, got %d", len(pve))
}
docker := store.GetByPlatform(PlatformDocker)
if len(docker) != 1 {
t.Errorf("Expected 1 Docker resource, got %d", len(docker))
}
}
func TestStoreGetInfrastructureAndWorkloads(t *testing.T) {
store := NewStore()
store.Upsert(Resource{ID: "node1", Type: ResourceTypeNode, LastSeen: time.Now()})
store.Upsert(Resource{ID: "host1", Type: ResourceTypeHost, LastSeen: time.Now()})
store.Upsert(Resource{ID: "vm1", Type: ResourceTypeVM, LastSeen: time.Now()})
store.Upsert(Resource{ID: "ct1", Type: ResourceTypeContainer, LastSeen: time.Now()})
store.Upsert(Resource{ID: "dc1", Type: ResourceTypeDockerContainer, LastSeen: time.Now()})
infra := store.GetInfrastructure()
if len(infra) != 2 {
t.Errorf("Expected 2 infrastructure resources, got %d", len(infra))
}
workloads := store.GetWorkloads()
if len(workloads) != 3 {
t.Errorf("Expected 3 workload resources, got %d", len(workloads))
}
}
func TestStoreGetChildren(t *testing.T) {
store := NewStore()
store.Upsert(Resource{ID: "node1", Type: ResourceTypeNode, LastSeen: time.Now()})
store.Upsert(Resource{ID: "vm1", Type: ResourceTypeVM, ParentID: "node1", LastSeen: time.Now()})
store.Upsert(Resource{ID: "vm2", Type: ResourceTypeVM, ParentID: "node1", LastSeen: time.Now()})
store.Upsert(Resource{ID: "vm3", Type: ResourceTypeVM, ParentID: "node2", LastSeen: time.Now()})
children := store.GetChildren("node1")
if len(children) != 2 {
t.Errorf("Expected 2 children of node1, got %d", len(children))
}
}
func TestStoreRemove(t *testing.T) {
store := NewStore()
store.Upsert(Resource{ID: "test-1", LastSeen: time.Now()})
store.Upsert(Resource{ID: "test-2", LastSeen: time.Now()})
if len(store.GetAll()) != 2 {
t.Fatal("Expected 2 resources before remove")
}
store.Remove("test-1")
if len(store.GetAll()) != 1 {
t.Error("Expected 1 resource after remove")
}
_, ok := store.Get("test-1")
if ok {
t.Error("Removed resource should not be retrievable")
}
}
func TestDeduplicationByHostname(t *testing.T) {
store := NewStore()
now := time.Now()
// Add a Proxmox node (API source)
nodeResource := Resource{
ID: "pve1/node/server1",
Type: ResourceTypeNode,
Name: "server1",
PlatformType: PlatformProxmoxPVE,
SourceType: SourceAPI,
Status: StatusOnline,
CPU: &MetricValue{Current: 50.0},
LastSeen: now,
Identity: &ResourceIdentity{
Hostname: "server1",
},
}
store.Upsert(nodeResource)
// Add a host agent for the same server (agent source - should be preferred)
hostResource := Resource{
ID: "host-agent/server1",
Type: ResourceTypeHost,
Name: "server1",
PlatformType: PlatformHostAgent,
SourceType: SourceAgent,
Status: StatusOnline,
CPU: &MetricValue{Current: 55.0}, // Slightly different value
LastSeen: now,
Identity: &ResourceIdentity{
Hostname: "server1",
},
}
store.Upsert(hostResource)
// We should only have 1 resource (the agent one, as it's preferred)
all := store.GetAll()
if len(all) != 1 {
t.Errorf("Expected 1 resource after dedup, got %d", len(all))
}
// The agent resource should be the one stored
r, ok := store.Get("host-agent/server1")
if !ok {
t.Fatal("Failed to get agent resource")
}
if r.SourceType != SourceAgent {
t.Errorf("Expected agent source, got %s", r.SourceType)
}
if r.CPU.Current != 55.0 {
t.Errorf("Expected CPU 55.0 from agent, got %f", r.CPU.Current)
}
// The node resource should redirect to the agent resource
if !store.IsSuppressed("pve1/node/server1") {
t.Error("Node resource should be suppressed")
}
preferred := store.GetPreferredID("pve1/node/server1")
if preferred != "host-agent/server1" {
t.Errorf("Expected preferred ID to be host-agent/server1, got %s", preferred)
}
// Accessing by suppressed ID should return the preferred resource
r, ok = store.Get("pve1/node/server1")
if !ok {
t.Fatal("Should be able to get by suppressed ID")
}
if r.ID != "host-agent/server1" {
t.Errorf("Expected to get agent resource when accessing by node ID, got %s", r.ID)
}
}
func TestDeduplicationByMachineID(t *testing.T) {
store := NewStore()
now := time.Now()
machineID := "abc-123-def-456"
// Add a Docker host
dockerHost := Resource{
ID: "docker-host-1",
Type: ResourceTypeDockerHost,
Name: "server-different-name",
PlatformType: PlatformDocker,
SourceType: SourceAgent,
Status: StatusOnline,
LastSeen: now,
Identity: &ResourceIdentity{
Hostname: "server-different-name",
MachineID: machineID,
},
}
store.Upsert(dockerHost)
// Add a host agent with the same machine ID but different hostname
hostAgent := Resource{
ID: "host-agent-1",
Type: ResourceTypeHost,
Name: "server-production",
PlatformType: PlatformHostAgent,
SourceType: SourceAgent,
Status: StatusOnline,
LastSeen: now.Add(time.Second), // Slightly newer
Identity: &ResourceIdentity{
Hostname: "server-production",
MachineID: machineID,
},
}
store.Upsert(hostAgent)
// Should only have 1 resource (the newer one, since both are agent sources)
all := store.GetAll()
if len(all) != 1 {
t.Errorf("Expected 1 resource after dedup by machineID, got %d", len(all))
}
}
func TestDeduplicationByIP(t *testing.T) {
store := NewStore()
now := time.Now()
sharedIP := "192.168.1.100"
// Add a Proxmox node
node := Resource{
ID: "node-1",
Type: ResourceTypeNode,
Name: "pve-node",
PlatformType: PlatformProxmoxPVE,
SourceType: SourceAPI,
Status: StatusOnline,
LastSeen: now,
Identity: &ResourceIdentity{
Hostname: "pve-node",
IPs: []string{sharedIP},
},
}
store.Upsert(node)
// Add a host agent with the same IP
host := Resource{
ID: "host-1",
Type: ResourceTypeHost,
Name: "different-hostname",
PlatformType: PlatformHostAgent,
SourceType: SourceAgent, // Agent preferred
Status: StatusOnline,
LastSeen: now,
Identity: &ResourceIdentity{
Hostname: "different-hostname",
IPs: []string{sharedIP},
},
}
store.Upsert(host)
// Should only have 1 resource
all := store.GetAll()
if len(all) != 1 {
t.Errorf("Expected 1 resource after dedup by IP, got %d", len(all))
}
// Agent should be preferred
r, _ := store.Get("host-1")
if r == nil || r.SourceType != SourceAgent {
t.Error("Agent resource should be preferred")
}
}
func TestNoDeduplicationForWorkloads(t *testing.T) {
store := NewStore()
now := time.Now()
// VMs with the same hostname should NOT be deduplicated
// (they're workloads, not infrastructure)
vm1 := Resource{
ID: "pve1/vm/100",
Type: ResourceTypeVM,
Name: "webserver",
PlatformType: PlatformProxmoxPVE,
SourceType: SourceAPI,
LastSeen: now,
Identity: &ResourceIdentity{
Hostname: "webserver", // Same hostname
},
}
store.Upsert(vm1)
vm2 := Resource{
ID: "pve2/vm/100",
Type: ResourceTypeVM,
Name: "webserver",
PlatformType: PlatformProxmoxPVE,
SourceType: SourceAPI,
LastSeen: now,
Identity: &ResourceIdentity{
Hostname: "webserver", // Same hostname
},
}
store.Upsert(vm2)
// Both VMs should exist (workloads are not deduplicated)
all := store.GetAll()
if len(all) != 2 {
t.Errorf("Expected 2 VMs (no dedup for workloads), got %d", len(all))
}
}
func TestNoDeduplicationForLocalhost(t *testing.T) {
store := NewStore()
now := time.Now()
host1 := Resource{
ID: "host-1",
Type: ResourceTypeHost,
Name: "server1",
PlatformType: PlatformHostAgent,
SourceType: SourceAgent,
LastSeen: now,
Identity: &ResourceIdentity{
Hostname: "server1",
IPs: []string{"127.0.0.1", "192.168.1.1"},
},
}
store.Upsert(host1)
host2 := Resource{
ID: "host-2",
Type: ResourceTypeHost,
Name: "server2",
PlatformType: PlatformHostAgent,
SourceType: SourceAgent,
LastSeen: now,
Identity: &ResourceIdentity{
Hostname: "server2",
IPs: []string{"127.0.0.1", "192.168.1.2"}, // Both have localhost
},
}
store.Upsert(host2)
// Both should exist (127.0.0.1 shouldn't trigger dedup)
all := store.GetAll()
if len(all) != 2 {
t.Errorf("Expected 2 hosts (localhost shouldn't dedup), got %d", len(all))
}
}
func TestStoreStats(t *testing.T) {
store := NewStore()
store.Upsert(Resource{ID: "1", Type: ResourceTypeNode, PlatformType: PlatformProxmoxPVE, LastSeen: time.Now()})
store.Upsert(Resource{ID: "2", Type: ResourceTypeNode, PlatformType: PlatformProxmoxPVE, LastSeen: time.Now()})
store.Upsert(Resource{ID: "3", Type: ResourceTypeVM, PlatformType: PlatformProxmoxPVE, LastSeen: time.Now()})
store.Upsert(Resource{ID: "4", Type: ResourceTypeDockerHost, PlatformType: PlatformDocker, LastSeen: time.Now()})
stats := store.GetStats()
if stats.TotalResources != 4 {
t.Errorf("Expected 4 total resources, got %d", stats.TotalResources)
}
if stats.ByType[ResourceTypeNode] != 2 {
t.Errorf("Expected 2 nodes, got %d", stats.ByType[ResourceTypeNode])
}
if stats.ByPlatform[PlatformProxmoxPVE] != 3 {
t.Errorf("Expected 3 PVE resources, got %d", stats.ByPlatform[PlatformProxmoxPVE])
}
}
func TestStoreQuery(t *testing.T) {
store := NewStore()
store.Upsert(Resource{
ID: "1",
Type: ResourceTypeNode,
PlatformType: PlatformProxmoxPVE,
Status: StatusOnline,
LastSeen: time.Now(),
})
store.Upsert(Resource{
ID: "2",
Type: ResourceTypeVM,
PlatformType: PlatformProxmoxPVE,
Status: StatusRunning,
ParentID: "1",
LastSeen: time.Now(),
})
store.Upsert(Resource{
ID: "3",
Type: ResourceTypeVM,
PlatformType: PlatformProxmoxPVE,
Status: StatusStopped,
ParentID: "1",
LastSeen: time.Now(),
})
store.Upsert(Resource{
ID: "4",
Type: ResourceTypeDockerContainer,
PlatformType: PlatformDocker,
Status: StatusRunning,
LastSeen: time.Now(),
})
// Query by type
vms := store.Query().OfType(ResourceTypeVM).Execute()
if len(vms) != 2 {
t.Errorf("Expected 2 VMs, got %d", len(vms))
}
// Query by status
running := store.Query().WithStatus(StatusRunning).Execute()
if len(running) != 2 {
t.Errorf("Expected 2 running resources, got %d", len(running))
}
// Query by platform
pve := store.Query().FromPlatform(PlatformProxmoxPVE).Execute()
if len(pve) != 3 {
t.Errorf("Expected 3 PVE resources, got %d", len(pve))
}
// Query by parent
node1Children := store.Query().WithParent("1").Execute()
if len(node1Children) != 2 {
t.Errorf("Expected 2 children of node 1, got %d", len(node1Children))
}
// Combined query
runningVMs := store.Query().
OfType(ResourceTypeVM).
WithStatus(StatusRunning).
Execute()
if len(runningVMs) != 1 {
t.Errorf("Expected 1 running VM, got %d", len(runningVMs))
}
// Count
count := store.Query().OfType(ResourceTypeVM).Count()
if count != 2 {
t.Errorf("Expected count 2, got %d", count)
}
// Limit
limited := store.Query().Limit(2).Execute()
if len(limited) > 2 {
t.Errorf("Expected at most 2 results, got %d", len(limited))
}
}
func TestMarkStale(t *testing.T) {
store := NewStore()
old := time.Now().Add(-2 * time.Hour)
recent := time.Now()
store.Upsert(Resource{
ID: "old-1",
Status: StatusOnline,
LastSeen: old,
})
store.Upsert(Resource{
ID: "recent-1",
Status: StatusOnline,
LastSeen: recent,
})
stale := store.MarkStale(time.Hour)
if len(stale) != 1 {
t.Errorf("Expected 1 stale resource, got %d", len(stale))
}
r, _ := store.Get("old-1")
if r.Status != StatusDegraded {
t.Errorf("Expected stale resource to be degraded, got %s", r.Status)
}
r, _ = store.Get("recent-1")
if r.Status != StatusOnline {
t.Errorf("Recent resource should still be online, got %s", r.Status)
}
}
func TestPruneStale(t *testing.T) {
store := NewStore()
veryOld := time.Now().Add(-48 * time.Hour)
old := time.Now().Add(-2 * time.Hour)
recent := time.Now()
store.Upsert(Resource{ID: "very-old", LastSeen: veryOld})
store.Upsert(Resource{ID: "old", LastSeen: old})
store.Upsert(Resource{ID: "recent", LastSeen: recent})
removed := store.PruneStale(time.Hour, 24*time.Hour)
if len(removed) != 1 {
t.Errorf("Expected 1 removed resource, got %d", len(removed))
}
if len(store.GetAll()) != 2 {
t.Errorf("Expected 2 remaining resources, got %d", len(store.GetAll()))
}
}
func TestAPIToAgentPreference(t *testing.T) {
store := NewStore()
now := time.Now()
// First, add an API resource
apiResource := Resource{
ID: "api-node",
Type: ResourceTypeNode,
Name: "server",
PlatformType: PlatformProxmoxPVE,
SourceType: SourceAPI,
CPU: &MetricValue{Current: 50.0},
LastSeen: now,
Identity: &ResourceIdentity{
Hostname: "server",
},
}
store.Upsert(apiResource)
// Then, add an agent resource for the same machine
agentResource := Resource{
ID: "agent-host",
Type: ResourceTypeHost,
Name: "server",
PlatformType: PlatformHostAgent,
SourceType: SourceAgent,
CPU: &MetricValue{Current: 55.0},
LastSeen: now,
Identity: &ResourceIdentity{
Hostname: "server",
},
}
store.Upsert(agentResource)
// Only agent resource should exist
all := store.GetAll()
if len(all) != 1 {
t.Fatalf("Expected 1 resource, got %d", len(all))
}
if all[0].SourceType != SourceAgent {
t.Errorf("Expected agent source type, got %s", all[0].SourceType)
}
}
func TestGetTopByCPU(t *testing.T) {
store := NewStore()
now := time.Now()
store.Upsert(Resource{
ID: "vm1",
Type: ResourceTypeVM,
Name: "low-cpu-vm",
CPU: &MetricValue{Current: 20.0},
LastSeen: now,
})
store.Upsert(Resource{
ID: "vm2",
Type: ResourceTypeVM,
Name: "high-cpu-vm",
CPU: &MetricValue{Current: 85.0},
LastSeen: now,
})
store.Upsert(Resource{
ID: "node1",
Type: ResourceTypeNode,
Name: "busy-node",
CPU: &MetricValue{Current: 75.0},
LastSeen: now,
})
// Get top 2 by CPU
top := store.GetTopByCPU(2, nil)
if len(top) != 2 {
t.Fatalf("Expected 2 resources, got %d", len(top))
}
if top[0].Name != "high-cpu-vm" {
t.Errorf("Expected high-cpu-vm first, got %s", top[0].Name)
}
if top[1].Name != "busy-node" {
t.Errorf("Expected busy-node second, got %s", top[1].Name)
}
// Filter by type
topVMs := store.GetTopByCPU(10, []ResourceType{ResourceTypeVM})
if len(topVMs) != 2 {
t.Errorf("Expected 2 VMs, got %d", len(topVMs))
}
}
func TestGetRelated(t *testing.T) {
store := NewStore()
now := time.Now()
store.Upsert(Resource{
ID: "node1",
Type: ResourceTypeNode,
Name: "parent-node",
ClusterID: "cluster1",
LastSeen: now,
})
store.Upsert(Resource{
ID: "vm1",
Type: ResourceTypeVM,
Name: "child-vm-1",
ParentID: "node1",
ClusterID: "cluster1",
LastSeen: now,
})
store.Upsert(Resource{
ID: "vm2",
Type: ResourceTypeVM,
Name: "child-vm-2",
ParentID: "node1",
ClusterID: "cluster1",
LastSeen: now,
})
store.Upsert(Resource{
ID: "node2",
Type: ResourceTypeNode,
Name: "cluster-peer",
ClusterID: "cluster1",
LastSeen: now,
})
// Get related resources for vm1
related := store.GetRelated("vm1")
// Should have parent
if parent, ok := related["parent"]; !ok || len(parent) != 1 {
t.Error("Expected 1 parent")
}
// Should have sibling (vm2)
if siblings, ok := related["siblings"]; !ok || len(siblings) != 1 {
t.Errorf("Expected 1 sibling, got %d", len(related["siblings"]))
}
// Should have cluster members
if cluster, ok := related["cluster_members"]; !ok || len(cluster) != 3 {
t.Errorf("Expected 3 cluster members, got %d", len(related["cluster_members"]))
}
}
func TestGetResourceSummary(t *testing.T) {
store := NewStore()
now := time.Now()
store.Upsert(Resource{
ID: "node1",
Type: ResourceTypeNode,
PlatformType: PlatformProxmoxPVE,
Status: StatusOnline,
CPU: &MetricValue{Current: 50},
Memory: &MetricValue{Current: 50}, // 50% usage
LastSeen: now,
})
store.Upsert(Resource{
ID: "vm1",
Type: ResourceTypeVM,
PlatformType: PlatformProxmoxPVE,
Status: StatusRunning,
CPU: &MetricValue{Current: 70},
Memory: &MetricValue{Current: 50}, // 50% usage
LastSeen: now,
})
store.Upsert(Resource{
ID: "vm2",
Type: ResourceTypeVM,
PlatformType: PlatformProxmoxPVE,
Status: StatusStopped,
LastSeen: now,
})
summary := store.GetResourceSummary()
if summary.TotalResources != 3 {
t.Errorf("Expected 3 total resources, got %d", summary.TotalResources)
}
if summary.Healthy != 2 {
t.Errorf("Expected 2 healthy, got %d", summary.Healthy)
}
if summary.Offline != 1 {
t.Errorf("Expected 1 offline, got %d", summary.Offline)
}
// Check per-type stats
vmStats := summary.ByType[ResourceTypeVM]
if vmStats.Count != 2 {
t.Errorf("Expected 2 VMs, got %d", vmStats.Count)
}
}