Pulse/internal/api/resources_test.go
2026-04-11 00:24:03 +01:00

3756 lines
123 KiB
Go

package api
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/storagehealth"
"github.com/rcourtman/pulse-go-rewrite/internal/truenas"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
type resourceStateProvider struct {
snapshot models.StateSnapshot
}
func (s resourceStateProvider) ReadSnapshot() models.StateSnapshot {
return s.snapshot
}
type resourceUnifiedSeedProvider struct {
snapshot models.StateSnapshot
resources []unified.Resource
}
func (p resourceUnifiedSeedProvider) ReadSnapshot() models.StateSnapshot {
return p.snapshot
}
func (p resourceUnifiedSeedProvider) UnifiedResourceSnapshot() ([]unified.Resource, time.Time) {
out := make([]unified.Resource, len(p.resources))
copy(out, p.resources)
return out, p.snapshot.LastUpdate
}
type mutableResourceUnifiedSeedProvider struct {
snapshot models.StateSnapshot
resources []unified.Resource
freshness time.Time
}
func (p *mutableResourceUnifiedSeedProvider) ReadSnapshot() models.StateSnapshot {
return p.snapshot
}
func (p *mutableResourceUnifiedSeedProvider) UnifiedResourceSnapshot() ([]unified.Resource, time.Time) {
out := make([]unified.Resource, len(p.resources))
copy(out, p.resources)
return out, p.freshness
}
type mockSupplementalRecordsProvider struct {
records []unified.IngestRecord
ownedSources []unified.DataSource
}
func (m mockSupplementalRecordsProvider) GetCurrentRecords() []unified.IngestRecord {
out := make([]unified.IngestRecord, len(m.records))
copy(out, m.records)
return out
}
func (m mockSupplementalRecordsProvider) SnapshotOwnedSources() []unified.DataSource {
out := make([]unified.DataSource, len(m.ownedSources))
copy(out, m.ownedSources)
return out
}
func TestResourceListRejectsLegacyHostTypeFilter(t *testing.T) {
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{}})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=host", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if body := rec.Body.String(); !strings.Contains(body, `unsupported type filter token(s): host`) {
t.Fatalf("unexpected response body: %s", body)
}
}
func TestResourceListMergesLinkedHost(t *testing.T) {
now := time.Now().UTC()
node := models.Node{
ID: "instance-pve1",
Name: "pve1",
Instance: "instance",
Host: "https://pve1:8006",
Status: "online",
CPU: 0.15,
Memory: models.Memory{Total: 1024, Used: 512, Free: 512, Usage: 0.5},
Disk: models.Disk{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5},
LastSeen: now,
LinkedAgentID: "host-1",
}
host := models.Host{
ID: "host-1",
Hostname: "pve1",
Status: "online",
Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5},
LastSeen: now,
LinkedNodeID: node.ID,
}
snapshot := models.StateSnapshot{
Nodes: []models.Node{node},
Hosts: []models.Host{host},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 resource, got %d", len(resp.Data))
}
resource := resp.Data[0]
if !containsSource(resource.Sources, unified.SourceProxmox) || !containsSource(resource.Sources, unified.SourceAgent) {
t.Fatalf("expected merged sources, got %+v", resource.Sources)
}
if resource.DiscoveryTarget == nil {
t.Fatalf("expected discovery target on merged host")
}
if resource.DiscoveryTarget.ResourceType != "agent" {
t.Fatalf("discovery target resourceType = %q, want agent", resource.DiscoveryTarget.ResourceType)
}
if resource.DiscoveryTarget.AgentID != "host-1" || resource.DiscoveryTarget.ResourceID != "host-1" {
t.Fatalf("discovery target = %+v, want host-1/host-1", resource.DiscoveryTarget)
}
if resource.Canonical == nil {
t.Fatalf("expected canonical identity on merged host")
}
if got := resource.Canonical.DisplayName; got != "pve1" {
t.Fatalf("canonical displayName = %q, want pve1", got)
}
if got := resource.Canonical.PlatformID; got != "pve1" {
t.Fatalf("canonical platformId = %q, want pve1", got)
}
if got := resource.Canonical.PrimaryID; got != "node:instance-pve1" {
t.Fatalf("canonical primaryId = %q, want node:instance-pve1", got)
}
}
func TestResourceListUsesUnifiedSeedProvider(t *testing.T) {
now := time.Now().UTC()
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceUnifiedSeedProvider{
snapshot: models.StateSnapshot{LastUpdate: now},
resources: []unified.Resource{
{
ID: "agent-seeded",
Type: unified.ResourceTypeAgent,
Name: "seeded-agent",
Status: unified.StatusOnline,
LastSeen: now,
UpdatedAt: now,
Sources: []unified.DataSource{unified.SourceAgent},
Capabilities: []unified.ResourceCapability{
{
Name: "restart",
Type: unified.CapabilityTypeCommon,
Description: "Restart the resource",
MinimumApprovalLevel: unified.ApprovalAdmin,
},
},
Relationships: []unified.ResourceRelationship{
{
SourceID: "agent-seeded",
TargetID: "node-1",
Type: unified.RelRunsOn,
Confidence: 1,
Active: true,
Discoverer: "proxmox_adapter",
ObservedAt: now,
LastSeenAt: now,
},
},
RecentChanges: []unified.ResourceChange{
{
ID: "chg-1",
ResourceID: "agent-seeded",
ObservedAt: now,
Kind: unified.ChangeStateTransition,
From: "offline",
To: "online",
SourceType: unified.SourcePlatformEvent,
SourceAdapter: unified.AdapterProxmox,
Confidence: unified.ConfidenceHigh,
},
},
Identity: unified.ResourceIdentity{
Hostnames: []string{"seeded-agent"},
},
},
},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 resource, got %d", len(resp.Data))
}
if got := resp.Data[0].Name; got != "seeded-agent" {
t.Fatalf("resource name = %q, want seeded-agent", got)
}
if resp.Data[0].Policy == nil {
t.Fatal("expected policy metadata on seeded resource")
}
if got := resp.Data[0].Policy.Sensitivity; got != unified.ResourceSensitivityInternal {
t.Fatalf("policy sensitivity = %q, want %q", got, unified.ResourceSensitivityInternal)
}
if strings.TrimSpace(resp.Data[0].AISafeSummary) == "" {
t.Fatal("expected aiSafeSummary on seeded resource")
}
if got := resp.Data[0].FacetCounts; got.RecentChanges != 1 {
t.Fatalf("facetCounts = %+v, want recentChanges=1", got)
}
}
func TestResourceListUsesDeterministicNameTieBreakers(t *testing.T) {
now := time.Date(2026, 4, 11, 0, 0, 0, 0, time.UTC)
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceUnifiedSeedProvider{
snapshot: models.StateSnapshot{LastUpdate: now},
resources: []unified.Resource{
{ID: "storage-c", Type: unified.ResourceTypeStorage, Name: "backup-vault-a", Status: unified.StatusOnline, LastSeen: now},
{ID: "storage-a", Type: unified.ResourceTypeStorage, Name: "backup-vault-a", Status: unified.StatusOnline, LastSeen: now},
{ID: "storage-b", Type: unified.ResourceTypeStorage, Name: "backup-vault-a", Status: unified.StatusOnline, LastSeen: now},
},
})
for attempt := 0; attempt < 2; attempt++ {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=storage&page=1&limit=100", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("attempt %d: status = %d, body=%s", attempt+1, rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("attempt %d: decode response: %v", attempt+1, err)
}
gotIDs := make([]string, 0, len(resp.Data))
for _, resource := range resp.Data {
gotIDs = append(gotIDs, resource.ID)
}
wantIDs := []string{"storage-a", "storage-b", "storage-c"}
if len(gotIDs) != len(wantIDs) {
t.Fatalf("attempt %d: expected %d resources, got %d (%v)", attempt+1, len(wantIDs), len(gotIDs), gotIDs)
}
for index := range wantIDs {
if gotIDs[index] != wantIDs[index] {
t.Fatalf("attempt %d: position %d = %q, want %q (got=%v)", attempt+1, index, gotIDs[index], wantIDs[index], gotIDs)
}
}
}
}
func TestResourceListInvalidatesUnifiedSeedCacheOnFreshnessChange(t *testing.T) {
now := time.Now().UTC()
provider := &mutableResourceUnifiedSeedProvider{
snapshot: models.StateSnapshot{LastUpdate: now},
resources: []unified.Resource{
{
ID: "agent-seeded-1",
Type: unified.ResourceTypeAgent,
Name: "seeded-agent-old",
Status: unified.StatusOnline,
LastSeen: now,
UpdatedAt: now,
Sources: []unified.DataSource{unified.SourceAgent},
Identity: unified.ResourceIdentity{
Hostnames: []string{"seeded-agent-old"},
},
},
},
freshness: now,
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(provider)
firstRec := httptest.NewRecorder()
firstReq := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent", nil)
h.HandleListResources(firstRec, firstReq)
if firstRec.Code != http.StatusOK {
t.Fatalf("first status = %d, body=%s", firstRec.Code, firstRec.Body.String())
}
var firstResp ResourcesResponse
if err := json.NewDecoder(firstRec.Body).Decode(&firstResp); err != nil {
t.Fatalf("decode first response: %v", err)
}
if len(firstResp.Data) != 1 || firstResp.Data[0].Name != "seeded-agent-old" {
t.Fatalf("unexpected first response: %#v", firstResp.Data)
}
provider.resources = []unified.Resource{
{
ID: "agent-seeded-2",
Type: unified.ResourceTypeAgent,
Name: "seeded-agent-new",
Status: unified.StatusOnline,
LastSeen: now.Add(time.Minute),
UpdatedAt: now.Add(time.Minute),
Sources: []unified.DataSource{unified.SourceAgent},
Identity: unified.ResourceIdentity{
Hostnames: []string{"seeded-agent-new"},
},
},
}
provider.freshness = now.Add(time.Minute)
secondRec := httptest.NewRecorder()
secondReq := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent", nil)
h.HandleListResources(secondRec, secondReq)
if secondRec.Code != http.StatusOK {
t.Fatalf("second status = %d, body=%s", secondRec.Code, secondRec.Body.String())
}
var secondResp ResourcesResponse
if err := json.NewDecoder(secondRec.Body).Decode(&secondResp); err != nil {
t.Fatalf("decode second response: %v", err)
}
if len(secondResp.Data) != 1 || secondResp.Data[0].Name != "seeded-agent-new" {
t.Fatalf("expected cache invalidation after freshness change, got %#v", secondResp.Data)
}
}
func TestResourceListMergesOneSidedLinkedHostWhenHostnameCorroborates(t *testing.T) {
now := time.Now().UTC()
node := models.Node{
ID: "instance-pve1",
Name: "pve1",
Instance: "instance",
Host: "https://pve1:8006",
Status: "online",
CPU: 0.15,
Memory: models.Memory{Total: 1024, Used: 512, Free: 512, Usage: 0.5},
Disk: models.Disk{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5},
LastSeen: now,
LinkedAgentID: "host-1",
}
host := models.Host{
ID: "host-1",
Hostname: "pve1",
Status: "online",
Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5},
LastSeen: now,
// Intentionally not setting LinkedNodeID to ensure one-sided links are ignored.
}
snapshot := models.StateSnapshot{
Nodes: []models.Node{node},
Hosts: []models.Host{host},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 merged resource when one-sided link is corroborated, got %d", len(resp.Data))
}
resource := resp.Data[0]
if !containsSource(resource.Sources, unified.SourceAgent) || !containsSource(resource.Sources, unified.SourceProxmox) {
t.Fatalf("expected merged agent+proxmox sources, got %+v", resource.Sources)
}
if resource.DiscoveryTarget == nil {
t.Fatalf("expected discovery target for merged host")
}
if resource.DiscoveryTarget.ResourceType != "agent" {
t.Fatalf("discovery target type = %q, want agent", resource.DiscoveryTarget.ResourceType)
}
if resource.DiscoveryTarget.AgentID != "host-1" || resource.DiscoveryTarget.ResourceID != "host-1" {
t.Fatalf("discovery target = %+v, want host-1/host-1", resource.DiscoveryTarget)
}
}
func TestResourceListDoesNotMergeOneSidedLinkedHostWithoutHostnameCorroboration(t *testing.T) {
now := time.Now().UTC()
node := models.Node{
ID: "instance-pve1",
Name: "pve1",
Instance: "instance",
Host: "https://pve1:8006",
Status: "online",
CPU: 0.15,
Memory: models.Memory{Total: 1024, Used: 512, Free: 512, Usage: 0.5},
Disk: models.Disk{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5},
LastSeen: now,
LinkedAgentID: "host-1",
}
host := models.Host{
ID: "host-1",
Hostname: "minipc",
Status: "online",
Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5},
LastSeen: now,
}
snapshot := models.StateSnapshot{
Nodes: []models.Node{node},
Hosts: []models.Host{host},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 2 {
t.Fatalf("expected 2 resources without corroborating hostname, got %d", len(resp.Data))
}
}
func TestResourceListCollapsesClusterAndStandaloneNodeViewsByEndpoint(t *testing.T) {
state := models.NewState()
now := time.Now().UTC()
state.Hosts = []models.Host{
{
ID: "host-1",
Hostname: "minipc.local",
Status: "online",
ReportIP: "10.0.0.5",
Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5},
LastSeen: now,
NetworkInterfaces: []models.HostNetworkInterface{
{Name: "eth0", Addresses: []string{"10.0.0.5/24"}},
},
},
}
state.UpdateNodesForInstance("homelab-entry", []models.Node{
{
ID: "homelab-minipc",
Name: "minipc",
Instance: "homelab-entry",
ClusterName: "homelab",
IsClusterMember: true,
Host: "https://10.0.0.5:8006",
Status: "online",
LastSeen: now,
},
})
state.UpdateNodesForInstance("minipc-standalone", []models.Node{
{
ID: "standalone-minipc",
Name: "minipc",
Instance: "minipc-standalone",
Host: "https://10.0.0.5:8006",
Status: "online",
LastSeen: now,
},
})
snapshot := state.GetSnapshot()
if len(snapshot.Nodes) != 1 {
t.Fatalf("state snapshot nodes = %#v, want exactly 1 node", snapshot.Nodes)
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent&q=minipc", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 minipc resource, got %d", len(resp.Data))
}
resource := resp.Data[0]
if !containsSource(resource.Sources, unified.SourceAgent) || !containsSource(resource.Sources, unified.SourceProxmox) {
t.Fatalf("expected merged agent+proxmox sources, got %+v", resource.Sources)
}
if resource.Proxmox == nil || resource.Proxmox.ClusterName != "homelab" {
t.Fatalf("expected proxmox cluster homelab, got %+v", resource.Proxmox)
}
}
func TestResourceListCollapsesAsymmetricLinkedClusterNodeViews(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
Nodes: []models.Node{
{
ID: "homelab-minipc",
Name: "minipc",
Instance: "homelab-entry",
ClusterName: "homelab",
IsClusterMember: true,
Host: "https://10.0.0.5:8006",
LinkedAgentID: "host-1",
Status: "online",
LastSeen: now,
},
{
ID: "homelab-minipc-shadow",
Name: "minipc",
Instance: "homelab-shadow",
ClusterName: "homelab",
IsClusterMember: true,
Host: "https://10.0.0.5:8006",
Status: "online",
LastSeen: now.Add(-time.Minute),
},
},
Hosts: []models.Host{
{
ID: "host-1",
Hostname: "minipc.local",
Status: "online",
ReportIP: "10.0.0.5",
MachineID: "machine-1",
LinkedNodeID: "homelab-minipc",
Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5},
LastSeen: now,
NetworkInterfaces: []models.HostNetworkInterface{
{Name: "eth0", Addresses: []string{"10.0.0.5/24"}},
},
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent&q=minipc", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 minipc resource, got %d", len(resp.Data))
}
resource := resp.Data[0]
if !containsSource(resource.Sources, unified.SourceAgent) || !containsSource(resource.Sources, unified.SourceProxmox) {
t.Fatalf("expected merged agent+proxmox sources, got %+v", resource.Sources)
}
if resource.Proxmox == nil || resource.Proxmox.ClusterName != "homelab" {
t.Fatalf("expected proxmox cluster homelab, got %+v", resource.Proxmox)
}
if resource.DiscoveryTarget == nil || resource.DiscoveryTarget.AgentID != "host-1" {
t.Fatalf("expected merged discovery target for host-1, got %+v", resource.DiscoveryTarget)
}
}
func TestResourceListCollapsesHostLinkedClusterNodeViews(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
Nodes: []models.Node{
{
ID: "homelab-delly",
Name: "delly",
Instance: "homelab-entry",
ClusterName: "homelab",
IsClusterMember: true,
Host: "https://10.0.0.9:8006",
Status: "online",
LastSeen: now,
},
{
ID: "homelab-delly-shadow",
Name: "delly",
Instance: "homelab-shadow",
ClusterName: "homelab",
IsClusterMember: true,
Host: "https://10.0.0.9:8006",
Status: "online",
LastSeen: now.Add(-time.Minute),
},
},
Hosts: []models.Host{
{
ID: "host-1",
Hostname: "delly.local",
Status: "online",
ReportIP: "10.0.0.9",
MachineID: "machine-delly",
LinkedNodeID: "homelab-delly",
Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5},
LastSeen: now,
NetworkInterfaces: []models.HostNetworkInterface{
{Name: "eth0", Addresses: []string{"10.0.0.9/24"}},
},
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent&q=delly", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 delly resource, got %d", len(resp.Data))
}
resource := resp.Data[0]
if !containsSource(resource.Sources, unified.SourceAgent) || !containsSource(resource.Sources, unified.SourceProxmox) {
t.Fatalf("expected merged agent+proxmox sources, got %+v", resource.Sources)
}
if resource.Proxmox == nil || resource.Proxmox.ClusterName != "homelab" {
t.Fatalf("expected proxmox cluster homelab, got %+v", resource.Proxmox)
}
if resource.DiscoveryTarget == nil || resource.DiscoveryTarget.AgentID != "host-1" {
t.Fatalf("expected merged discovery target for host-1, got %+v", resource.DiscoveryTarget)
}
}
func TestResourceListCollapsesHostLinkedNodeViewsAcrossEndpointForms(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
Nodes: []models.Node{
{
ID: "minipc-ip-view",
Name: "minipc",
Instance: "standalone-ip",
Host: "https://10.0.0.5:8006",
Status: "online",
LastSeen: now,
},
{
ID: "minipc-hostname-view",
Name: "minipc",
Instance: "standalone-hostname",
Host: "https://minipc.local:8006",
Status: "online",
LastSeen: now.Add(-time.Minute),
},
},
Hosts: []models.Host{
{
ID: "host-1",
Hostname: "minipc.local",
Status: "online",
ReportIP: "10.0.0.5",
MachineID: "machine-minipc",
LinkedNodeID: "minipc-ip-view",
Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5},
LastSeen: now,
NetworkInterfaces: []models.HostNetworkInterface{
{Name: "eth0", Addresses: []string{"10.0.0.5/24"}},
},
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent&q=minipc", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 minipc resource, got %d", len(resp.Data))
}
resource := resp.Data[0]
if !containsSource(resource.Sources, unified.SourceAgent) || !containsSource(resource.Sources, unified.SourceProxmox) {
t.Fatalf("expected merged agent+proxmox sources, got %+v", resource.Sources)
}
if resource.DiscoveryTarget == nil || resource.DiscoveryTarget.AgentID != "host-1" {
t.Fatalf("expected merged discovery target for host-1, got %+v", resource.DiscoveryTarget)
}
}
func TestResourceListIncludesHostSMARTPhysicalDisks(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
Hosts: []models.Host{
{
ID: "host-tower",
Hostname: "tower",
Status: "online",
LastSeen: now,
Disks: []models.Disk{
{Device: "/dev/sdb", Total: 12 * 1024, Mountpoint: "/mnt/disk1"},
},
Unraid: &models.HostUnraidStorage{
ArrayStarted: true,
Disks: []models.HostUnraidDisk{
{Name: "parity", Device: "/dev/sdb", Role: "parity", Status: "online", Serial: "SERIAL-TOWER-1"},
},
},
Sensors: models.HostSensorSummary{
SMART: []models.HostDiskSMART{
{
Device: "/dev/sdb",
Model: "Seagate IronWolf",
Serial: "SERIAL-TOWER-1",
Type: "sata",
Temperature: 37,
Health: "PASSED",
},
},
},
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=physical_disk", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 tower physical disk resource, got %d", len(resp.Data))
}
resource := resp.Data[0]
if !containsSource(resource.Sources, unified.SourceAgent) {
t.Fatalf("expected agent-backed physical disk source, got %+v", resource.Sources)
}
if resource.PhysicalDisk == nil || resource.PhysicalDisk.Serial != "SERIAL-TOWER-1" {
t.Fatalf("expected SMART-backed physical disk metadata, got %+v", resource.PhysicalDisk)
}
if resource.PhysicalDisk.StorageRole != "parity" {
t.Fatalf("storageRole = %q, want parity", resource.PhysicalDisk.StorageRole)
}
if resource.MetricsTarget == nil || resource.MetricsTarget.ResourceType != "disk" || resource.MetricsTarget.ResourceID != "SERIAL-TOWER-1" {
t.Fatalf("expected disk metrics target SERIAL-TOWER-1, got %+v", resource.MetricsTarget)
}
}
func TestResourceListUsesCanonicalMetricIDForProxmoxPhysicalDisks(t *testing.T) {
snapshot := models.StateSnapshot{
PhysicalDisks: []models.PhysicalDisk{
{
ID: "pve1-node1-/dev-sda",
Instance: "pve1",
Node: "node1",
DevPath: "/dev/sda",
Model: "Exos",
Serial: "SERIAL-PVE-1",
Temperature: 34,
LastChecked: time.Now().UTC(),
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=physical_disk", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 proxmox physical disk resource, got %d", len(resp.Data))
}
resource := resp.Data[0]
if resource.MetricsTarget == nil || resource.MetricsTarget.ResourceType != "disk" || resource.MetricsTarget.ResourceID != "SERIAL-PVE-1" {
t.Fatalf("expected canonical disk metrics target SERIAL-PVE-1, got %+v", resource.MetricsTarget)
}
}
func TestResourceGetResource(t *testing.T) {
now := time.Now().UTC()
host := models.Host{
ID: "host-1",
Hostname: "pve1",
Status: "online",
Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5},
LastSeen: now,
}
snapshot := models.StateSnapshot{Hosts: []models.Host{host}}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
listRec := httptest.NewRecorder()
listReq := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent,docker-host", nil)
h.HandleListResources(listRec, listReq)
var listResp ResourcesResponse
if err := json.NewDecoder(listRec.Body).Decode(&listResp); err != nil {
t.Fatalf("decode list response: %v", err)
}
if len(listResp.Data) != 1 {
t.Fatalf("expected 1 resource, got %d", len(listResp.Data))
}
resourceID := listResp.Data[0].ID
getRec := httptest.NewRecorder()
getReq := httptest.NewRequest(http.MethodGet, "/api/resources/"+resourceID, nil)
h.HandleGetResource(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", getRec.Code, getRec.Body.String())
}
var resource unified.Resource
if err := json.NewDecoder(getRec.Body).Decode(&resource); err != nil {
t.Fatalf("decode resource: %v", err)
}
if resource.ID != resourceID {
t.Fatalf("resource id = %q, want %q", resource.ID, resourceID)
}
if resource.DiscoveryTarget == nil {
t.Fatalf("expected discovery target on get resource")
}
if resource.DiscoveryTarget.ResourceType != "agent" {
t.Fatalf("discovery target resourceType = %q, want agent", resource.DiscoveryTarget.ResourceType)
}
if resource.DiscoveryTarget.AgentID != "host-1" || resource.DiscoveryTarget.ResourceID != "host-1" {
t.Fatalf("discovery target = %+v, want host-1/host-1", resource.DiscoveryTarget)
}
}
func TestResourceGetFacetsAndTimeline(t *testing.T) {
now := time.Date(2026, 3, 18, 17, 0, 0, 0, time.UTC)
resource := unified.Resource{
ID: "vm:42",
Type: unified.ResourceTypeVM,
Name: "web-42",
Status: unified.StatusOnline,
LastSeen: now,
Capabilities: []unified.ResourceCapability{
{
Name: "restart",
Type: unified.CapabilityTypeCommon,
Description: "Restart the VM",
MinimumApprovalLevel: unified.ApprovalAdmin,
},
},
Relationships: []unified.ResourceRelationship{
{
SourceID: "vm:42",
TargetID: "node-1",
Type: unified.RelRunsOn,
Confidence: 1,
Active: true,
Discoverer: "proxmox_adapter",
ObservedAt: now,
LastSeenAt: now,
Metadata: map[string]any{
"source": "live",
"cluster": "pve-prod",
},
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceUnifiedSeedProvider{
snapshot: models.StateSnapshot{LastUpdate: now},
resources: []unified.Resource{resource},
})
store, err := h.getStore("default")
if err != nil {
t.Fatalf("getStore: %v", err)
}
if err := store.RecordChange(unified.ResourceChange{
ID: "chg-42",
ResourceID: "vm:42",
ObservedAt: now,
OccurredAt: &now,
Kind: unified.ChangeRestart,
From: "offline",
To: "online",
SourceType: unified.SourcePlatformEvent,
SourceAdapter: unified.AdapterProxmox,
Confidence: unified.ConfidenceHigh,
Reason: "vm started",
RelatedResources: []string{"node-1"},
Metadata: map[string]any{"source": "snapshot", "ticket": "INC-1234"},
}); err != nil {
t.Fatalf("RecordChange: %v", err)
}
for i, offset := range []time.Duration{2 * time.Minute, 4 * time.Minute} {
sourceAdapter := unified.AdapterProxmox
if i == 1 {
sourceAdapter = unified.AdapterDocker
}
if err := store.RecordChange(unified.ResourceChange{
ID: fmt.Sprintf("chg-42-extra-%d", i+1),
ResourceID: "vm:42",
ObservedAt: now.Add(-offset),
Kind: unified.ChangeAnomaly,
SourceType: unified.SourcePulseDiff,
SourceAdapter: sourceAdapter,
Confidence: unified.ConfidenceMedium,
Reason: "history backfill",
RelatedResources: []string{"node-1"},
}); err != nil {
t.Fatalf("RecordChange extra %d: %v", i+1, err)
}
}
t.Run("facets", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/facets?limit=1", nil)
h.HandleResourceRoutes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var payload struct {
ResourceID string `json:"resourceId"`
RecentChanges []unified.ResourceChange `json:"recentChanges"`
Counts struct {
RecentChanges int `json:"recentChanges"`
RecentChangeKinds map[unified.ChangeKind]int `json:"recentChangeKinds"`
RecentChangeSourceTypes map[unified.ChangeSourceType]int `json:"recentChangeSourceTypes"`
RecentChangeSourceAdapters map[unified.ChangeSourceAdapter]int `json:"recentChangeSourceAdapters"`
} `json:"counts"`
}
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode facets: %v", err)
}
if payload.ResourceID != "vm:42" || payload.Counts.RecentChanges != 3 || len(payload.RecentChanges) != 1 {
t.Fatalf("unexpected facets payload: %#v", payload)
}
if got := payload.Counts.RecentChangeKinds; len(got) != 2 || got[unified.ChangeRestart] != 1 || got[unified.ChangeAnomaly] != 2 {
t.Fatalf("unexpected recent change kind counts: %#v", got)
}
if got := payload.Counts.RecentChangeSourceTypes; len(got) != 2 || got[unified.SourcePlatformEvent] != 1 || got[unified.SourcePulseDiff] != 2 {
t.Fatalf("unexpected recent change source type counts: %#v", got)
}
if got := payload.Counts.RecentChangeSourceAdapters; len(got) != 2 || got[unified.AdapterProxmox] != 2 || got[unified.AdapterDocker] != 1 {
t.Fatalf("unexpected recent change source adapter counts: %#v", got)
}
if got := payload.RecentChanges[0].Metadata["ticket"]; got != "INC-1234" {
t.Fatalf("unexpected change metadata: %#v", payload.RecentChanges[0].Metadata)
}
})
t.Run("timeline", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/timeline?limit=10", nil)
h.HandleResourceRoutes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var payload struct {
ResourceID string `json:"resourceId"`
RecentChanges []unified.ResourceChange `json:"recentChanges"`
Count int `json:"count"`
}
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode timeline: %v", err)
}
if payload.ResourceID != "vm:42" || payload.Count != 3 || len(payload.RecentChanges) != 3 {
t.Fatalf("unexpected timeline payload: %#v", payload)
}
if payload.RecentChanges[0].ID != "chg-42" {
t.Fatalf("unexpected timeline change: %#v", payload.RecentChanges[0])
}
if got := payload.RecentChanges[0].Metadata["ticket"]; got != "INC-1234" {
t.Fatalf("unexpected timeline metadata: %#v", payload.RecentChanges[0].Metadata)
}
if got := payload.RecentChanges[0].RelatedResources; len(got) != 1 || got[0] != "node-1" {
t.Fatalf("unexpected timeline related resources: %#v", got)
}
})
t.Run("filtered timeline", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/timeline?kind=restart&sourceType=platform_event", nil)
h.HandleResourceRoutes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var payload struct {
ResourceID string `json:"resourceId"`
RecentChanges []unified.ResourceChange `json:"recentChanges"`
Count int `json:"count"`
}
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode filtered timeline: %v", err)
}
if payload.ResourceID != "vm:42" || payload.Count != 1 || len(payload.RecentChanges) != 1 {
t.Fatalf("unexpected filtered timeline payload: %#v", payload)
}
if payload.RecentChanges[0].ID != "chg-42" {
t.Fatalf("unexpected filtered timeline change: %#v", payload.RecentChanges[0])
}
})
t.Run("filtered timeline by source adapter", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/timeline?sourceAdapter=docker_adapter", nil)
h.HandleResourceRoutes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var payload struct {
ResourceID string `json:"resourceId"`
RecentChanges []unified.ResourceChange `json:"recentChanges"`
Count int `json:"count"`
}
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode filtered timeline adapter: %v", err)
}
if payload.ResourceID != "vm:42" || payload.Count != 1 || len(payload.RecentChanges) != 1 {
t.Fatalf("unexpected filtered timeline adapter payload: %#v", payload)
}
if payload.RecentChanges[0].ID != "chg-42-extra-2" {
t.Fatalf("unexpected adapter-filtered change: %#v", payload.RecentChanges[0])
}
if got := payload.RecentChanges[0].RelatedResources; len(got) != 1 || got[0] != "node-1" {
t.Fatalf("unexpected adapter-filtered related resources: %#v", got)
}
})
t.Run("filtered facets", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/facets?kind=restart&sourceType=platform_event", nil)
h.HandleResourceRoutes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var payload struct {
ResourceID string `json:"resourceId"`
RecentChanges []unified.ResourceChange `json:"recentChanges"`
Counts struct {
RecentChanges int `json:"recentChanges"`
RecentChangeKinds map[unified.ChangeKind]int `json:"recentChangeKinds"`
RecentChangeSourceTypes map[unified.ChangeSourceType]int `json:"recentChangeSourceTypes"`
RecentChangeSourceAdapters map[unified.ChangeSourceAdapter]int `json:"recentChangeSourceAdapters"`
} `json:"counts"`
}
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode filtered facets: %v", err)
}
if payload.ResourceID != "vm:42" || payload.Counts.RecentChanges != 1 || len(payload.RecentChanges) != 1 {
t.Fatalf("unexpected filtered facets payload: %#v", payload)
}
if got := payload.Counts.RecentChangeKinds; len(got) != 1 || got[unified.ChangeRestart] != 1 {
t.Fatalf("unexpected filtered facet kind counts: %#v", got)
}
if got := payload.Counts.RecentChangeSourceTypes; len(got) != 1 || got[unified.SourcePlatformEvent] != 1 {
t.Fatalf("unexpected filtered facet source type counts: %#v", got)
}
if got := payload.Counts.RecentChangeSourceAdapters; len(got) != 1 || got[unified.AdapterProxmox] != 1 {
t.Fatalf("unexpected filtered facet source adapter counts: %#v", got)
}
if payload.RecentChanges[0].ID != "chg-42" {
t.Fatalf("unexpected filtered facets change: %#v", payload.RecentChanges[0])
}
})
t.Run("filtered facets by source adapter", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/facets?sourceAdapter=docker_adapter", nil)
h.HandleResourceRoutes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var payload struct {
ResourceID string `json:"resourceId"`
RecentChanges []unified.ResourceChange `json:"recentChanges"`
Counts struct {
RecentChanges int `json:"recentChanges"`
RecentChangeKinds map[unified.ChangeKind]int `json:"recentChangeKinds"`
RecentChangeSourceTypes map[unified.ChangeSourceType]int `json:"recentChangeSourceTypes"`
RecentChangeSourceAdapters map[unified.ChangeSourceAdapter]int `json:"recentChangeSourceAdapters"`
} `json:"counts"`
}
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode filtered facets adapter: %v", err)
}
if payload.ResourceID != "vm:42" || payload.Counts.RecentChanges != 1 || len(payload.RecentChanges) != 1 {
t.Fatalf("unexpected filtered facets adapter payload: %#v", payload)
}
if got := payload.Counts.RecentChangeKinds; len(got) != 1 || got[unified.ChangeAnomaly] != 1 {
t.Fatalf("unexpected adapter-filtered facet kind counts: %#v", got)
}
if got := payload.Counts.RecentChangeSourceTypes; len(got) != 1 || got[unified.SourcePulseDiff] != 1 {
t.Fatalf("unexpected adapter-filtered facet source type counts: %#v", got)
}
if got := payload.Counts.RecentChangeSourceAdapters; len(got) != 1 || got[unified.AdapterDocker] != 1 {
t.Fatalf("unexpected adapter-filtered facet source adapter counts: %#v", got)
}
if payload.RecentChanges[0].ID != "chg-42-extra-2" {
t.Fatalf("unexpected adapter-filtered facets change: %#v", payload.RecentChanges[0])
}
})
t.Run("invalid source adapter filter", func(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:42/timeline?sourceAdapter=unknown_adapter", nil)
h.HandleResourceRoutes(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
})
}
func containsSource(sources []unified.DataSource, target unified.DataSource) bool {
for _, source := range sources {
if source == target {
return true
}
}
return false
}
func TestResourceLinkMergesResources(t *testing.T) {
now := time.Now().UTC()
host := models.Host{
ID: "host-1",
Hostname: "alpha",
Status: "online",
Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5},
LastSeen: now,
}
dockerHost := models.DockerHost{
ID: "docker-1",
Hostname: "beta",
Status: "online",
CPUs: 4,
Memory: models.Memory{Total: 4096, Used: 1024, Free: 3072, Usage: 0.25},
LastSeen: now,
}
snapshot := models.StateSnapshot{Hosts: []models.Host{host}, DockerHosts: []models.DockerHost{dockerHost}}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
listRec := httptest.NewRecorder()
listReq := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent,docker-host", nil)
h.HandleListResources(listRec, listReq)
var listResp ResourcesResponse
if err := json.NewDecoder(listRec.Body).Decode(&listResp); err != nil {
t.Fatalf("decode list response: %v", err)
}
if len(listResp.Data) != 2 {
t.Fatalf("expected 2 resources before link, got %d", len(listResp.Data))
}
primaryID := listResp.Data[0].ID
secondaryID := listResp.Data[1].ID
linkPayload := map[string]string{"targetId": secondaryID, "reason": "manual merge"}
payloadBytes, _ := json.Marshal(linkPayload)
linkRec := httptest.NewRecorder()
linkReq := httptest.NewRequest(http.MethodPost, "/api/resources/"+primaryID+"/link", bytes.NewReader(payloadBytes))
h.HandleLink(linkRec, linkReq)
if linkRec.Code != http.StatusOK {
t.Fatalf("link status = %d, body=%s", linkRec.Code, linkRec.Body.String())
}
listRec2 := httptest.NewRecorder()
listReq2 := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent,docker-host", nil)
h.HandleListResources(listRec2, listReq2)
var listResp2 ResourcesResponse
if err := json.NewDecoder(listRec2.Body).Decode(&listResp2); err != nil {
t.Fatalf("decode list response: %v", err)
}
if len(listResp2.Data) != 1 {
t.Fatalf("expected 1 resource after link, got %d", len(listResp2.Data))
}
resource := listResp2.Data[0]
if !containsSource(resource.Sources, unified.SourceAgent) || !containsSource(resource.Sources, unified.SourceDocker) {
t.Fatalf("expected merged sources, got %+v", resource.Sources)
}
}
func TestResourceReportMergeCreatesExclusions(t *testing.T) {
now := time.Now().UTC()
sharedInterfaces := []models.HostNetworkInterface{
{
Name: "eth0",
MAC: "aa:bb:cc:dd:ee:ff",
Addresses: []string{"10.0.0.5"},
},
}
host := models.Host{
ID: "host-1",
Hostname: "alpha",
Status: "online",
Memory: models.Memory{Total: 2048, Used: 1024, Free: 1024, Usage: 0.5},
LastSeen: now,
NetworkInterfaces: sharedInterfaces,
}
dockerHost := models.DockerHost{
ID: "docker-1",
Hostname: "alpha",
Status: "online",
CPUs: 4,
Memory: models.Memory{Total: 4096, Used: 2048, Free: 2048, Usage: 0.5},
LastSeen: now,
NetworkInterfaces: sharedInterfaces,
}
snapshot := models.StateSnapshot{Hosts: []models.Host{host}, DockerHosts: []models.DockerHost{dockerHost}}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
listRec := httptest.NewRecorder()
listReq := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent,docker-host", nil)
h.HandleListResources(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("list status = %d, body=%s", listRec.Code, listRec.Body.String())
}
var listResp ResourcesResponse
if err := json.NewDecoder(listRec.Body).Decode(&listResp); err != nil {
t.Fatalf("decode list response: %v", err)
}
if len(listResp.Data) != 1 {
t.Fatalf("expected 1 merged resource, got %d", len(listResp.Data))
}
resourceID := listResp.Data[0].ID
reportPayload := map[string]any{
"sources": []string{"agent", "docker"},
"notes": "incorrect merge",
}
reportBytes, _ := json.Marshal(reportPayload)
reportRec := httptest.NewRecorder()
reportReq := httptest.NewRequest(http.MethodPost, "/api/resources/"+resourceID+"/report-merge", bytes.NewReader(reportBytes))
h.HandleReportMerge(reportRec, reportReq)
if reportRec.Code != http.StatusOK {
t.Fatalf("report-merge status = %d, body=%s", reportRec.Code, reportRec.Body.String())
}
listRec2 := httptest.NewRecorder()
listReq2 := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent,docker-host", nil)
h.HandleListResources(listRec2, listReq2)
if listRec2.Code != http.StatusOK {
t.Fatalf("list status = %d, body=%s", listRec2.Code, listRec2.Body.String())
}
var listResp2 ResourcesResponse
if err := json.NewDecoder(listRec2.Body).Decode(&listResp2); err != nil {
t.Fatalf("decode list response: %v", err)
}
if len(listResp2.Data) != 2 {
t.Fatalf("expected 2 resources after report-merge, got %d", len(listResp2.Data))
}
}
func TestResourceListIncludesKubernetesPods(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
KubernetesClusters: []models.KubernetesCluster{
{
ID: "cluster-1",
AgentID: "agent-1",
Name: "prod-k8s",
Context: "prod",
Status: "online",
LastSeen: now,
Version: "1.31.2",
Hidden: false,
Pods: []models.KubernetesPod{
{
UID: "pod-1",
Name: "api-7f8d",
Namespace: "default",
NodeName: "worker-1",
Phase: "Running",
Containers: []models.KubernetesPodContainer{
{Name: "api", Image: "ghcr.io/acme/api:1.2.3", Ready: true, State: "Running"},
},
},
},
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=pod", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 kubernetes pod resource, got %d", len(resp.Data))
}
resource := resp.Data[0]
if resource.Type != unified.ResourceTypePod {
t.Fatalf("resource type = %q, want %q", resource.Type, unified.ResourceTypePod)
}
if !containsSource(resource.Sources, unified.SourceK8s) {
t.Fatalf("expected kubernetes source, got %+v", resource.Sources)
}
if resource.Kubernetes == nil || resource.Kubernetes.Namespace != "default" {
t.Fatalf("expected kubernetes namespace metadata, got %+v", resource.Kubernetes)
}
if resource.DiscoveryTarget == nil {
t.Fatalf("expected discovery target for kubernetes pod")
}
if resource.DiscoveryTarget.ResourceType != string(unified.ResourceTypePod) {
t.Fatalf("discovery target type = %q, want %q", resource.DiscoveryTarget.ResourceType, unified.ResourceTypePod)
}
if resource.DiscoveryTarget.AgentID != "agent-1" {
t.Fatalf("discovery target agentID = %q, want agent-1", resource.DiscoveryTarget.AgentID)
}
if resource.DiscoveryTarget.ResourceID != "pod-1" {
t.Fatalf("discovery target resourceID = %q, want pod-1", resource.DiscoveryTarget.ResourceID)
}
if resource.MetricsTarget == nil {
t.Fatalf("expected metrics target for kubernetes pod")
}
if resource.MetricsTarget.ResourceType != string(unified.ResourceTypePod) {
t.Fatalf("metrics target type = %q, want %q", resource.MetricsTarget.ResourceType, unified.ResourceTypePod)
}
if resource.MetricsTarget.ResourceID != "k8s:cluster-1:pod:pod-1" {
t.Fatalf("metrics target resourceID = %q, want %q", resource.MetricsTarget.ResourceID, "k8s:cluster-1:pod:pod-1")
}
}
func TestResourceListFiltersCanonicalKubernetesNamespace(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
KubernetesClusters: []models.KubernetesCluster{
{
ID: "cluster-1",
AgentID: "agent-1",
Name: "prod-k8s",
Context: "prod",
Status: "online",
LastSeen: now,
Version: "1.31.2",
Hidden: false,
Pods: []models.KubernetesPod{
{UID: "pod-1", Name: "api-1", Namespace: "default", Phase: "Running"},
{UID: "pod-2", Name: "api-2", Namespace: "kube-system", Phase: "Running"},
},
Deployments: []models.KubernetesDeployment{
{UID: "dep-1", Name: "web", Namespace: "default", DesiredReplicas: 3, ReadyReplicas: 3},
{UID: "dep-2", Name: "dns", Namespace: "kube-system", DesiredReplicas: 2, ReadyReplicas: 2},
},
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=pod,k8s-deployment&namespace=default", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 2 {
t.Fatalf("expected 2 kubernetes resources for namespace=default, got %d", len(resp.Data))
}
for _, resource := range resp.Data {
if resource.Kubernetes == nil {
t.Fatalf("expected kubernetes payload, got nil: %+v", resource)
}
if resource.Kubernetes.Namespace != "default" {
t.Fatalf("expected namespace default, got %q (resource=%+v)", resource.Kubernetes.Namespace, resource)
}
}
}
func TestBuildDiscoveryTargetKubernetesPrefersAgentID(t *testing.T) {
tests := []struct {
name string
resource unified.Resource
wantType unified.ResourceType
wantResourceID string
}{
{
name: "pod",
resource: unified.Resource{
ID: "resource:pod:1",
Type: unified.ResourceTypePod,
Name: "api-1",
Kubernetes: &unified.K8sData{
AgentID: "agent-k8s-1",
ClusterID: "cluster-a",
Namespace: "default",
PodUID: "pod-uid-1",
},
},
wantType: unified.ResourceTypePod,
wantResourceID: "pod-uid-1",
},
{
name: "cluster",
resource: unified.Resource{
ID: "resource:k8s-cluster:1",
Type: unified.ResourceTypeK8sCluster,
Name: "cluster-a",
Kubernetes: &unified.K8sData{
AgentID: "agent-k8s-1",
ClusterID: "cluster-a",
},
},
wantType: unified.ResourceTypeK8sCluster,
wantResourceID: "cluster-a",
},
{
name: "deployment",
resource: unified.Resource{
ID: "resource:k8s-deployment:1",
Type: unified.ResourceTypeK8sDeployment,
Name: "web",
Kubernetes: &unified.K8sData{
AgentID: "agent-k8s-1",
ClusterID: "cluster-a",
Namespace: "default",
},
},
wantType: unified.ResourceTypeK8sDeployment,
wantResourceID: "default/web",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
target := buildDiscoveryTarget(tt.resource)
if target == nil {
t.Fatalf("expected discovery target")
}
if target.ResourceType != string(tt.wantType) {
t.Fatalf("resource type = %q, want %q", target.ResourceType, tt.wantType)
}
if target.AgentID != "agent-k8s-1" {
t.Fatalf("agentID = %q, want agent-k8s-1", target.AgentID)
}
if target.ResourceID != tt.wantResourceID {
t.Fatalf("resourceID = %q, want %q", target.ResourceID, tt.wantResourceID)
}
})
}
}
func TestK8sNamespacesEndpointAggregatesPodsAndDeployments(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
KubernetesClusters: []models.KubernetesCluster{
{
ID: "cluster-1",
AgentID: "agent-1",
Name: "prod-k8s",
Context: "prod",
Status: "online",
LastSeen: now,
Version: "1.31.2",
Hidden: false,
Pods: []models.KubernetesPod{
{UID: "pod-1", Name: "api-1", Namespace: "default", Phase: "Running"},
{UID: "pod-2", Name: "api-2", Namespace: "default", Phase: "Pending"},
{UID: "pod-3", Name: "dns-1", Namespace: "kube-system", Phase: "Running"},
},
Deployments: []models.KubernetesDeployment{
{UID: "dep-1", Name: "web", Namespace: "default", DesiredReplicas: 3, ReadyReplicas: 3, AvailableReplicas: 3},
{UID: "dep-2", Name: "dns", Namespace: "kube-system", DesiredReplicas: 2, ReadyReplicas: 1, AvailableReplicas: 1},
},
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources/k8s/namespaces?cluster=prod-k8s", nil)
h.HandleK8sNamespaces(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Cluster string `json:"cluster"`
Data []struct {
Namespace string `json:"namespace"`
Pods struct {
Total int `json:"total"`
Online int `json:"online"`
Warning int `json:"warning"`
Offline int `json:"offline"`
Unknown int `json:"unknown"`
} `json:"pods"`
Deployments struct {
Total int `json:"total"`
Online int `json:"online"`
Warning int `json:"warning"`
Offline int `json:"offline"`
Unknown int `json:"unknown"`
} `json:"deployments"`
} `json:"data"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Cluster != "prod-k8s" {
t.Fatalf("cluster = %q, want prod-k8s", resp.Cluster)
}
byNS := make(map[string]struct {
Namespace string `json:"namespace"`
Pods struct {
Total int `json:"total"`
Online int `json:"online"`
Warning int `json:"warning"`
Offline int `json:"offline"`
Unknown int `json:"unknown"`
} `json:"pods"`
Deployments struct {
Total int `json:"total"`
Online int `json:"online"`
Warning int `json:"warning"`
Offline int `json:"offline"`
Unknown int `json:"unknown"`
} `json:"deployments"`
})
for _, row := range resp.Data {
byNS[row.Namespace] = row
}
if len(byNS) != 2 {
t.Fatalf("expected 2 namespaces, got %d (%+v)", len(byNS), resp.Data)
}
// default: 2 pods (one running=online, one pending=warning), 1 deployment (ready=online)
defaultRow, ok := byNS["default"]
if !ok {
t.Fatalf("expected default namespace row")
}
if defaultRow.Pods.Total != 2 || defaultRow.Pods.Online != 1 || defaultRow.Pods.Warning != 1 {
t.Fatalf("default pods = %+v, want total=2 online=1 warning=1", defaultRow.Pods)
}
if defaultRow.Deployments.Total != 1 || defaultRow.Deployments.Online != 1 {
t.Fatalf("default deployments = %+v, want total=1 online=1", defaultRow.Deployments)
}
kubeSystemRow, ok := byNS["kube-system"]
if !ok {
t.Fatalf("expected kube-system namespace row")
}
if kubeSystemRow.Pods.Total != 1 || kubeSystemRow.Pods.Online != 1 {
t.Fatalf("kube-system pods = %+v, want total=1 online=1", kubeSystemRow.Pods)
}
if kubeSystemRow.Deployments.Total != 1 || kubeSystemRow.Deployments.Warning != 1 {
t.Fatalf("kube-system deployments = %+v, want total=1 warning=1", kubeSystemRow.Deployments)
}
}
func TestK8sNamespacesResponseUsesCanonicalEmptyCollections(t *testing.T) {
payload, err := json.Marshal(emptyK8sNamespacesResponse())
if err != nil {
t.Fatalf("marshal empty k8s namespaces response: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode empty k8s namespaces response: %v", err)
}
data, ok := decoded["data"].([]any)
if !ok || len(data) != 0 {
t.Fatalf("expected data to be an empty array, got %T (%v)", decoded["data"], decoded["data"])
}
}
func TestResourceAndStorageResponsesUseCanonicalEmptyCollections(t *testing.T) {
tests := []struct {
name string
raw any
keys []string
}{
{name: "resources_data", raw: EmptyResourcesResponse(), keys: []string{"data"}},
{name: "resources_by_type", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "byType"}},
{name: "resources_by_status", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "byStatus"}},
{name: "resources_by_source", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "bySource"}},
{name: "storage_summary_by_platform", raw: EmptyStorageSummaryResponse(), keys: []string{"byPlatform"}},
{name: "storage_summary_by_resource_type", raw: EmptyStorageSummaryResponse(), keys: []string{"byResourceType"}},
{name: "storage_summary_by_incident_category", raw: EmptyStorageSummaryResponse(), keys: []string{"byIncidentCategory"}},
{name: "storage_summary_top_incidents", raw: EmptyStorageSummaryResponse(), keys: []string{"topIncidents"}},
{name: "storage_incidents_by_category", raw: EmptyStorageIncidentsResponse(), keys: []string{"byCategory"}},
{name: "storage_incidents_by_urgency", raw: EmptyStorageIncidentsResponse(), keys: []string{"byUrgency"}},
{name: "storage_incidents_sections", raw: EmptyStorageIncidentsResponse(), keys: []string{"sections"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
payload, err := json.Marshal(tc.raw)
if err != nil {
t.Fatalf("marshal %s: %v", tc.name, err)
}
var decoded map[string]any
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode %s: %v", tc.name, err)
}
var current any = decoded
for _, key := range tc.keys {
obj, ok := current.(map[string]any)
if !ok {
current = nil
break
}
current = obj[key]
}
switch tc.name {
case "resources_data", "storage_summary_top_incidents", "storage_incidents_sections":
values, ok := current.([]any)
if !ok || len(values) != 0 {
t.Fatalf("expected %s to be an empty array, got %T (%v)", tc.name, current, current)
}
default:
values, ok := current.(map[string]any)
if !ok || len(values) != 0 {
t.Fatalf("expected %s to be an empty object, got %T (%v)", tc.name, current, current)
}
}
})
}
payload, err := json.Marshal(StorageIncidentsResponse{
Sections: []StorageIncidentSection{{
Category: "health",
Label: "Health",
}},
}.NormalizeCollections())
if err != nil {
t.Fatalf("marshal normalized storage incidents: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode normalized storage incidents: %v", err)
}
sections := decoded["sections"].([]any)
section := sections[0].(map[string]any)
resources, ok := section["resources"].([]any)
if !ok || len(resources) != 0 {
t.Fatalf("expected section resources to be an empty array, got %T (%v)", section["resources"], section["resources"])
}
}
func TestResourceListRejectsLegacyKubernetesTypeAlias(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
KubernetesClusters: []models.KubernetesCluster{
{
ID: "cluster-1",
AgentID: "agent-1",
Name: "prod-k8s",
Status: "online",
LastSeen: now,
Nodes: []models.KubernetesNode{
{
UID: "node-1",
Name: "worker-1",
Ready: true,
},
},
Pods: []models.KubernetesPod{
{
UID: "pod-1",
Name: "api-7f8d",
Namespace: "default",
Phase: "Running",
},
},
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=k8s", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if body := rec.Body.String(); !strings.Contains(body, "unsupported type filter token(s): k8s") {
t.Fatalf("unexpected response body: %s", body)
}
}
func TestResourceListReturnsCanonicalKubernetesMetricsTargets(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
KubernetesClusters: []models.KubernetesCluster{
{
ID: "cluster-1",
AgentID: "agent-1",
Name: "prod-k8s",
Status: "online",
LastSeen: now,
Nodes: []models.KubernetesNode{
{
UID: "node-1",
Name: "worker-1",
Ready: true,
},
},
Pods: []models.KubernetesPod{
{
UID: "pod-1",
Name: "api-7f8d",
Namespace: "default",
Phase: "Running",
},
},
Deployments: []models.KubernetesDeployment{
{
UID: "dep-1",
Name: "web",
Namespace: "default",
DesiredReplicas: 2,
ReadyReplicas: 2,
},
},
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=pod,k8s-node,k8s-deployment", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 3 {
t.Fatalf("expected 3 kubernetes resources, got %d", len(resp.Data))
}
wantByType := map[unified.ResourceType]unified.ResourceType{
unified.ResourceTypePod: unified.ResourceTypePod,
unified.ResourceTypeK8sNode: unified.ResourceTypeK8sNode,
unified.ResourceTypeK8sDeployment: unified.ResourceTypeK8sDeployment,
}
for _, resource := range resp.Data {
wantTargetType, ok := wantByType[resource.Type]
if !ok {
t.Fatalf("unexpected resource type in response: %q", resource.Type)
}
if resource.MetricsTarget == nil {
t.Fatalf("expected metrics target for %q", resource.Type)
}
if resource.MetricsTarget.ResourceType != string(wantTargetType) {
t.Fatalf(
"metrics target type for %q = %q, want %q",
resource.Type,
resource.MetricsTarget.ResourceType,
wantTargetType,
)
}
}
}
func TestResourceListIncludesDockerSwarmServicesAndFiltersByCluster(t *testing.T) {
now := time.Now().UTC()
service := models.DockerService{
ID: "svc-1",
Name: "web",
Stack: "edge",
Image: "nginx:1.27",
Mode: "replicated",
DesiredTasks: 3,
RunningTasks: 2,
EndpointPorts: []models.DockerServicePort{
{Protocol: "tcp", TargetPort: 80, PublishedPort: 8080, PublishMode: "ingress"},
},
}
// Two Swarm nodes reporting the same service; unified ingest should de-dupe services per swarm cluster.
host1 := models.DockerHost{
ID: "docker-1",
AgentID: "agent-1",
Hostname: "swarm-1",
DisplayName: "swarm-1",
Status: "online",
CPUs: 4,
TotalMemoryBytes: 8 * 1024 * 1024 * 1024,
Memory: models.Memory{Total: 8 * 1024 * 1024 * 1024, Used: 2 * 1024 * 1024 * 1024, Free: 6 * 1024 * 1024 * 1024, Usage: 0.25},
LastSeen: now,
IntervalSeconds: 5,
Swarm: &models.DockerSwarmInfo{
ClusterID: "cluster-1",
ClusterName: "prod-swarm",
NodeID: "node-1",
NodeRole: "manager",
},
Services: []models.DockerService{service},
}
host2 := models.DockerHost{
ID: "docker-2",
AgentID: "agent-2",
Hostname: "swarm-2",
DisplayName: "swarm-2",
Status: "online",
CPUs: 4,
TotalMemoryBytes: 8 * 1024 * 1024 * 1024,
Memory: models.Memory{Total: 8 * 1024 * 1024 * 1024, Used: 1 * 1024 * 1024 * 1024, Free: 7 * 1024 * 1024 * 1024, Usage: 0.125},
LastSeen: now,
IntervalSeconds: 5,
Swarm: &models.DockerSwarmInfo{
ClusterID: "cluster-1",
ClusterName: "prod-swarm",
NodeID: "node-2",
NodeRole: "worker",
},
Services: []models.DockerService{service},
}
snapshot := models.StateSnapshot{
DockerHosts: []models.DockerHost{host1, host2},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
// Unfiltered-by-cluster: expect the service to show up exactly once.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=docker-service", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 docker service resource (de-duped across swarm nodes), got %d", len(resp.Data))
}
r := resp.Data[0]
if r.Type != unified.ResourceTypeDockerService {
t.Fatalf("resource type = %q, want %q", r.Type, unified.ResourceTypeDockerService)
}
if r.Docker == nil {
t.Fatalf("expected docker payload on docker-service resource")
}
if r.Docker.ServiceID != "svc-1" || r.Name != "web" {
t.Fatalf("unexpected service identity: name=%q serviceId=%q", r.Name, r.Docker.ServiceID)
}
if r.Identity.ClusterName != "prod-swarm" {
t.Fatalf("identity.clusterName = %q, want prod-swarm", r.Identity.ClusterName)
}
// Cluster filter should also return the service.
rec2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/api/resources?type=docker-service&cluster=prod-swarm", nil)
h.HandleListResources(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("cluster status = %d, body=%s", rec2.Code, rec2.Body.String())
}
var resp2 ResourcesResponse
if err := json.NewDecoder(rec2.Body).Decode(&resp2); err != nil {
t.Fatalf("decode cluster response: %v", err)
}
if len(resp2.Data) != 1 {
t.Fatalf("expected 1 docker service resource for cluster filter, got %d", len(resp2.Data))
}
}
func TestResourceListIncludesPBSAndPMG(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
PBSInstances: []models.PBSInstance{
{
ID: "pbs-1",
Name: "pbs-main",
Host: "https://pbs.example.com:8007",
Status: "online",
CPU: 14.2,
Memory: 35.0,
MemoryUsed: 4 * 1024 * 1024 * 1024,
MemoryTotal: 12 * 1024 * 1024 * 1024,
Uptime: 7200,
ConnectionHealth: "connected",
LastSeen: now,
},
},
PMGInstances: []models.PMGInstance{
{
ID: "pmg-1",
Name: "pmg-main",
Host: "https://pmg.example.com:8006",
Status: "online",
ConnectionHealth: "connected",
LastSeen: now,
LastUpdated: now,
RelayDomains: []models.PMGRelayDomain{
{Domain: "example.com", Comment: "primary relay"},
},
DomainStats: []models.PMGDomainStat{
{Domain: "example.com", MailCount: 100, SpamCount: 5, VirusCount: 1, Bytes: 1234},
},
DomainStatsAsOf: now,
MailStats: &models.PMGMailStats{
BytesIn: 1_500_000,
BytesOut: 900_000,
},
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=pbs,pmg", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 2 {
t.Fatalf("expected 2 resources, got %d", len(resp.Data))
}
var gotPBS, gotPMG bool
var pmgResourceID string
for _, resource := range resp.Data {
switch resource.Type {
case unified.ResourceTypePBS:
gotPBS = true
if resource.PBS == nil {
t.Fatalf("expected PBS payload, got nil")
}
if resource.DiscoveryTarget == nil || resource.DiscoveryTarget.ResourceType != "agent" {
t.Fatalf("expected agent discovery target for PBS, got %+v", resource.DiscoveryTarget)
}
case unified.ResourceTypePMG:
gotPMG = true
pmgResourceID = resource.ID
if resource.PMG == nil {
t.Fatalf("expected PMG payload, got nil")
}
// List response should be summary-only (heavy fields pruned).
if len(resource.PMG.RelayDomains) > 0 {
t.Fatalf("expected relayDomains pruned from list response, got %+v", resource.PMG.RelayDomains)
}
if len(resource.PMG.DomainStats) > 0 {
t.Fatalf("expected domainStats pruned from list response, got %+v", resource.PMG.DomainStats)
}
if resource.DiscoveryTarget == nil || resource.DiscoveryTarget.ResourceType != "agent" {
t.Fatalf("expected agent discovery target for PMG, got %+v", resource.DiscoveryTarget)
}
}
}
if !gotPBS || !gotPMG {
t.Fatalf("expected both PBS and PMG resources, got %+v", resp.Data)
}
// Detail response should include the heavy fields.
if pmgResourceID == "" {
t.Fatalf("expected pmg resource id to be set")
}
getRec := httptest.NewRecorder()
getReq := httptest.NewRequest(http.MethodGet, "/api/resources/"+pmgResourceID, nil)
h.HandleGetResource(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("get status = %d, body=%s", getRec.Code, getRec.Body.String())
}
var pmgResource unified.Resource
if err := json.NewDecoder(getRec.Body).Decode(&pmgResource); err != nil {
t.Fatalf("decode pmg get response: %v", err)
}
if pmgResource.PMG == nil {
t.Fatalf("expected PMG payload on get response, got nil")
}
if len(pmgResource.PMG.RelayDomains) == 0 {
t.Fatalf("expected relayDomains on get response, got empty")
}
if len(pmgResource.PMG.DomainStats) == 0 {
t.Fatalf("expected domainStats on get response, got empty")
}
}
func TestResourceListIncludesStorageMetadata(t *testing.T) {
snapshot := models.StateSnapshot{
Storage: []models.Storage{
{
ID: "storage-1",
Name: "ceph-rbd",
Node: "pve-1",
Instance: "cluster-a",
Type: "rbd",
Pool: "ceph/rbd-a",
Content: "images,backup",
Shared: true,
Status: "available",
Enabled: true,
Active: true,
Total: 1000,
Used: 250,
Free: 750,
Usage: 25,
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=storage", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 storage resource, got %d", len(resp.Data))
}
resource := resp.Data[0]
if resource.Storage == nil {
t.Fatalf("expected storage metadata payload")
}
if got, want := resource.Storage.Type, "rbd"; got != want {
t.Fatalf("storage.type = %q, want %q", got, want)
}
if got, want := resource.Storage.Content, "images,backup"; got != want {
t.Fatalf("storage.content = %q, want %q", got, want)
}
if got, want := resource.Storage.ContentTypes, []string{"images", "backup"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("storage.contentTypes = %v, want %v", got, want)
}
if got, want := resource.Storage.Pool, "ceph/rbd-a"; got != want {
t.Fatalf("storage.pool = %q, want %q", got, want)
}
if !resource.Storage.Shared {
t.Fatalf("expected storage.shared=true")
}
if !resource.Storage.IsCeph {
t.Fatalf("expected storage.isCeph=true")
}
if resource.Storage.IsZFS {
t.Fatalf("expected storage.isZfs=false")
}
if resource.Proxmox == nil || resource.Proxmox.NodeName != "pve-1" || resource.Proxmox.Instance != "cluster-a" {
t.Fatalf("expected proxmox node/instance metadata to remain populated, got %+v", resource.Proxmox)
}
}
func TestResourceListIncludesStorageConsumerImpact(t *testing.T) {
snapshot := models.StateSnapshot{
Storage: []models.Storage{
{
ID: "cluster-a-pve-1-local-lvm",
Name: "local-lvm",
Node: "pve-1",
Instance: "cluster-a",
Type: "lvmthin",
Status: "available",
Enabled: true,
Active: true,
},
{
ID: "cluster-a-pve-1-media",
Name: "media",
Node: "pve-1",
Instance: "cluster-a",
Type: "dir",
Status: "available",
Enabled: true,
Active: true,
Path: "/mnt/pve/media",
},
},
VMs: []models.VM{
{
ID: "vm-100",
Name: "app01",
Node: "pve-1",
Instance: "cluster-a",
Status: "running",
LastSeen: time.Now().UTC(),
Disks: []models.Disk{
{Device: "local-lvm:vm-100-disk-0"},
{Device: "local-lvm:vm-100-disk-1"},
},
},
},
Containers: []models.Container{
{
ID: "ct-200",
Name: "media01",
Node: "pve-1",
Instance: "cluster-a",
Status: "running",
LastSeen: time.Now().UTC(),
Disks: []models.Disk{
{Device: "/mnt/pve/media/subvol-200-disk-1"},
},
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=storage", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 2 {
t.Fatalf("expected 2 storage resources, got %d", len(resp.Data))
}
local := findAPIResourceByNameAndNode(resp.Data, "local-lvm", "pve-1")
if local.Storage == nil {
t.Fatalf("expected local-lvm storage payload")
}
if got := local.Storage.ConsumerCount; got != 1 {
t.Fatalf("local-lvm consumerCount = %d, want 1", got)
}
if got := local.Storage.ConsumerTypes; len(got) != 1 || got[0] != "vm" {
t.Fatalf("local-lvm consumerTypes = %v, want [vm]", got)
}
if len(local.Storage.TopConsumers) != 1 {
t.Fatalf("local-lvm topConsumers length = %d, want 1", len(local.Storage.TopConsumers))
}
if consumer := local.Storage.TopConsumers[0]; consumer.Name != "app01" || consumer.ResourceType != unified.ResourceTypeVM || consumer.DiskCount != 2 {
t.Fatalf("unexpected local-lvm top consumer %+v", consumer)
}
if got := local.Storage.ConsumerImpactSummary; got != "Affects 1 dependent resource: app01" {
t.Fatalf("local-lvm consumerImpactSummary = %q", got)
}
media := findAPIResourceByNameAndNode(resp.Data, "media", "pve-1")
if media.Storage == nil {
t.Fatalf("expected media storage payload")
}
if got := media.Storage.ConsumerCount; got != 1 {
t.Fatalf("media consumerCount = %d, want 1", got)
}
if len(media.Storage.TopConsumers) != 1 {
t.Fatalf("media topConsumers length = %d, want 1", len(media.Storage.TopConsumers))
}
if consumer := media.Storage.TopConsumers[0]; consumer.Name != "media01" || consumer.ResourceType != unified.ResourceTypeSystemContainer || consumer.DiskCount != 1 {
t.Fatalf("unexpected media top consumer %+v", consumer)
}
if got := media.Storage.ConsumerImpactSummary; got != "Affects 1 dependent resource: media01" {
t.Fatalf("media consumerImpactSummary = %q", got)
}
}
func TestResourceListIncludesPBSStorageConsumerImpact(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
PBSInstances: []models.PBSInstance{
{
ID: "pbs-1",
Name: "pbs-main",
Host: "https://pbs-main.local:8007",
Status: "online",
LastSeen: now,
Datastores: []models.PBSDatastore{
{
Name: "backup-store",
Status: "online",
},
},
},
},
PBSBackups: []models.PBSBackup{
{
ID: "pbs-1/backup-store/vm/100",
Instance: "pbs-main",
Datastore: "backup-store",
Namespace: "pve",
BackupType: "vm",
VMID: "100",
BackupTime: now,
},
{
ID: "pbs-1/backup-store/ct/200",
Instance: "pbs-main",
Datastore: "backup-store",
Namespace: "nat",
BackupType: "ct",
VMID: "200",
BackupTime: now,
},
},
VMs: []models.VM{
{
ID: "vm-100",
Name: "app01",
Node: "pve-1",
Instance: "pve",
Status: "running",
LastSeen: now,
VMID: 100,
},
},
Containers: []models.Container{
{
ID: "ct-200",
Name: "media01",
Node: "pve-2",
Instance: "pve-nat",
Status: "running",
LastSeen: now,
VMID: 200,
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=storage", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 storage resource, got %d", len(resp.Data))
}
datastore := findAPIStorageResourceByPlatform(resp.Data, "backup-store", "pbs", "datastore")
if datastore.Storage == nil {
t.Fatalf("expected PBS datastore storage payload, got %+v", datastore)
}
if got := datastore.Storage.ConsumerCount; got != 2 {
t.Fatalf("backup-store consumerCount = %d, want 2", got)
}
if got := datastore.Storage.ConsumerTypes; len(got) != 2 || got[0] != "system-container" || got[1] != "vm" {
t.Fatalf("backup-store consumerTypes = %v, want [system-container vm]", got)
}
if len(datastore.Storage.TopConsumers) != 2 {
t.Fatalf("backup-store topConsumers length = %d, want 2", len(datastore.Storage.TopConsumers))
}
if got := datastore.Storage.ConsumerImpactSummary; got != "Puts backups for 2 protected workloads at risk: media01, app01" {
t.Fatalf("backup-store consumerImpactSummary = %q", got)
}
if !containsSource(datastore.Sources, unified.SourcePBS) {
t.Fatalf("expected PBS source on datastore, got %+v", datastore.Sources)
}
if datastore.ParentID == nil {
t.Fatalf("expected PBS datastore to remain parented under PBS instance")
}
if !hasAPIStorageConsumer(datastore.Storage.TopConsumers, "app01", unified.ResourceTypeVM, 1) {
t.Fatalf("expected vm consumer on backup-store, got %+v", datastore.Storage.TopConsumers)
}
if !hasAPIStorageConsumer(datastore.Storage.TopConsumers, "media01", unified.ResourceTypeSystemContainer, 1) {
t.Fatalf("expected container consumer on backup-store, got %+v", datastore.Storage.TopConsumers)
}
}
func TestResourceListIncludesPBSPrimaryIncidentRollup(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
PBSInstances: []models.PBSInstance{
{
ID: "pbs-1",
Name: "pbs-main",
Host: "https://pbs-main.local:8007",
Status: "online",
LastSeen: now,
Datastores: []models.PBSDatastore{
{
Name: "fast",
Status: "online",
Total: 100,
Used: 96,
Usage: 96,
},
{
Name: "archive",
Status: "read_only",
},
},
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=pbs", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 pbs resource, got %d", len(resp.Data))
}
resource := resp.Data[0]
if resource.IncidentCount != 2 {
t.Fatalf("incidentCount = %d, want 2", resource.IncidentCount)
}
if resource.IncidentCode != "capacity_runway_low" {
t.Fatalf("incidentCode = %q, want capacity_runway_low", resource.IncidentCode)
}
if resource.IncidentSeverity != storagehealth.RiskCritical {
t.Fatalf("incidentSeverity = %q, want %q", resource.IncidentSeverity, storagehealth.RiskCritical)
}
if resource.IncidentSummary != "PBS datastore fast is 96% full" {
t.Fatalf("incidentSummary = %q", resource.IncidentSummary)
}
if resource.IncidentCategory != unified.IncidentCategoryRecoverability {
t.Fatalf("incidentCategory = %q, want %q", resource.IncidentCategory, unified.IncidentCategoryRecoverability)
}
if resource.IncidentLabel != "Backup Coverage At Risk" {
t.Fatalf("incidentLabel = %q", resource.IncidentLabel)
}
if resource.IncidentPriority != 4502 {
t.Fatalf("incidentPriority = %d", resource.IncidentPriority)
}
if resource.IncidentImpactSummary != "Affects 2 backup datastores: archive, fast" {
t.Fatalf("incidentImpactSummary = %q", resource.IncidentImpactSummary)
}
if resource.IncidentUrgency != unified.IncidentUrgencyNow {
t.Fatalf("incidentUrgency = %q, want %q", resource.IncidentUrgency, unified.IncidentUrgencyNow)
}
if resource.IncidentAction != "Restore backup target health immediately to protect recoverability" {
t.Fatalf("incidentAction = %q", resource.IncidentAction)
}
}
func TestResourceListIncludesPBSProtectedWorkloadRollup(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
PBSInstances: []models.PBSInstance{
{
ID: "pbs-1",
Name: "pbs-main",
Host: "https://pbs-main.local:8007",
Status: "online",
LastSeen: now,
Datastores: []models.PBSDatastore{
{
Name: "backup-store",
Status: "online",
Total: 100,
Used: 96,
},
},
},
},
PBSBackups: []models.PBSBackup{
{
ID: "pbs-1/backup-store/vm/100",
Instance: "pbs-main",
Datastore: "backup-store",
Namespace: "pve",
BackupType: "vm",
VMID: "100",
BackupTime: now,
},
{
ID: "pbs-1/backup-store/ct/200",
Instance: "pbs-main",
Datastore: "backup-store",
Namespace: "nat",
BackupType: "ct",
VMID: "200",
BackupTime: now,
},
},
VMs: []models.VM{
{
ID: "vm-100",
Name: "app01",
Node: "pve-1",
Instance: "pve",
Status: "running",
LastSeen: now,
VMID: 100,
},
},
Containers: []models.Container{
{
ID: "ct-200",
Name: "media01",
Node: "pve-2",
Instance: "pve-nat",
Status: "running",
LastSeen: now,
VMID: 200,
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=pbs", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 PBS resource, got %d", len(resp.Data))
}
pbs := resp.Data[0]
if pbs.Type != unified.ResourceTypePBS || pbs.PBS == nil {
t.Fatalf("expected PBS resource payload, got %+v", pbs)
}
if got := pbs.PBS.ProtectedWorkloadCount; got != 2 {
t.Fatalf("protectedWorkloadCount = %d, want 2", got)
}
if got := pbs.PBS.ProtectedWorkloadTypes; len(got) != 2 || got[0] != "system-container" || got[1] != "vm" {
t.Fatalf("protectedWorkloadTypes = %v, want [system-container vm]", got)
}
if got := pbs.PBS.ProtectedWorkloadNames; len(got) != 2 || got[0] != "media01" || got[1] != "app01" {
t.Fatalf("protectedWorkloadNames = %v, want [media01 app01]", got)
}
if got := pbs.PBS.AffectedDatastoreCount; got != 1 {
t.Fatalf("affectedDatastoreCount = %d, want 1", got)
}
if got := pbs.PBS.AffectedDatastores; len(got) != 1 || got[0] != "backup-store" {
t.Fatalf("affectedDatastores = %v, want [backup-store]", got)
}
if got := pbs.PBS.AffectedDatastoreSummary; got != "Affects 1 backup datastore: backup-store" {
t.Fatalf("affectedDatastoreSummary = %q", got)
}
if got := pbs.PBS.ProtectedWorkloadSummary; got != "Puts backups for 2 protected workloads at risk: media01, app01" {
t.Fatalf("protectedWorkloadSummary = %q", got)
}
if got := pbs.PBS.PostureSummary; got != "Affects 1 backup datastore: backup-store. Puts backups for 2 protected workloads at risk: media01, app01" {
t.Fatalf("postureSummary = %q", got)
}
}
func TestResourceListIncludesHostUnraidStorage(t *testing.T) {
snapshot := models.StateSnapshot{
Hosts: []models.Host{
{
ID: "host-tower",
Hostname: "tower",
Status: "online",
LastSeen: time.Now().UTC(),
MachineID: "machine-tower",
Disks: []models.Disk{
{Mountpoint: "/mnt/user", Total: 1000, Used: 400, Free: 600, Usage: 40},
},
Unraid: &models.HostUnraidStorage{
ArrayStarted: true,
ArrayState: "STARTED",
NumProtected: 1,
Disks: []models.HostUnraidDisk{
{Name: "parity", Role: "parity", Status: "online"},
{Name: "disk1", Role: "data", Status: "online"},
},
},
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=storage", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 storage resource, got %d", len(resp.Data))
}
resource := resp.Data[0]
if !containsSource(resource.Sources, unified.SourceAgent) {
t.Fatalf("expected agent-backed storage source, got %+v", resource.Sources)
}
if resource.Storage == nil || resource.Storage.Type != "unraid-array" {
t.Fatalf("expected unraid storage metadata, got %+v", resource.Storage)
}
if resource.Storage.Platform != "unraid" || resource.Storage.Protection != "single-parity" {
t.Fatalf("expected unraid storage platform/protection, got %+v", resource.Storage)
}
if resource.Metrics == nil || resource.Metrics.Disk == nil || resource.Metrics.Disk.Percent != 40 {
t.Fatalf("expected unraid storage capacity metric, got %+v", resource.Metrics)
}
if resource.MetricsTarget == nil || resource.MetricsTarget.ResourceType != "storage" || resource.MetricsTarget.ResourceID != "host-tower/storage:unraid-array" {
t.Fatalf("expected unraid storage metrics target host-tower/storage:unraid-array, got %+v", resource.MetricsTarget)
}
}
func TestResourceListReturnsCanonicalStorageMetricsTargets(t *testing.T) {
t.Run("pbs datastore", func(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
PBSInstances: []models.PBSInstance{
{
ID: "pbs-1",
Name: "pbs-main",
Host: "https://pbs.example.com:8007",
Status: "online",
LastSeen: now,
Datastores: []models.PBSDatastore{
{Name: "archive", Status: "online", Total: 100, Used: 45, Free: 55, Usage: 45},
},
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=storage", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
resource := findAPIStorageResourceByPlatform(resp.Data, "archive", "pbs", "datastore")
if resource.ID == "" {
t.Fatalf("expected PBS datastore resource in %#v", resp.Data)
}
if resource.MetricsTarget == nil || resource.MetricsTarget.ResourceType != "storage" || resource.MetricsTarget.ResourceID != "pbs-1/archive" {
t.Fatalf("expected PBS storage metrics target pbs-1/archive, got %+v", resource.MetricsTarget)
}
})
t.Run("truenas pool", func(t *testing.T) {
now := time.Now().UTC()
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: now}})
h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{
records: []unified.IngestRecord{
{
SourceID: "pool:tank",
Resource: unified.Resource{
ID: "storage:tank",
Type: unified.ResourceTypeStorage,
Name: "tank",
Status: unified.StatusOnline,
LastSeen: now,
UpdatedAt: now,
Sources: []unified.DataSource{unified.SourceTrueNAS},
Metrics: &unified.ResourceMetrics{
Disk: &unified.MetricValue{Percent: 62},
},
Storage: &unified.StorageMeta{
Platform: "truenas",
Topology: "pool",
Protection: "zfs",
},
},
},
},
ownedSources: []unified.DataSource{unified.SourceTrueNAS},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=storage&source=truenas", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
resource := findAPIStorageResourceByPlatform(resp.Data, "tank", "truenas", "pool")
if resource.ID == "" {
t.Fatalf("expected TrueNAS pool resource in %#v", resp.Data)
}
if resource.MetricsTarget == nil || resource.MetricsTarget.ResourceType != "storage" || resource.MetricsTarget.ResourceID != "pool:tank" {
t.Fatalf("expected TrueNAS storage metrics target pool:tank, got %+v", resource.MetricsTarget)
}
})
}
func TestResourceStorageSummaryRollsUpIncidents(t *testing.T) {
now := time.Now().UTC()
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceUnifiedSeedProvider{
snapshot: models.StateSnapshot{LastUpdate: now},
resources: []unified.Resource{
{
ID: "pbs:main",
Type: unified.ResourceTypePBS,
Name: "pbs-main",
Status: unified.StatusWarning,
LastSeen: now,
UpdatedAt: now,
Sources: []unified.DataSource{unified.SourcePBS},
Incidents: []unified.ResourceIncident{{
Provider: "pulse",
NativeID: "pbs-alert-1",
Code: "pbs_datastore_state",
Severity: storagehealth.RiskCritical,
Summary: "PBS datastore archive is READ_ONLY",
}},
PBS: &unified.PBSData{
ProtectedWorkloadCount: 2,
AffectedDatastoreCount: 1,
ProtectedWorkloadNames: []string{"media01", "app01"},
},
},
{
ID: "storage:tower-array",
Type: unified.ResourceTypeStorage,
Name: "Tower Array",
Status: unified.StatusWarning,
LastSeen: now,
UpdatedAt: now,
Sources: []unified.DataSource{unified.SourceAgent},
Incidents: []unified.ResourceIncident{{
Provider: "pulse",
NativeID: "unraid-alert-1",
Code: "unraid_parity_unavailable",
Severity: storagehealth.RiskWarning,
Summary: "Unraid parity protection is unavailable",
}},
Storage: &unified.StorageMeta{
Platform: "unraid",
Topology: "array",
Risk: &unified.StorageRisk{
Level: storagehealth.RiskWarning,
Reasons: []unified.StorageRiskReason{{
Code: "unraid_parity_unavailable",
Severity: storagehealth.RiskWarning,
Summary: "Unraid parity protection is unavailable",
}},
},
ProtectionReduced: true,
ConsumerCount: 3,
ConsumerImpactSummary: "Affects 3 dependent resources: media01, app01, and 1 more",
},
},
{
ID: "disk:serial-1",
Type: unified.ResourceTypePhysicalDisk,
Name: "SERIAL-1",
Status: unified.StatusWarning,
LastSeen: now,
UpdatedAt: now,
Sources: []unified.DataSource{unified.SourceAgent},
Incidents: []unified.ResourceIncident{{
Provider: "pulse",
NativeID: "disk-alert-1",
Code: "smart_pending_sectors",
Severity: storagehealth.RiskWarning,
Summary: "Disk has pending sectors",
}},
PhysicalDisk: &unified.PhysicalDiskMeta{Serial: "SERIAL-1"},
},
},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources/storage-summary", nil)
h.HandleStorageSummary(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp StorageSummaryResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.TotalResources != 3 {
t.Fatalf("totalResources = %d, want 3", resp.TotalResources)
}
if resp.RiskyResources != 3 || resp.CriticalResources != 1 || resp.WarningResources != 2 {
t.Fatalf("unexpected severity counts %+v", resp)
}
if resp.ProtectionReducedCount != 1 {
t.Fatalf("protectionReducedCount = %d, want 1", resp.ProtectionReducedCount)
}
if got := resp.ByIncidentCategory[unified.IncidentCategoryRecoverability]; got != 1 {
t.Fatalf("recoverability count = %d, want 1", got)
}
if got := resp.ByIncidentCategory[unified.IncidentCategoryProtection]; got != 1 {
t.Fatalf("protection count = %d, want 1", got)
}
if got := resp.ByIncidentCategory[unified.IncidentCategoryDiskHealth]; got != 1 {
t.Fatalf("disk-health count = %d, want 1", got)
}
if got := resp.ByPlatform["pbs"]; got != 1 {
t.Fatalf("pbs platform count = %d, want 1", got)
}
if got := resp.ByPlatform["unraid"]; got != 1 {
t.Fatalf("unraid platform count = %d, want 1", got)
}
if len(resp.TopIncidents) != 3 {
t.Fatalf("topIncidents len = %d, want 3", len(resp.TopIncidents))
}
if resp.TopIncidents[0].ResourceID != "pbs:main" || resp.TopIncidents[0].IncidentCategory != unified.IncidentCategoryRecoverability {
t.Fatalf("expected PBS recoverability incident first, got %+v", resp.TopIncidents[0])
}
if resp.TopIncidents[1].ResourceID != "storage:tower-array" || !resp.TopIncidents[1].ProtectionReduced {
t.Fatalf("expected unraid protection incident second, got %+v", resp.TopIncidents[1])
}
if resp.TopIncidents[2].ResourceID != "disk:serial-1" {
t.Fatalf("expected disk incident third, got %+v", resp.TopIncidents[2])
}
}
func TestResourceDashboardSummaryUsesCompactGovernedPayload(t *testing.T) {
now := time.Now().UTC()
criticalDiskTotal := int64(1_000)
criticalDiskUsed := int64(850)
restrictedResource := unified.Resource{
ID: "agent:restricted-1",
Type: unified.ResourceTypeAgent,
Name: "restricted-host",
Status: unified.StatusOnline,
LastSeen: now,
UpdatedAt: now,
Tags: []string{"restricted"},
Sources: []unified.DataSource{unified.SourceAgent},
MetricsTarget: &unified.MetricsTarget{
ResourceType: "agent",
ResourceID: "metrics-restricted-1",
},
Metrics: &unified.ResourceMetrics{
CPU: &unified.MetricValue{Percent: 95},
Memory: &unified.MetricValue{Percent: 88},
},
}
expectedRestricted := unified.RefreshCanonicalMetadataSlice([]unified.Resource{restrictedResource})[0]
expectedRestrictedLabel := unified.ResourcePolicyLabel(
unified.ResourceDisplayName(expectedRestricted),
expectedRestricted.AISafeSummary,
expectedRestricted.Policy,
)
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceUnifiedSeedProvider{
snapshot: models.StateSnapshot{LastUpdate: now},
resources: []unified.Resource{
restrictedResource,
{
ID: "docker-host:preview-1",
Type: unified.ResourceTypeAgent,
Name: "preview-docker-host",
Status: unified.StatusOffline,
LastSeen: now,
UpdatedAt: now,
Sources: []unified.DataSource{unified.SourceDocker},
Docker: &unified.DockerData{Hostname: "preview-docker-host"},
Metrics: &unified.ResourceMetrics{
CPU: &unified.MetricValue{Percent: 70},
Memory: &unified.MetricValue{Percent: 40},
},
},
{
ID: "vm:101",
Type: unified.ResourceTypeVM,
Name: "vm-101",
Status: unified.ResourceStatus("running"),
LastSeen: now,
UpdatedAt: now,
Sources: []unified.DataSource{unified.SourceProxmox},
},
{
ID: "dataset:critical-1",
Type: "dataset",
Name: "tank/apps",
Status: unified.ResourceStatus("degraded"),
LastSeen: now,
UpdatedAt: now,
Sources: []unified.DataSource{unified.SourceTrueNAS},
Metrics: &unified.ResourceMetrics{
Disk: &unified.MetricValue{Used: &criticalDiskUsed, Total: &criticalDiskTotal, Percent: 85},
},
},
},
})
registry, err := h.buildRegistry("")
if err != nil {
t.Fatalf("buildRegistry: %v", err)
}
expectedTopCPUMetricsTarget := registry.MetricsTarget("agent:restricted-1")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources/dashboard-summary", nil)
h.HandleDashboardSummary(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp DashboardOverviewSummaryResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Health.TotalResources != 4 {
t.Fatalf("totalResources = %d, want 4", resp.Health.TotalResources)
}
if resp.Infrastructure.Total != 2 {
t.Fatalf("infrastructure.total = %d, want 2", resp.Infrastructure.Total)
}
if resp.Infrastructure.ByType["agent"] != 1 || resp.Infrastructure.ByType["docker-host"] != 1 {
t.Fatalf("unexpected infrastructure.byType = %+v", resp.Infrastructure.ByType)
}
if len(resp.Infrastructure.TopCPU) == 0 || resp.Infrastructure.TopCPU[0].Name != expectedRestrictedLabel {
t.Fatalf("topCPU = %+v, want governed summary label first", resp.Infrastructure.TopCPU)
}
if expectedTopCPUMetricsTarget == nil {
t.Fatal("expected restricted top CPU resource to expose a metrics target")
}
if resp.Infrastructure.TopCPU[0].MetricsTarget == nil || *resp.Infrastructure.TopCPU[0].MetricsTarget != *expectedTopCPUMetricsTarget {
t.Fatalf("topCPU[0].metricsTarget = %+v, want %+v", resp.Infrastructure.TopCPU[0].MetricsTarget, expectedTopCPUMetricsTarget)
}
if resp.Infrastructure.TopCPU[0].Name == "restricted-host" {
t.Fatalf("expected governed label, got raw restricted hostname")
}
if resp.Workloads.Total != 1 || resp.Workloads.Running != 1 || resp.Workloads.Stopped != 0 {
t.Fatalf("unexpected workloads summary = %+v", resp.Workloads)
}
if resp.Storage.Total != 1 || resp.Storage.TotalCapacity != 1_000 || resp.Storage.TotalUsed != 850 {
t.Fatalf("unexpected storage summary = %+v", resp.Storage)
}
if resp.Storage.WarningCount != 1 || resp.Storage.CriticalCount != 0 {
t.Fatalf("unexpected storage warning counts = %+v", resp.Storage)
}
if len(resp.ProblemResources) != 3 {
t.Fatalf("problemResources len = %d, want 3", len(resp.ProblemResources))
}
if resp.ProblemResources[0].ID != "docker-host:preview-1" || len(resp.ProblemResources[0].Problems) == 0 || resp.ProblemResources[0].Problems[0] != "Offline" {
t.Fatalf("expected offline docker host first, got %+v", resp.ProblemResources[0])
}
if resp.ProblemResources[1].ID != "dataset:critical-1" || len(resp.ProblemResources[1].Problems) == 0 || resp.ProblemResources[1].Problems[0] != "Degraded" {
t.Fatalf("expected degraded storage row second, got %+v", resp.ProblemResources[1])
}
if resp.ProblemResources[2].ID != "agent:restricted-1" || resp.ProblemResources[2].Name != expectedRestrictedLabel {
t.Fatalf("expected governed restricted host third, got %+v", resp.ProblemResources[2])
}
if resp.ProblemResources[2].CanonicalIdentity == nil || resp.ProblemResources[2].Policy == nil {
t.Fatalf("expected governed problem resource to include canonical identity and policy, got %+v", resp.ProblemResources[2])
}
}
func TestResourceListIncludesTrueNASPhysicalDiskTemperature(t *testing.T) {
previous := truenas.IsFeatureEnabled()
truenas.SetFeatureEnabled(true)
t.Cleanup(func() {
truenas.SetFeatureEnabled(previous)
})
now := time.Now().UTC()
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: now}})
h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{
records: truenas.NewProvider(truenas.DefaultFixtures()).Records(),
ownedSources: []unified.DataSource{unified.SourceTrueNAS},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=physical_disk&source=truenas", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) == 0 {
t.Fatal("expected TrueNAS physical disk resources")
}
var sda unified.Resource
foundSDA := false
var sdc unified.Resource
foundSDC := false
for _, candidate := range resp.Data {
if candidate.Name == "sda" {
sda = candidate
foundSDA = true
}
if candidate.Name == "sdc" {
sdc = candidate
foundSDC = true
}
}
if !foundSDA {
t.Fatalf("expected sda in TrueNAS physical disk response, got %+v", resp.Data)
}
if !foundSDC {
t.Fatalf("expected sdc in TrueNAS physical disk response, got %+v", resp.Data)
}
if sda.PhysicalDisk == nil {
t.Fatalf("expected sda physical-disk metadata, got %+v", sda)
}
if sda.PhysicalDisk.Temperature != 34 {
t.Fatalf("expected sda temperature 34, got %+v", sda.PhysicalDisk)
}
if sda.MetricsTarget == nil || sda.MetricsTarget.ResourceType != "disk" || sda.MetricsTarget.ResourceID != "ZL0A1234" {
t.Fatalf("expected canonical disk metrics target ZL0A1234, got %+v", sda.MetricsTarget)
}
if sdc.PhysicalDisk == nil || sdc.PhysicalDisk.Risk == nil {
t.Fatalf("expected sdc disk risk payload, got %+v", sdc)
}
if sdc.PhysicalDisk.Risk.Level != storagehealth.RiskWarning {
t.Fatalf("expected sdc risk level warning, got %+v", sdc.PhysicalDisk.Risk)
}
foundSmartReason := false
for _, reason := range sdc.PhysicalDisk.Risk.Reasons {
if reason.Code == "truenas_smart" {
foundSmartReason = true
break
}
}
if !foundSmartReason {
t.Fatalf("expected sdc SMART-backed risk reason, got %+v", sdc.PhysicalDisk.Risk.Reasons)
}
}
func TestResourceListIncludesTrueNASAppsAsAppContainers(t *testing.T) {
previous := truenas.IsFeatureEnabled()
truenas.SetFeatureEnabled(true)
t.Cleanup(func() {
truenas.SetFeatureEnabled(previous)
})
fixtures := truenas.DefaultFixtures()
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: time.Now().UTC()}})
h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{
records: truenas.NewProvider(fixtures).Records(),
ownedSources: []unified.DataSource{unified.SourceTrueNAS},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=app-container&source=truenas", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != len(fixtures.Apps) {
t.Fatalf("expected %d TrueNAS app-container resources, got %d", len(fixtures.Apps), len(resp.Data))
}
var nextcloud *unified.Resource
for i := range resp.Data {
resource := &resp.Data[i]
if resource.Name == "Nextcloud" {
nextcloud = resource
break
}
}
if nextcloud == nil {
t.Fatalf("expected Nextcloud in TrueNAS app-container response, got %+v", resp.Data)
}
if !containsSource(nextcloud.Sources, unified.SourceTrueNAS) {
t.Fatalf("expected TrueNAS source, got %+v", nextcloud.Sources)
}
if nextcloud.ParentName != "truenas-main" {
t.Fatalf("expected Nextcloud parentName truenas-main, got %q", nextcloud.ParentName)
}
if nextcloud.Docker == nil {
t.Fatal("expected docker payload on TrueNAS app resource")
}
if nextcloud.Docker.ContainerID != "nextcloud" {
t.Fatalf("expected canonical app container ID %q, got %q", "nextcloud", nextcloud.Docker.ContainerID)
}
if nextcloud.Docker.Image != "docker.io/library/nextcloud:29.0.7" {
t.Fatalf("expected Nextcloud image %q, got %q", "docker.io/library/nextcloud:29.0.7", nextcloud.Docker.Image)
}
if nextcloud.Docker.Runtime != "docker" {
t.Fatalf("expected runtime docker, got %q", nextcloud.Docker.Runtime)
}
if nextcloud.MetricsTarget == nil || nextcloud.MetricsTarget.ResourceType != "app-container" || nextcloud.MetricsTarget.ResourceID != "nextcloud" {
t.Fatalf("expected canonical TrueNAS app metrics target nextcloud, got %+v", nextcloud.MetricsTarget)
}
}
func TestResourceListIncludesTrueNASSystemAsCanonicalHost(t *testing.T) {
previous := truenas.IsFeatureEnabled()
truenas.SetFeatureEnabled(true)
t.Cleanup(func() {
truenas.SetFeatureEnabled(previous)
})
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: time.Now().UTC()}})
h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{
records: truenas.NewProvider(truenas.DefaultFixtures()).Records(),
ownedSources: []unified.DataSource{unified.SourceTrueNAS},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=agent&source=truenas", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) == 0 {
t.Fatal("expected TrueNAS system resources")
}
var system *unified.Resource
for i := range resp.Data {
if resp.Data[i].Name == "truenas-main" {
system = &resp.Data[i]
break
}
}
if system == nil {
t.Fatalf("expected truenas-main in response, got %+v", resp.Data)
}
if system.Agent == nil || system.Agent.Platform != "truenas" {
t.Fatalf("expected canonical host payload on TrueNAS system resource, got %+v", system)
}
if system.Metrics == nil || system.Metrics.CPU == nil || system.Metrics.Memory == nil {
t.Fatalf("expected canonical host metrics on TrueNAS system resource, got %+v", system)
}
if system.MetricsTarget == nil || system.MetricsTarget.ResourceType != "agent" || system.MetricsTarget.ResourceID != "truenas-main" {
t.Fatalf("expected canonical TrueNAS host metrics target truenas-main, got %+v", system.MetricsTarget)
}
}
func TestResourceStorageIncidentsGroupsCanonicalSections(t *testing.T) {
now := time.Now().UTC()
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceUnifiedSeedProvider{
snapshot: models.StateSnapshot{LastUpdate: now},
resources: []unified.Resource{
{
ID: "pbs:main",
Type: unified.ResourceTypePBS,
Name: "pbs-main",
Status: unified.StatusWarning,
LastSeen: now,
UpdatedAt: now,
Sources: []unified.DataSource{unified.SourcePBS},
Incidents: []unified.ResourceIncident{{
Provider: "pulse",
NativeID: "pbs-alert-1",
Code: "pbs_datastore_state",
Severity: storagehealth.RiskCritical,
Summary: "PBS datastore archive is READ_ONLY",
}},
PBS: &unified.PBSData{
ProtectedWorkloadCount: 2,
ProtectedWorkloadNames: []string{"media01", "app01"},
},
},
{
ID: "storage:tower-array",
Type: unified.ResourceTypeStorage,
Name: "Tower Array",
Status: unified.StatusWarning,
LastSeen: now,
UpdatedAt: now,
Sources: []unified.DataSource{unified.SourceAgent},
Incidents: []unified.ResourceIncident{{
Provider: "pulse",
NativeID: "unraid-alert-1",
Code: "unraid_parity_unavailable",
Severity: storagehealth.RiskWarning,
Summary: "Unraid parity protection is unavailable",
}},
Storage: &unified.StorageMeta{
Platform: "unraid",
Topology: "array",
Risk: &unified.StorageRisk{
Level: storagehealth.RiskWarning,
Reasons: []unified.StorageRiskReason{{
Code: "unraid_parity_unavailable",
Severity: storagehealth.RiskWarning,
Summary: "Unraid parity protection is unavailable",
}},
},
ProtectionReduced: true,
},
},
{
ID: "storage:tank",
Type: unified.ResourceTypeStorage,
Name: "tank",
Status: unified.StatusWarning,
LastSeen: now,
UpdatedAt: now,
Sources: []unified.DataSource{unified.SourceTrueNAS},
Incidents: []unified.ResourceIncident{{
Provider: "pulse",
NativeID: "zfs-alert-1",
Code: "raid_rebuilding",
Severity: storagehealth.RiskWarning,
Summary: "Pool tank is rebuilding",
}},
Storage: &unified.StorageMeta{
Platform: "truenas",
Topology: "pool",
Risk: &unified.StorageRisk{
Level: storagehealth.RiskWarning,
Reasons: []unified.StorageRiskReason{{
Code: "raid_rebuilding",
Severity: storagehealth.RiskWarning,
Summary: "Pool tank is rebuilding",
}},
},
RebuildInProgress: true,
},
},
{
ID: "disk:serial-1",
Type: unified.ResourceTypePhysicalDisk,
Name: "SERIAL-1",
Status: unified.StatusWarning,
LastSeen: now,
UpdatedAt: now,
Sources: []unified.DataSource{unified.SourceAgent},
Incidents: []unified.ResourceIncident{{
Provider: "pulse",
NativeID: "disk-alert-1",
Code: "disk_health",
Severity: storagehealth.RiskWarning,
Summary: "Disk health risk detected",
}},
PhysicalDisk: &unified.PhysicalDiskMeta{Serial: "SERIAL-1"},
},
},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources/storage-incidents", nil)
h.HandleStorageIncidents(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp StorageIncidentsResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.TotalResources != 4 || resp.CriticalResources != 1 || resp.WarningResources != 3 {
t.Fatalf("unexpected counts %+v", resp)
}
if got := resp.ByUrgency[unified.IncidentUrgencyNow]; got != 1 {
t.Fatalf("now count = %d, want 1", got)
}
if got := resp.ByUrgency[unified.IncidentUrgencyToday]; got != 2 {
t.Fatalf("today count = %d, want 2", got)
}
if got := resp.ByUrgency[unified.IncidentUrgencyMonitor]; got != 1 {
t.Fatalf("monitor count = %d, want 1", got)
}
if len(resp.Sections) != 4 {
t.Fatalf("sections len = %d, want 4", len(resp.Sections))
}
if resp.Sections[0].Category != unified.IncidentCategoryRecoverability || resp.Sections[0].PrimaryUrgency != unified.IncidentUrgencyNow {
t.Fatalf("expected recoverability section first, got %+v", resp.Sections[0])
}
if len(resp.Sections[0].Resources) != 1 || resp.Sections[0].Resources[0].ResourceID != "pbs:main" {
t.Fatalf("expected PBS incident in first section, got %+v", resp.Sections[0].Resources)
}
if resp.Sections[1].Category != unified.IncidentCategoryProtection || !resp.Sections[1].Resources[0].ProtectionReduced {
t.Fatalf("expected protection section second, got %+v", resp.Sections[1])
}
if resp.Sections[2].Category != unified.IncidentCategoryRebuild || !resp.Sections[2].Resources[0].RebuildInProgress {
t.Fatalf("expected rebuild section third, got %+v", resp.Sections[2])
}
if resp.Sections[3].Category != unified.IncidentCategoryDiskHealth || resp.Sections[3].Resources[0].ResourceID != "disk:serial-1" {
t.Fatalf("expected disk health section fourth, got %+v", resp.Sections[3])
}
}
func findAPIResourceByNameAndNode(resources []unified.Resource, name, node string) unified.Resource {
for _, resource := range resources {
if resource.Name != name || resource.Proxmox == nil || resource.Proxmox.NodeName != node {
continue
}
return resource
}
return unified.Resource{}
}
func findAPIStorageResourceByPlatform(resources []unified.Resource, name, platform, topology string) unified.Resource {
for _, resource := range resources {
if resource.Name != name || resource.Storage == nil {
continue
}
if resource.Storage.Platform != platform || resource.Storage.Topology != topology {
continue
}
return resource
}
return unified.Resource{}
}
func hasAPIStorageConsumer(consumers []unified.StorageConsumerMeta, name string, resourceType unified.ResourceType, diskCount int) bool {
for _, consumer := range consumers {
if consumer.Name == name && consumer.ResourceType == resourceType && consumer.DiskCount == diskCount {
return true
}
}
return false
}
func TestResourceListIncludesTrueNASFromSupplementalProvider(t *testing.T) {
now := time.Now().UTC()
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: models.StateSnapshot{LastUpdate: now}})
h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{
records: []unified.IngestRecord{
{
SourceID: "system:truenas-main",
Resource: unified.Resource{
Type: unified.ResourceTypeAgent,
Name: "truenas-main",
Status: unified.StatusOnline,
LastSeen: now,
UpdatedAt: now,
},
Identity: unified.ResourceIdentity{
MachineID: "tn-main",
Hostnames: []string{"truenas-main"},
},
},
},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?source=truenas", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 truenas resource, got %d", len(resp.Data))
}
resource := resp.Data[0]
if resource.Type != "agent" {
t.Fatalf("resource type = %q, want %q", resource.Type, "agent")
}
if !containsSource(resource.Sources, unified.SourceTrueNAS) {
t.Fatalf("expected truenas source, got %+v", resource.Sources)
}
}
func TestResourceListUnifiedSeedSkipsSupplementalReingest(t *testing.T) {
now := time.Now().UTC()
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceUnifiedSeedProvider{
snapshot: models.StateSnapshot{LastUpdate: now},
resources: []unified.Resource{
{
ID: "agent-truenas-seeded",
Type: unified.ResourceTypeAgent,
Name: "truenas-main",
Status: unified.StatusOnline,
LastSeen: now,
UpdatedAt: now,
Sources: []unified.DataSource{unified.SourceTrueNAS},
Identity: unified.ResourceIdentity{
MachineID: "tn-main",
Hostnames: []string{"truenas-main"},
},
},
},
})
h.SetSupplementalRecordsProvider(unified.SourceTrueNAS, mockSupplementalRecordsProvider{
records: []unified.IngestRecord{
{
SourceID: "system:truenas-main",
Resource: unified.Resource{
Type: unified.ResourceTypeAgent,
Name: "truenas-main-duplicate",
Status: unified.StatusOnline,
LastSeen: now,
UpdatedAt: now,
},
Identity: unified.ResourceIdentity{
MachineID: "tn-main-duplicate",
Hostnames: []string{"truenas-main-duplicate"},
},
},
},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?source=truenas", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected unified seed to avoid duplicate supplemental ingest, got %d resources", len(resp.Data))
}
if got := resp.Data[0].Name; got != "truenas-main" {
t.Fatalf("resource name = %q, want truenas-main", got)
}
}
func TestResourceListUnifiedSeedIngestsOwnedSupplementalWhenSourceMissing(t *testing.T) {
now := time.Now().UTC()
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceUnifiedSeedProvider{
snapshot: models.StateSnapshot{LastUpdate: now},
resources: []unified.Resource{
{
ID: "agent-generic-seeded",
Type: unified.ResourceTypeAgent,
Name: "generic-host",
Status: unified.StatusOnline,
LastSeen: now,
UpdatedAt: now,
Sources: []unified.DataSource{unified.SourceAgent},
Identity: unified.ResourceIdentity{
MachineID: "generic-host",
Hostnames: []string{"generic-host"},
},
},
},
})
h.SetSupplementalRecordsProvider(unified.SourceVMware, mockSupplementalRecordsProvider{
ownedSources: []unified.DataSource{unified.SourceVMware},
records: []unified.IngestRecord{
{
SourceID: "vmware:host-1",
Resource: unified.Resource{
Type: unified.ResourceTypeAgent,
Name: "esxi-01.lab.local",
Status: unified.StatusOnline,
LastSeen: now,
UpdatedAt: now,
},
Identity: unified.ResourceIdentity{
MachineID: "esxi-01",
Hostnames: []string{"esxi-01.lab.local"},
},
},
},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?source=vmware-vsphere", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 vmware resource from owned supplemental provider, got %d", len(resp.Data))
}
if got := resp.Data[0].Name; got != "esxi-01.lab.local" {
t.Fatalf("resource name = %q, want esxi-01.lab.local", got)
}
if !containsSource(resp.Data[0].Sources, unified.SourceVMware) {
t.Fatalf("expected vmware source, got %+v", resp.Data[0].Sources)
}
}
func TestResourceListSupplementalOwnerSuppressesSnapshotSource(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
LastUpdate: now,
Hosts: []models.Host{
{
ID: "host-snapshot-1",
Hostname: "snapshot-host",
Status: "online",
LastSeen: now,
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
h.SetSupplementalRecordsProvider(unified.SourceAgent, mockSupplementalRecordsProvider{
ownedSources: []unified.DataSource{unified.SourceAgent},
records: []unified.IngestRecord{
{
SourceID: "host-provider-1",
Resource: unified.Resource{
Type: unified.ResourceTypeAgent,
Name: "provider-host",
Status: unified.StatusOnline,
LastSeen: now,
UpdatedAt: now,
},
Identity: unified.ResourceIdentity{
MachineID: "provider-machine",
Hostnames: []string{"provider-host"},
},
},
},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/resources?source=agent", nil)
h.HandleListResources(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp ResourcesResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Data) != 1 {
t.Fatalf("expected 1 provider-owned resource, got %d", len(resp.Data))
}
if resp.Data[0].Name != "provider-host" {
t.Fatalf("resource name = %q, want provider-host", resp.Data[0].Name)
}
}
func TestResourceListWithoutSupplementalProvider(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
LastUpdate: now,
Hosts: []models.Host{
{
ID: "host-1",
Hostname: "agent-host",
Status: "online",
LastSeen: now,
},
},
}
cfg := &config.Config{DataPath: t.TempDir()}
h := NewResourceHandlers(cfg)
h.SetStateProvider(resourceStateProvider{snapshot: snapshot})
truenasRec := httptest.NewRecorder()
truenasReq := httptest.NewRequest(http.MethodGet, "/api/resources?source=truenas", nil)
h.HandleListResources(truenasRec, truenasReq)
if truenasRec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", truenasRec.Code, truenasRec.Body.String())
}
var truenasResp ResourcesResponse
if err := json.NewDecoder(truenasRec.Body).Decode(&truenasResp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(truenasResp.Data) != 0 {
t.Fatalf("expected 0 truenas resources without supplemental provider, got %d", len(truenasResp.Data))
}
agentRec := httptest.NewRecorder()
agentReq := httptest.NewRequest(http.MethodGet, "/api/resources?source=agent", nil)
h.HandleListResources(agentRec, agentReq)
if agentRec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", agentRec.Code, agentRec.Body.String())
}
var agentResp ResourcesResponse
if err := json.NewDecoder(agentRec.Body).Decode(&agentResp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(agentResp.Data) != 1 {
t.Fatalf("expected 1 agent resource, got %d", len(agentResp.Data))
}
}
func TestSupplementalSnapshotOwnedSources_TrueNASProviders(t *testing.T) {
sources := supplementalSnapshotOwnedSources(map[unified.DataSource]SupplementalRecordsProvider{
unified.SourceTrueNAS: mockSupplementalRecordsAdapter{source: unified.SourceTrueNAS},
}, "default")
if len(sources) != 1 || sources[0] != unified.SourceTrueNAS {
t.Fatalf("expected owned sources [%q], got %#v", unified.SourceTrueNAS, sources)
}
}
func TestParseSources_AcceptsVMwareAliases(t *testing.T) {
for _, raw := range []string{"vmware", "vmware-vsphere"} {
sources := parseSources(raw)
if len(sources) != 1 {
t.Fatalf("parseSources(%q) returned %d entries, want 1", raw, len(sources))
}
if _, ok := sources[unified.SourceVMware]; !ok {
t.Fatalf("parseSources(%q) did not normalize to vmware: %#v", raw, sources)
}
}
}