mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-20 10:52:08 +00:00
497390ff95
Scope TrueNAS child source IDs under the system source key so duplicate pool, dataset, app, VM, share, and disk names from different appliances do not merge. Use the resulting hierarchy for per-system TrueNAS overview counts and scoped mock metric targets.
4023 lines
132 KiB
Go
4023 lines
132 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/monitoring"
|
|
"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"
|
|
authpkg "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
|
)
|
|
|
|
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 (m mockSupplementalRecordsProvider) SupplementalRecords(*monitoring.Monitor, string) []unified.IngestRecord {
|
|
return m.GetCurrentRecords()
|
|
}
|
|
|
|
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 TestResourceListDerivesProxmoxWorkloadParentFromUnifiedSeed(t *testing.T) {
|
|
now := time.Date(2026, 5, 14, 10, 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: "agent-delly",
|
|
Type: unified.ResourceTypeAgent,
|
|
Name: "delly",
|
|
Status: unified.StatusOnline,
|
|
LastSeen: now,
|
|
Sources: []unified.DataSource{unified.SourceProxmox, unified.SourceAgent},
|
|
Identity: unified.ResourceIdentity{
|
|
MachineID: "machine-delly",
|
|
Hostnames: []string{"delly"},
|
|
},
|
|
Proxmox: &unified.ProxmoxData{
|
|
SourceID: "homelab-delly",
|
|
NodeName: "delly",
|
|
ClusterName: "homelab",
|
|
Instance: "delly",
|
|
},
|
|
Agent: &unified.AgentData{
|
|
AgentID: "agent-source-delly",
|
|
Hostname: "delly",
|
|
},
|
|
},
|
|
{
|
|
ID: "system-container-cloudflared",
|
|
Type: unified.ResourceTypeSystemContainer,
|
|
Name: "cloudflared",
|
|
Status: unified.StatusOnline,
|
|
LastSeen: now,
|
|
Sources: []unified.DataSource{unified.SourceProxmox},
|
|
Identity: unified.ResourceIdentity{Hostnames: []string{"cloudflared"}},
|
|
Proxmox: &unified.ProxmoxData{
|
|
SourceID: "delly:delly:104",
|
|
NodeName: "delly",
|
|
ClusterName: "homelab",
|
|
Instance: "delly",
|
|
VMID: 104,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/resources?type=system-container&q=cloudflared&page=1&limit=20", 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 cloudflared resource, got %d: %#v", len(resp.Data), resp.Data)
|
|
}
|
|
resource := resp.Data[0]
|
|
if resource.ParentID == nil || *resource.ParentID != "agent-delly" {
|
|
t.Fatalf("expected cloudflared parent agent-delly, got %+v", resource.ParentID)
|
|
}
|
|
if resource.ParentName != "delly" {
|
|
t.Fatalf("expected cloudflared parentName delly, got %q", resource.ParentName)
|
|
}
|
|
if resource.Proxmox == nil || resource.Proxmox.NodeName != "delly" || resource.Proxmox.ClusterName != "homelab" {
|
|
t.Fatalf("expected cloudflared Proxmox node metadata, got %+v", resource.Proxmox)
|
|
}
|
|
}
|
|
|
|
func TestStateEndpointDerivesProxmoxWorkloadParentFromSupplementalRecords(t *testing.T) {
|
|
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
|
hashedPassword, err := authpkg.HashPassword("password")
|
|
if err != nil {
|
|
t.Fatalf("hash password: %v", err)
|
|
}
|
|
dataPath := t.TempDir()
|
|
InitSessionStore(dataPath)
|
|
InitCSRFStore(dataPath)
|
|
cfg := &config.Config{
|
|
DataPath: dataPath,
|
|
AuthUser: "admin",
|
|
AuthPass: hashedPassword,
|
|
}
|
|
monitor, err := monitoring.New(cfg)
|
|
if err != nil {
|
|
t.Fatalf("new monitor: %v", err)
|
|
}
|
|
t.Cleanup(func() { monitor.Stop() })
|
|
|
|
monitor.SetResourceStore(unified.NewMonitorAdapter(nil))
|
|
monitor.SetSupplementalRecordsProvider(unified.SourceProxmox, mockSupplementalRecordsProvider{
|
|
records: []unified.IngestRecord{
|
|
{
|
|
SourceID: "homelab-delly",
|
|
Resource: unified.Resource{
|
|
Type: unified.ResourceTypeAgent,
|
|
Name: "delly",
|
|
Status: unified.StatusOnline,
|
|
LastSeen: now,
|
|
Proxmox: &unified.ProxmoxData{
|
|
SourceID: "homelab-delly",
|
|
NodeName: "delly",
|
|
ClusterName: "homelab",
|
|
Instance: "delly",
|
|
},
|
|
},
|
|
Identity: unified.ResourceIdentity{
|
|
MachineID: "machine-delly",
|
|
Hostnames: []string{"delly"},
|
|
},
|
|
},
|
|
{
|
|
SourceID: "delly:delly:104",
|
|
Resource: unified.Resource{
|
|
Type: unified.ResourceTypeSystemContainer,
|
|
Name: "cloudflared",
|
|
Status: unified.StatusOnline,
|
|
LastSeen: now,
|
|
Proxmox: &unified.ProxmoxData{
|
|
SourceID: "delly:delly:104",
|
|
NodeName: "delly",
|
|
ClusterName: "homelab",
|
|
Instance: "delly",
|
|
VMID: 104,
|
|
},
|
|
},
|
|
Identity: unified.ResourceIdentity{Hostnames: []string{"cloudflared"}},
|
|
},
|
|
},
|
|
})
|
|
|
|
router := &Router{config: cfg, monitor: monitor}
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
|
|
req.SetBasicAuth("admin", "password")
|
|
router.handleState(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("/api/state status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var state models.StateFrontend
|
|
if err := json.NewDecoder(rec.Body).Decode(&state); err != nil {
|
|
t.Fatalf("decode /api/state: %v", err)
|
|
}
|
|
|
|
dellyCount := 0
|
|
dellyID := ""
|
|
cloudflaredParentID := ""
|
|
for _, resource := range state.Resources {
|
|
switch {
|
|
case resource.Type == string(unified.ResourceTypeAgent) && resource.Name == "delly":
|
|
dellyCount++
|
|
dellyID = resource.ID
|
|
case resource.Type == string(unified.ResourceTypeSystemContainer) && resource.Name == "cloudflared":
|
|
cloudflaredParentID = resource.ParentID
|
|
}
|
|
}
|
|
|
|
if dellyCount != 1 {
|
|
t.Fatalf("expected exactly one delly resource in /api/state, got %d: %#v", dellyCount, state.Resources)
|
|
}
|
|
if dellyID == "" {
|
|
t.Fatalf("expected delly resource id in /api/state: %#v", state.Resources)
|
|
}
|
|
if cloudflaredParentID != dellyID {
|
|
t.Fatalf("expected cloudflared parent %q, got %q: %#v", dellyID, cloudflaredParentID, state.Resources)
|
|
}
|
|
}
|
|
|
|
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",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
node := unified.Resource{
|
|
ID: "node-1",
|
|
Type: unified.ResourceTypeAgent,
|
|
Name: "pve-node-1",
|
|
Status: unified.StatusOnline,
|
|
LastSeen: now,
|
|
}
|
|
|
|
cfg := &config.Config{DataPath: t.TempDir()}
|
|
h := NewResourceHandlers(cfg)
|
|
h.SetStateProvider(resourceUnifiedSeedProvider{
|
|
snapshot: models.StateSnapshot{LastUpdate: now},
|
|
resources: []unified.Resource{resource, node},
|
|
})
|
|
|
|
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)
|
|
}
|
|
}
|
|
if err := store.RecordChange(unified.ResourceChange{
|
|
ID: "chg-node-direct",
|
|
ResourceID: "node-1",
|
|
ObservedAt: now.Add(-30 * time.Second),
|
|
Kind: unified.ChangeStateTransition,
|
|
SourceType: unified.SourcePulseDiff,
|
|
SourceAdapter: unified.AdapterProxmox,
|
|
Confidence: unified.ConfidenceMedium,
|
|
Reason: "node state refreshed",
|
|
}); err != nil {
|
|
t.Fatalf("RecordChange node direct: %v", 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"`
|
|
Capabilities []unified.ResourceCapability `json:"capabilities"`
|
|
Relationships []unified.ResourceRelationship `json:"relationships"`
|
|
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 len(payload.Capabilities) != 1 || payload.Capabilities[0].Name != "restart" {
|
|
t.Fatalf("unexpected facets capabilities: %#v", payload.Capabilities)
|
|
}
|
|
if len(payload.Relationships) != 1 || payload.Relationships[0].TargetID != "node-1" {
|
|
t.Fatalf("unexpected facets relationships: %#v", payload.Relationships)
|
|
}
|
|
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("relationship-aware timeline", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/resources/node-1/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 relationship-aware timeline: %v", err)
|
|
}
|
|
if payload.ResourceID != "node-1" || payload.Count != 4 || len(payload.RecentChanges) != 4 {
|
|
t.Fatalf("unexpected relationship-aware timeline payload: %#v", payload)
|
|
}
|
|
if payload.RecentChanges[0].ID != "chg-42" || payload.RecentChanges[1].ID != "chg-node-direct" {
|
|
t.Fatalf("relationship-aware timeline order = %#v, want related vm event then direct node event", payload.RecentChanges)
|
|
}
|
|
if got := payload.RecentChanges[0].ResourceID; got != "vm:42" {
|
|
t.Fatalf("related timeline preserves originating resource ID = %q, want vm:42", 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("relationship-aware filtered facets", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/resources/node-1/facets?kind=restart&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"`
|
|
Counts struct {
|
|
RecentChanges int `json:"recentChanges"`
|
|
RecentChangeKinds map[unified.ChangeKind]int `json:"recentChangeKinds"`
|
|
} `json:"counts"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode relationship-aware filtered facets: %v", err)
|
|
}
|
|
if payload.ResourceID != "node-1" || payload.Counts.RecentChanges != 1 || len(payload.RecentChanges) != 1 {
|
|
t.Fatalf("unexpected relationship-aware filtered facets payload: %#v", payload)
|
|
}
|
|
if payload.RecentChanges[0].ID != "chg-42" {
|
|
t.Fatalf("unexpected relationship-aware filtered facets change: %#v", payload.RecentChanges[0])
|
|
}
|
|
if got := payload.Counts.RecentChangeKinds[unified.ChangeRestart]; got != 1 {
|
|
t.Fatalf("relationship-aware restart facet count = %d, want 1", got)
|
|
}
|
|
})
|
|
|
|
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 TestBuildDiscoveryTargetDockerAppContainerUsesAgentAndContainerName(t *testing.T) {
|
|
target := buildDiscoveryTarget(unified.Resource{
|
|
ID: "app-container:source:abc123",
|
|
Type: unified.ResourceTypeAppContainer,
|
|
Name: "customer-portal",
|
|
Docker: &unified.DockerData{
|
|
AgentID: "agent-edge-01",
|
|
ContainerID: "abc123def456",
|
|
Hostname: "edge-apps-01",
|
|
},
|
|
})
|
|
|
|
if target == nil {
|
|
t.Fatal("expected discovery target")
|
|
}
|
|
if target.ResourceType != "app-container" {
|
|
t.Fatalf("resource type = %q, want app-container", target.ResourceType)
|
|
}
|
|
if target.AgentID != "agent-edge-01" {
|
|
t.Fatalf("agentID = %q, want agent-edge-01", target.AgentID)
|
|
}
|
|
if target.ResourceID != "customer-portal" {
|
|
t.Fatalf("resourceID = %q, want customer-portal", target.ResourceID)
|
|
}
|
|
if target.Hostname != "edge-apps-01" {
|
|
t.Fatalf("hostname = %q, want edge-apps-01", target.Hostname)
|
|
}
|
|
}
|
|
|
|
func TestBuildDiscoveryTargetDockerHostUsesAgentID(t *testing.T) {
|
|
target := buildDiscoveryTarget(unified.Resource{
|
|
ID: "agent:docker-host",
|
|
Type: unified.ResourceTypeAgent,
|
|
Name: "Edge Apps 01",
|
|
Docker: &unified.DockerData{
|
|
AgentID: "agent-edge-01",
|
|
Hostname: "edge-apps-01",
|
|
},
|
|
})
|
|
|
|
if target == nil {
|
|
t.Fatal("expected discovery target")
|
|
}
|
|
if target.ResourceType != "agent" {
|
|
t.Fatalf("resource type = %q, want agent", target.ResourceType)
|
|
}
|
|
if target.AgentID != "agent-edge-01" || target.ResourceID != "agent-edge-01" {
|
|
t.Fatalf("target = %+v, want agent-edge-01/agent-edge-01", target)
|
|
}
|
|
}
|
|
|
|
func TestK8sNamespacesEndpointAggregatesPodsAndDeployments(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
snapshot := models.StateSnapshot{
|
|
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: "resources_policy_sensitivity", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "policyPosture", "sensitivityCounts"}},
|
|
{name: "resources_policy_routing", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "policyPosture", "routingCounts"}},
|
|
{name: "resources_policy_redaction", raw: EmptyResourcesResponse(), keys: []string{"aggregations", "policyPosture", "redactionCounts"}},
|
|
{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 TestPBSResourceListIncludesProtectedWorkloadRollup(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,
|
|
},
|
|
},
|
|
JobHealthEvidence: []models.PBSJobHealthEvidence{{
|
|
ID: "sync-remote-a",
|
|
Family: "sync",
|
|
Store: "backup-store",
|
|
LastRunState: "OK",
|
|
LastRunUPID: "UPID:sync:1",
|
|
LastRunEndtime: now.Add(-time.Hour).Unix(),
|
|
NextRun: now.Add(time.Hour).Unix(),
|
|
Confidence: "direct-task-match",
|
|
EvidenceSource: "pbs-job-config",
|
|
EvidenceScope: "configured-job",
|
|
Freshness: models.PBSJobHealthFreshness{
|
|
ObservedAt: now,
|
|
LastRunEndTime: now.Add(-time.Hour),
|
|
NextRun: now.Add(time.Hour),
|
|
State: "observed",
|
|
},
|
|
Posture: "healthy",
|
|
}},
|
|
},
|
|
},
|
|
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)
|
|
}
|
|
if pbs.PBS.JobHealthEvidenceCount != 1 || len(pbs.PBS.JobHealthEvidence) != 1 {
|
|
t.Fatalf("expected PBS job health evidence in API payload, got %+v", pbs.PBS)
|
|
}
|
|
if got := pbs.PBS.JobHealthEvidence[0]; got.Confidence != "direct-task-match" || got.EvidenceSource != "pbs-job-config" || got.EvidenceScope != "configured-job" || got.LastRunState != "OK" || got.LastRunUPID != "UPID:sync:1" {
|
|
t.Fatalf("expected API payload to preserve PBS job evidence, got %+v", 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 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 != "system:truenas-main/app:nextcloud" {
|
|
t.Fatalf("expected canonical TrueNAS app metrics target system:truenas-main/app:nextcloud, got %+v", nextcloud.MetricsTarget)
|
|
}
|
|
}
|
|
|
|
func TestResourceListIncludesTrueNASVMsAsCanonicalWorkloads(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=vm&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.VMs) {
|
|
t.Fatalf("expected %d TrueNAS VM resources, got %d", len(fixtures.VMs), len(resp.Data))
|
|
}
|
|
|
|
var windows *unified.Resource
|
|
for i := range resp.Data {
|
|
resource := &resp.Data[i]
|
|
if resource.Name == "windows-lab" {
|
|
windows = resource
|
|
break
|
|
}
|
|
}
|
|
if windows == nil {
|
|
t.Fatalf("expected windows-lab in TrueNAS VM response, got %+v", resp.Data)
|
|
}
|
|
if !containsSource(windows.Sources, unified.SourceTrueNAS) {
|
|
t.Fatalf("expected TrueNAS source, got %+v", windows.Sources)
|
|
}
|
|
if windows.ParentName != "truenas-main" {
|
|
t.Fatalf("expected windows-lab parentName truenas-main, got %q", windows.ParentName)
|
|
}
|
|
if windows.TrueNAS == nil || windows.TrueNAS.VM == nil {
|
|
t.Fatalf("expected native VM payload on TrueNAS VM resource, got %+v", windows)
|
|
}
|
|
if windows.TrueNAS.VM.ID != "42" || windows.TrueNAS.VM.VCPUs != 4 || windows.TrueNAS.VM.MemoryBytes != 8*1024*1024*1024 {
|
|
t.Fatalf("unexpected native TrueNAS VM payload: %+v", windows.TrueNAS.VM)
|
|
}
|
|
if windows.MetricsTarget == nil || windows.MetricsTarget.ResourceType != "vm" || windows.MetricsTarget.ResourceID != "system:truenas-main/vm:42" {
|
|
t.Fatalf("expected canonical TrueNAS VM metrics target system:truenas-main/vm:42, got %+v", windows.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)
|
|
}
|
|
}
|
|
}
|