mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
853 lines
28 KiB
Go
853 lines
28 KiB
Go
package ai
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/baseline"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
)
|
|
|
|
func TestTriageThresholdChecks(t *testing.T) {
|
|
state := patrolRuntimeState{
|
|
Nodes: []models.Node{
|
|
{
|
|
ID: "node/pve1",
|
|
Name: "pve1",
|
|
CPU: 0.92, // CPU is 0-1 scale (92%)
|
|
Memory: models.Memory{Usage: 30.0}, // Memory.Usage is already percent
|
|
},
|
|
},
|
|
}
|
|
|
|
flags := triageThresholdChecksState(state, nil, DefaultPatrolThresholds())
|
|
if len(flags) == 0 {
|
|
t.Fatalf("expected threshold flags, got none")
|
|
}
|
|
|
|
flag := triageFindFlag(flags, func(f TriageFlag) bool {
|
|
return f.ResourceID == "node/pve1" && f.Metric == "cpu"
|
|
})
|
|
if flag == nil {
|
|
t.Fatalf("expected CPU flag for node/pve1")
|
|
}
|
|
if flag.Severity != "warning" {
|
|
t.Fatalf("expected warning severity, got %q", flag.Severity)
|
|
}
|
|
}
|
|
|
|
func TestTriageThresholdChecksState_UsesReadStateWhenLegacySlicesEmpty(t *testing.T) {
|
|
state := patrolRuntimeState{
|
|
readState: &mockReadState{
|
|
nodes: []*unifiedresources.NodeView{
|
|
func() *unifiedresources.NodeView {
|
|
node := unifiedresources.NewNodeView(&unifiedresources.Resource{
|
|
ID: "node/pve1",
|
|
Name: "pve1",
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Status: unifiedresources.StatusOnline,
|
|
Proxmox: &unifiedresources.ProxmoxData{
|
|
NodeName: "pve1",
|
|
},
|
|
Metrics: &unifiedresources.ResourceMetrics{
|
|
CPU: &unifiedresources.MetricValue{Percent: 92},
|
|
Memory: &unifiedresources.MetricValue{Percent: 30},
|
|
},
|
|
})
|
|
return &node
|
|
}(),
|
|
},
|
|
vms: []*unifiedresources.VMView{
|
|
func() *unifiedresources.VMView {
|
|
vm := unifiedresources.NewVMView(&unifiedresources.Resource{
|
|
ID: "qemu/100",
|
|
Name: "vm-1",
|
|
Type: unifiedresources.ResourceTypeVM,
|
|
Status: "running",
|
|
Proxmox: &unifiedresources.ProxmoxData{
|
|
SourceID: "qemu/100",
|
|
VMID: 100,
|
|
NodeName: "pve1",
|
|
},
|
|
Metrics: &unifiedresources.ResourceMetrics{
|
|
CPU: &unifiedresources.MetricValue{Percent: 70},
|
|
Memory: &unifiedresources.MetricValue{Percent: 92},
|
|
Disk: &unifiedresources.MetricValue{Percent: 85},
|
|
},
|
|
})
|
|
return &vm
|
|
}(),
|
|
},
|
|
storage: []*unifiedresources.StoragePoolView{
|
|
func() *unifiedresources.StoragePoolView {
|
|
storage := unifiedresources.NewStoragePoolView(&unifiedresources.Resource{
|
|
ID: "storage/local",
|
|
Name: "local",
|
|
Type: unifiedresources.ResourceTypeStorage,
|
|
Status: unifiedresources.StatusOnline,
|
|
Metrics: &unifiedresources.ResourceMetrics{
|
|
Disk: &unifiedresources.MetricValue{Percent: 96},
|
|
},
|
|
})
|
|
return &storage
|
|
}(),
|
|
},
|
|
},
|
|
}
|
|
|
|
flags := triageThresholdChecksState(state, nil, DefaultPatrolThresholds())
|
|
if len(flags) == 0 {
|
|
t.Fatalf("expected threshold flags from readState, got none")
|
|
}
|
|
|
|
if triageFindFlag(flags, func(f TriageFlag) bool { return f.ResourceID == "node/pve1" && f.Metric == "cpu" }) == nil {
|
|
t.Fatalf("expected node CPU flag from readState")
|
|
}
|
|
if triageFindFlag(flags, func(f TriageFlag) bool { return f.ResourceID == "qemu/100" && f.Metric == "memory" }) == nil {
|
|
t.Fatalf("expected VM memory flag from readState")
|
|
}
|
|
if triageFindFlag(flags, func(f TriageFlag) bool { return f.ResourceID == "storage/local" && f.Metric == "usage" }) == nil {
|
|
t.Fatalf("expected storage usage flag from readState")
|
|
}
|
|
}
|
|
|
|
func TestTriageAnomalyChecks(t *testing.T) {
|
|
intel := seedIntelligence{
|
|
anomalies: []baseline.AnomalyReport{
|
|
{
|
|
ResourceID: "qemu/100",
|
|
ResourceName: "web-01",
|
|
ResourceType: "vm",
|
|
Metric: "cpu",
|
|
CurrentValue: 0.65,
|
|
BaselineMean: 0.22,
|
|
ZScore: 3.2,
|
|
Severity: baseline.AnomalyHigh,
|
|
},
|
|
{
|
|
ResourceID: "lxc/200",
|
|
ResourceName: "worker-ct",
|
|
ResourceType: "system-container",
|
|
Metric: "memory",
|
|
CurrentValue: 0.82,
|
|
BaselineMean: 0.62,
|
|
ZScore: 2.7,
|
|
Severity: baseline.AnomalyMedium,
|
|
},
|
|
{
|
|
ResourceID: "node/pve2",
|
|
ResourceName: "pve2",
|
|
ResourceType: "node",
|
|
Metric: "cpu",
|
|
CurrentValue: 0.51,
|
|
BaselineMean: 0.40,
|
|
ZScore: 2.2,
|
|
Severity: baseline.AnomalyLow,
|
|
},
|
|
},
|
|
forecasts: []seedForecast{
|
|
{name: "local", resourceID: "storage/local", metric: "disk", daysToFull: 12, dailyChange: 2.1, current: 91},
|
|
{name: "fast", resourceID: "storage/fast", metric: "disk", daysToFull: 5, dailyChange: 4.3, current: 95},
|
|
},
|
|
}
|
|
|
|
flags := triageAnomalyChecks(intel)
|
|
if len(flags) != 4 {
|
|
t.Fatalf("expected 4 anomaly/capacity flags, got %d", len(flags))
|
|
}
|
|
|
|
high := triageFindFlag(flags, func(f TriageFlag) bool {
|
|
return f.ResourceID == "qemu/100" && f.Category == "anomaly"
|
|
})
|
|
if high == nil || high.Severity != "warning" {
|
|
t.Fatalf("expected high anomaly warning for qemu/100, got %#v", high)
|
|
}
|
|
|
|
medium := triageFindFlag(flags, func(f TriageFlag) bool {
|
|
return f.ResourceID == "lxc/200" && f.Category == "anomaly"
|
|
})
|
|
if medium == nil || medium.Severity != "watch" {
|
|
t.Fatalf("expected medium anomaly watch for lxc/200, got %#v", medium)
|
|
}
|
|
}
|
|
|
|
func TestTriageBackupChecks(t *testing.T) {
|
|
oldBackup := time.Now().Add(-72 * time.Hour)
|
|
state := patrolRuntimeState{
|
|
VMs: []models.VM{
|
|
{ID: "qemu/100", Name: "vm-never", Status: "running", Template: false, LastBackup: time.Time{}},
|
|
},
|
|
Containers: []models.Container{
|
|
{ID: "lxc/200", Name: "ct-stale", Status: "running", Template: false, LastBackup: oldBackup},
|
|
},
|
|
}
|
|
|
|
flags := triageBackupChecksState(state, nil)
|
|
if len(flags) != 2 {
|
|
t.Fatalf("expected 2 backup flags, got %d", len(flags))
|
|
}
|
|
|
|
never := triageFindFlag(flags, func(f TriageFlag) bool { return f.ResourceID == "qemu/100" })
|
|
if never == nil || !strings.Contains(never.Reason, "Never backed up") {
|
|
t.Fatalf("expected never-backed-up flag for qemu/100, got %#v", never)
|
|
}
|
|
|
|
stale := triageFindFlag(flags, func(f TriageFlag) bool { return f.ResourceID == "lxc/200" })
|
|
if stale == nil || !strings.Contains(stale.Reason, "threshold: 48h") {
|
|
t.Fatalf("expected stale-backup flag for lxc/200, got %#v", stale)
|
|
}
|
|
}
|
|
|
|
func TestTriageBackupChecksState_UsesReadStateWhenLegacySlicesEmpty(t *testing.T) {
|
|
oldBackup := time.Now().Add(-72 * time.Hour)
|
|
state := patrolRuntimeState{
|
|
readState: &mockReadState{
|
|
vms: []*unifiedresources.VMView{
|
|
func() *unifiedresources.VMView {
|
|
vm := unifiedresources.NewVMView(&unifiedresources.Resource{
|
|
ID: "reg-qemu/100",
|
|
Name: "vm-never",
|
|
Type: unifiedresources.ResourceTypeVM,
|
|
Status: "running",
|
|
Proxmox: &unifiedresources.ProxmoxData{
|
|
SourceID: "qemu/100",
|
|
VMID: 100,
|
|
NodeName: "pve1",
|
|
},
|
|
})
|
|
return &vm
|
|
}(),
|
|
},
|
|
containers: []*unifiedresources.ContainerView{
|
|
func() *unifiedresources.ContainerView {
|
|
ct := unifiedresources.NewContainerView(&unifiedresources.Resource{
|
|
ID: "reg-lxc/200",
|
|
Name: "ct-stale",
|
|
Type: unifiedresources.ResourceTypeSystemContainer,
|
|
Status: "running",
|
|
Proxmox: &unifiedresources.ProxmoxData{
|
|
SourceID: "lxc/200",
|
|
VMID: 200,
|
|
NodeName: "pve1",
|
|
LastBackup: oldBackup,
|
|
},
|
|
})
|
|
return &ct
|
|
}(),
|
|
},
|
|
},
|
|
}
|
|
|
|
flags := triageBackupChecksState(state, nil)
|
|
if len(flags) != 2 {
|
|
t.Fatalf("expected 2 backup flags from readState, got %d", len(flags))
|
|
}
|
|
|
|
never := triageFindFlag(flags, func(f TriageFlag) bool { return f.ResourceID == "reg-qemu/100" })
|
|
if never == nil || !strings.Contains(never.Reason, "Never backed up") {
|
|
t.Fatalf("expected never-backed-up readState flag, got %#v", never)
|
|
}
|
|
|
|
stale := triageFindFlag(flags, func(f TriageFlag) bool { return f.ResourceID == "reg-lxc/200" })
|
|
if stale == nil || !strings.Contains(stale.Reason, "threshold: 48h") {
|
|
t.Fatalf("expected stale readState backup flag, got %#v", stale)
|
|
}
|
|
}
|
|
|
|
func TestTriageDiskHealthChecks(t *testing.T) {
|
|
state := patrolRuntimeState{
|
|
PhysicalDisks: []models.PhysicalDisk{
|
|
{ID: "disk-1", DevPath: "/dev/sda", Health: "FAILED", Wearout: 50, Temperature: 40},
|
|
{ID: "disk-2", DevPath: "/dev/nvme0n1", Health: "PASSED", Wearout: 15, Temperature: 40},
|
|
{ID: "disk-3", DevPath: "/dev/sdb", Health: "PASSED", Wearout: -1, Temperature: 60},
|
|
{ID: "disk-4", DevPath: "/dev/sdc", Health: "PASSED", Wearout: 85, Temperature: 40},
|
|
},
|
|
}
|
|
|
|
flags := triageDiskHealthChecksState(state, nil)
|
|
if len(flags) != 3 {
|
|
t.Fatalf("expected 3 disk-health flags, got %d", len(flags))
|
|
}
|
|
|
|
if triageFindFlag(flags, func(f TriageFlag) bool {
|
|
return f.Severity == "critical" && strings.Contains(f.Reason, "Disk health reported FAILED")
|
|
}) == nil {
|
|
t.Fatalf("expected critical health flag for failed disk")
|
|
}
|
|
if triageFindFlag(flags, func(f TriageFlag) bool {
|
|
return f.Severity == "warning" && strings.Contains(f.Reason, "wearout at 15% remaining")
|
|
}) == nil {
|
|
t.Fatalf("expected wearout warning for 15%% remaining")
|
|
}
|
|
if triageFindFlag(flags, func(f TriageFlag) bool {
|
|
return f.Severity == "warning" && strings.Contains(f.Reason, "Disk temperature 60")
|
|
}) == nil {
|
|
t.Fatalf("expected temperature warning for 60C disk")
|
|
}
|
|
if triageFindFlag(flags, func(f TriageFlag) bool { return strings.Contains(f.Reason, "wearout at 85% remaining") }) != nil {
|
|
t.Fatalf("did not expect wearout flag for disk-4 (85%% remaining)")
|
|
}
|
|
}
|
|
|
|
func TestTriageDiskHealthChecksState_PrefersUnifiedProvider(t *testing.T) {
|
|
state := patrolRuntimeState{
|
|
unifiedResourceProvider: &mockUnifiedResourceProvider{
|
|
getByTypeFunc: func(t unifiedresources.ResourceType) []unifiedresources.Resource {
|
|
if t != unifiedresources.ResourceTypePhysicalDisk {
|
|
return nil
|
|
}
|
|
return []unifiedresources.Resource{
|
|
{
|
|
ID: "disk-1",
|
|
Name: "disk-1",
|
|
Type: unifiedresources.ResourceTypePhysicalDisk,
|
|
PhysicalDisk: &unifiedresources.PhysicalDiskMeta{
|
|
DevPath: "/dev/sda",
|
|
Model: "disk-1",
|
|
Health: "FAILED",
|
|
Wearout: 50,
|
|
Temperature: 40,
|
|
},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
flags := triageDiskHealthChecksState(state, nil)
|
|
if len(flags) != 1 {
|
|
t.Fatalf("expected 1 disk-health flag from unified provider, got %d", len(flags))
|
|
}
|
|
if flags[0].ResourceType != "physical_disk" {
|
|
t.Fatalf("expected unified-provider disk flag to use physical_disk, got %#v", flags[0])
|
|
}
|
|
if !strings.Contains(flags[0].Reason, "Disk health reported FAILED") {
|
|
t.Fatalf("expected unified-provider flag to use disk health reason, got %#v", flags[0])
|
|
}
|
|
}
|
|
|
|
func TestTriageDiskHealthChecksState_UsesPhysicalDiskFallback(t *testing.T) {
|
|
state := patrolRuntimeState{
|
|
PhysicalDisks: []models.PhysicalDisk{
|
|
{ID: "disk-1", DevPath: "/dev/sda", Health: "FAILED", Wearout: 50, Temperature: 40},
|
|
{ID: "disk-2", DevPath: "/dev/nvme0n1", Health: "PASSED", Wearout: 15, Temperature: 40},
|
|
{ID: "disk-3", DevPath: "/dev/sdb", Health: "PASSED", Wearout: -1, Temperature: 60},
|
|
},
|
|
}
|
|
|
|
flags := triageDiskHealthChecksState(state, nil)
|
|
if len(flags) != 3 {
|
|
t.Fatalf("expected 3 disk-health flags from physical-disk fallback, got %d", len(flags))
|
|
}
|
|
if triageFindFlag(flags, func(f TriageFlag) bool {
|
|
return f.ResourceID == "disk-1" && f.ResourceType == "physical_disk" && strings.Contains(f.Reason, "Disk health reported FAILED")
|
|
}) == nil {
|
|
t.Fatalf("expected failed health flag from physical-disk fallback, got %#v", flags)
|
|
}
|
|
if triageFindFlag(flags, func(f TriageFlag) bool {
|
|
return f.ResourceID == "disk-2" && f.ResourceType == "physical_disk" && strings.Contains(f.Reason, "wearout at 15% remaining")
|
|
}) == nil {
|
|
t.Fatalf("expected wearout flag from physical-disk fallback, got %#v", flags)
|
|
}
|
|
if triageFindFlag(flags, func(f TriageFlag) bool {
|
|
return f.ResourceID == "disk-3" && f.ResourceType == "physical_disk" && strings.Contains(f.Reason, "Disk temperature 60")
|
|
}) == nil {
|
|
t.Fatalf("expected temperature flag from physical-disk fallback, got %#v", flags)
|
|
}
|
|
}
|
|
|
|
func TestTriageAlertChecks(t *testing.T) {
|
|
state := patrolRuntimeState{
|
|
ActiveAlerts: []models.Alert{
|
|
{ResourceID: "qemu/100", ResourceName: "web-01", Type: "cpu", Level: "critical", Message: "CPU too high"},
|
|
{ResourceID: "storage/local", ResourceName: "local", Type: "backup", Level: "warning", Message: "Backup failed"},
|
|
{ResourceID: "node/pve1", ResourceName: "pve1", Type: "unknown", Level: "info", Message: ""},
|
|
},
|
|
}
|
|
|
|
flags := triageAlertChecksState(state, nil)
|
|
if len(flags) != 3 {
|
|
t.Fatalf("expected 3 alert flags, got %d", len(flags))
|
|
}
|
|
|
|
cpu := triageFindFlag(flags, func(f TriageFlag) bool { return f.ResourceID == "qemu/100" })
|
|
if cpu == nil || cpu.Severity != "critical" || cpu.Category != "performance" {
|
|
t.Fatalf("expected critical performance flag for qemu/100, got %#v", cpu)
|
|
}
|
|
|
|
backup := triageFindFlag(flags, func(f TriageFlag) bool { return f.ResourceID == "storage/local" })
|
|
if backup == nil || backup.Severity != "warning" || backup.Category != "backup" {
|
|
t.Fatalf("expected warning backup flag for storage/local, got %#v", backup)
|
|
}
|
|
|
|
unknown := triageFindFlag(flags, func(f TriageFlag) bool { return f.ResourceID == "node/pve1" })
|
|
if unknown == nil || unknown.Severity != "watch" || unknown.Category != "health" {
|
|
t.Fatalf("expected watch health flag for node/pve1, got %#v", unknown)
|
|
}
|
|
}
|
|
|
|
func TestTriageConnectivityChecks(t *testing.T) {
|
|
reachable := false
|
|
state := patrolRuntimeState{
|
|
ConnectionHealth: map[string]bool{
|
|
"pbs-backup1": false,
|
|
"node/pve1": true,
|
|
},
|
|
}
|
|
guestIntel := map[string]*GuestIntelligence{
|
|
"qemu/100": {Name: "web-01", GuestType: "vm", Reachable: &reachable},
|
|
"lxc/200": {Name: "worker-ct", GuestType: "lxc", Reachable: &reachable},
|
|
}
|
|
|
|
flags := triageConnectivityChecksState(state, guestIntel, nil)
|
|
if len(flags) != 3 {
|
|
t.Fatalf("expected 3 connectivity flags, got %d", len(flags))
|
|
}
|
|
|
|
if triageFindFlag(flags, func(f TriageFlag) bool { return f.ResourceID == "pbs-backup1" && f.Severity == "critical" }) == nil {
|
|
t.Fatalf("expected critical disconnected flag for pbs-backup1")
|
|
}
|
|
if triageFindFlag(flags, func(f TriageFlag) bool { return f.ResourceID == "qemu/100" && f.Severity == "warning" }) == nil {
|
|
t.Fatalf("expected warning unreachable flag for qemu/100")
|
|
}
|
|
ctFlag := triageFindFlag(flags, func(f TriageFlag) bool { return f.ResourceID == "lxc/200" && f.Severity == "warning" })
|
|
if ctFlag == nil || ctFlag.ResourceType != "system-container" {
|
|
t.Fatalf("expected system-container unreachable flag for lxc/200, got %#v", ctFlag)
|
|
}
|
|
}
|
|
|
|
func TestTriageResourceTypeRejectsLegacyKnownTypeAliases(t *testing.T) {
|
|
if got := triageResourceType("host", "qemu/100"); got != "vm" {
|
|
t.Fatalf("expected canonical vm type from resource ID when host alias is provided, got %q", got)
|
|
}
|
|
if got := triageResourceType("container", "qemu/100"); got != "vm" {
|
|
t.Fatalf("expected canonical vm type from resource ID, got %q", got)
|
|
}
|
|
if got := triageResourceType("lxc", "qemu/100"); got != "vm" {
|
|
t.Fatalf("expected canonical vm type from resource ID, got %q", got)
|
|
}
|
|
if got := triageResourceType("docker", "qemu/100"); got != "vm" {
|
|
t.Fatalf("expected canonical vm type from resource ID when docker alias is provided, got %q", got)
|
|
}
|
|
if got := triageResourceType("physical-disk", "disk-1"); got != "physical_disk" {
|
|
t.Fatalf("expected physical-disk alias to normalize to canonical physical_disk, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestTriageResourceTypeCanonicalResourceIDs(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
resourceID string
|
|
want string
|
|
}{
|
|
{name: "vm canonical id", resourceID: "vm:pve1:100", want: "vm"},
|
|
{name: "system container canonical id", resourceID: "system-container:pve1:200", want: "system-container"},
|
|
{name: "oci container canonical id", resourceID: "oci-container:pve1:201", want: "system-container"},
|
|
{name: "node canonical id", resourceID: "node:pve1", want: "node"},
|
|
{name: "app container canonical id", resourceID: "app-container:docker-host-1:abc123", want: "docker-host"},
|
|
{name: "docker host canonical id", resourceID: "docker-host:edge-1", want: "docker-host"},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := triageResourceType("", tc.resourceID); got != tc.want {
|
|
t.Fatalf("triageResourceType(%q) = %q, want %q", tc.resourceID, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTriageQuietInfra(t *testing.T) {
|
|
p := &PatrolService{
|
|
findings: NewFindingsStore(),
|
|
thresholds: DefaultPatrolThresholds(),
|
|
}
|
|
|
|
triage := p.runDeterministicTriageState(context.Background(), patrolRuntimeStateForTest(p, models.StateSnapshot{}), nil, nil)
|
|
if triage == nil {
|
|
t.Fatalf("expected triage result, got nil")
|
|
}
|
|
if !triage.IsQuiet {
|
|
t.Fatalf("expected quiet infrastructure state")
|
|
}
|
|
if len(triage.Flags) != 0 {
|
|
t.Fatalf("expected no flags, got %d", len(triage.Flags))
|
|
}
|
|
}
|
|
|
|
func TestTriageNotQuietWithActiveFindings(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
store.Add(&Finding{
|
|
ID: "finding-1",
|
|
Severity: FindingSeverityWarning,
|
|
Category: FindingCategoryGeneral,
|
|
ResourceID: "node/pve1",
|
|
ResourceName: "pve1",
|
|
ResourceType: "node",
|
|
Title: "Test finding",
|
|
Description: "test",
|
|
})
|
|
|
|
p := &PatrolService{
|
|
findings: store,
|
|
thresholds: DefaultPatrolThresholds(),
|
|
}
|
|
|
|
triage := p.runDeterministicTriageState(context.Background(), patrolRuntimeStateForTest(p, models.StateSnapshot{}), nil, nil)
|
|
if triage == nil {
|
|
t.Fatalf("expected triage result, got nil")
|
|
}
|
|
if triage.IsQuiet {
|
|
t.Fatalf("expected non-quiet state when active findings exist")
|
|
}
|
|
if len(triage.Flags) != 0 {
|
|
t.Fatalf("expected no deterministic flags, got %d", len(triage.Flags))
|
|
}
|
|
}
|
|
|
|
func TestTriageDeduplication(t *testing.T) {
|
|
p := &PatrolService{
|
|
findings: NewFindingsStore(),
|
|
thresholds: DefaultPatrolThresholds(),
|
|
}
|
|
|
|
state := models.StateSnapshot{
|
|
VMs: []models.VM{
|
|
{
|
|
ID: "qemu/100",
|
|
Name: "web-01",
|
|
Status: "running",
|
|
Template: false,
|
|
Disk: models.Disk{Usage: 95.0}, // already percent
|
|
LastBackup: time.Now(),
|
|
},
|
|
},
|
|
ActiveAlerts: []models.Alert{
|
|
{
|
|
ResourceID: "qemu/100",
|
|
ResourceName: "web-01",
|
|
Type: "disk",
|
|
Level: "warning",
|
|
Message: "Disk alert",
|
|
},
|
|
},
|
|
}
|
|
|
|
triage := p.runDeterministicTriageState(context.Background(), patrolRuntimeStateForTest(p, state), nil, nil)
|
|
if triage == nil {
|
|
t.Fatalf("expected triage result, got nil")
|
|
}
|
|
|
|
capacityFlags := triageFilterFlags(triage.Flags, func(f TriageFlag) bool {
|
|
return f.ResourceID == "qemu/100" && f.Category == "capacity"
|
|
})
|
|
if len(capacityFlags) != 1 {
|
|
t.Fatalf("expected 1 deduplicated capacity flag for qemu/100, got %d", len(capacityFlags))
|
|
}
|
|
if capacityFlags[0].Severity != "critical" {
|
|
t.Fatalf("expected deduplicated severity critical, got %q", capacityFlags[0].Severity)
|
|
}
|
|
}
|
|
|
|
func TestTriageDeduplicationDistinctMetrics(t *testing.T) {
|
|
p := &PatrolService{
|
|
findings: NewFindingsStore(),
|
|
thresholds: DefaultPatrolThresholds(),
|
|
}
|
|
|
|
// Node with both CPU and memory above thresholds — should produce TWO performance flags.
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{
|
|
{
|
|
ID: "node/pve1",
|
|
Name: "pve1",
|
|
CPU: 0.92, // CPU is 0-1 scale (92%)
|
|
Memory: models.Memory{Usage: 92.0}, // already percent
|
|
},
|
|
},
|
|
}
|
|
|
|
triage := p.runDeterministicTriageState(context.Background(), patrolRuntimeStateForTest(p, state), nil, nil)
|
|
if triage == nil {
|
|
t.Fatalf("expected triage result, got nil")
|
|
}
|
|
|
|
perfFlags := triageFilterFlags(triage.Flags, func(f TriageFlag) bool {
|
|
return f.ResourceID == "node/pve1" && f.Category == "performance"
|
|
})
|
|
if len(perfFlags) != 2 {
|
|
t.Fatalf("expected 2 distinct performance flags (cpu + memory), got %d", len(perfFlags))
|
|
}
|
|
|
|
cpuFlag := triageFindFlag(perfFlags, func(f TriageFlag) bool { return f.Metric == "cpu" })
|
|
memFlag := triageFindFlag(perfFlags, func(f TriageFlag) bool { return f.Metric == "memory" })
|
|
if cpuFlag == nil {
|
|
t.Fatal("expected CPU performance flag")
|
|
}
|
|
if memFlag == nil {
|
|
t.Fatal("expected memory performance flag")
|
|
}
|
|
}
|
|
|
|
func TestFormatTriageBriefing(t *testing.T) {
|
|
triage := &TriageResult{
|
|
Flags: []TriageFlag{
|
|
{
|
|
ResourceID: "qemu/100",
|
|
ResourceName: "web-01",
|
|
ResourceType: "vm",
|
|
Category: "performance",
|
|
Severity: "warning",
|
|
Reason: "Memory at 92% (threshold: 88%)",
|
|
Metric: "memory",
|
|
},
|
|
},
|
|
Summary: TriageSummary{
|
|
TotalNodes: 1,
|
|
TotalGuests: 2,
|
|
RunningGuests: 2,
|
|
StoppedGuests: 0,
|
|
TotalStoragePools: 1,
|
|
TotalPhysicalDisks: 0,
|
|
TotalStorage: 1,
|
|
TotalDocker: 1,
|
|
TotalTrueNAS: 1,
|
|
TotalPBS: 1,
|
|
TotalPMG: 0,
|
|
FlaggedCount: 1,
|
|
},
|
|
FlaggedIDs: map[string]bool{"qemu/100": true},
|
|
}
|
|
|
|
out := FormatTriageBriefing(triage)
|
|
if !strings.Contains(out, "# Deterministic Triage Results") {
|
|
t.Fatalf("expected title in output, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "| Resource | Type | Flag | Severity | Detail |") {
|
|
t.Fatalf("expected table header in output, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "web-01") || !strings.Contains(out, "High Memory") {
|
|
t.Fatalf("expected resource row in output, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "## Healthy Resources") {
|
|
t.Fatalf("expected healthy summary section, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "Scanned 7 resources: 1 nodes, 2 guests, 1 storage resources (1 pools, 0 physical disks), 1 docker hosts, 1 TrueNAS systems, 1 PBS, 0 PMG.") {
|
|
t.Fatalf("expected explicit storage breakdown in scanned summary, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "Storage: 1 resources monitored (1 pools, 0 physical disks)") {
|
|
t.Fatalf("expected storage resources wording in output, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestTriageBuildSummaryState_UsesReadStateWhenLegacySlicesEmpty(t *testing.T) {
|
|
state := patrolRuntimeState{
|
|
readState: &mockReadState{
|
|
nodes: []*unifiedresources.NodeView{
|
|
func() *unifiedresources.NodeView {
|
|
node := unifiedresources.NewNodeView(&unifiedresources.Resource{
|
|
ID: "node/pve1",
|
|
Name: "pve1",
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Status: unifiedresources.StatusOnline,
|
|
})
|
|
return &node
|
|
}(),
|
|
},
|
|
vms: []*unifiedresources.VMView{
|
|
func() *unifiedresources.VMView {
|
|
vm := unifiedresources.NewVMView(&unifiedresources.Resource{
|
|
ID: "qemu/100",
|
|
Name: "vm-run",
|
|
Type: unifiedresources.ResourceTypeVM,
|
|
Status: "running",
|
|
Proxmox: &unifiedresources.ProxmoxData{
|
|
SourceID: "qemu/100",
|
|
VMID: 100,
|
|
NodeName: "pve1",
|
|
},
|
|
})
|
|
return &vm
|
|
}(),
|
|
func() *unifiedresources.VMView {
|
|
vm := unifiedresources.NewVMView(&unifiedresources.Resource{
|
|
ID: "qemu/101",
|
|
Name: "vm-stop",
|
|
Type: unifiedresources.ResourceTypeVM,
|
|
Status: "stopped",
|
|
Proxmox: &unifiedresources.ProxmoxData{
|
|
SourceID: "qemu/101",
|
|
VMID: 101,
|
|
NodeName: "pve1",
|
|
},
|
|
})
|
|
return &vm
|
|
}(),
|
|
},
|
|
containers: []*unifiedresources.ContainerView{
|
|
func() *unifiedresources.ContainerView {
|
|
ct := unifiedresources.NewContainerView(&unifiedresources.Resource{
|
|
ID: "lxc/200",
|
|
Name: "ct-run",
|
|
Type: unifiedresources.ResourceTypeSystemContainer,
|
|
Status: "running",
|
|
Proxmox: &unifiedresources.ProxmoxData{
|
|
SourceID: "lxc/200",
|
|
VMID: 200,
|
|
NodeName: "pve1",
|
|
},
|
|
})
|
|
return &ct
|
|
}(),
|
|
},
|
|
storage: []*unifiedresources.StoragePoolView{
|
|
func() *unifiedresources.StoragePoolView {
|
|
storage := unifiedresources.NewStoragePoolView(&unifiedresources.Resource{
|
|
ID: "storage/local",
|
|
Name: "local",
|
|
Type: unifiedresources.ResourceTypeStorage,
|
|
Status: unifiedresources.StatusOnline,
|
|
})
|
|
return &storage
|
|
}(),
|
|
},
|
|
dockerHosts: []*unifiedresources.DockerHostView{
|
|
func() *unifiedresources.DockerHostView {
|
|
host := unifiedresources.NewDockerHostView(&unifiedresources.Resource{
|
|
ID: "docker-host-1",
|
|
Name: "docker-a",
|
|
Type: unifiedresources.ResourceTypeAppContainer,
|
|
Status: unifiedresources.StatusOnline,
|
|
})
|
|
return &host
|
|
}(),
|
|
},
|
|
hosts: []*unifiedresources.HostView{
|
|
func() *unifiedresources.HostView {
|
|
host := unifiedresources.NewHostView(&unifiedresources.Resource{
|
|
ID: "truenas-1",
|
|
Name: "truenas-main",
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Status: unifiedresources.StatusOnline,
|
|
Agent: &unifiedresources.AgentData{
|
|
Hostname: "truenas-main",
|
|
Platform: "truenas",
|
|
},
|
|
})
|
|
return &host
|
|
}(),
|
|
},
|
|
pbs: []*unifiedresources.PBSInstanceView{
|
|
func() *unifiedresources.PBSInstanceView {
|
|
pbs := unifiedresources.NewPBSInstanceView(&unifiedresources.Resource{
|
|
ID: "pbs-1",
|
|
Name: "pbs-a",
|
|
Type: unifiedresources.ResourceTypePBS,
|
|
Status: unifiedresources.StatusOnline,
|
|
})
|
|
return &pbs
|
|
}(),
|
|
},
|
|
pmg: []*unifiedresources.PMGInstanceView{
|
|
func() *unifiedresources.PMGInstanceView {
|
|
pmg := unifiedresources.NewPMGInstanceView(&unifiedresources.Resource{
|
|
ID: "pmg-1",
|
|
Name: "pmg-a",
|
|
Type: unifiedresources.ResourceTypePMG,
|
|
Status: unifiedresources.StatusOnline,
|
|
})
|
|
return &pmg
|
|
}(),
|
|
},
|
|
},
|
|
unifiedResourceProvider: &mockUnifiedResourceProvider{
|
|
getByTypeFunc: func(t unifiedresources.ResourceType) []unifiedresources.Resource {
|
|
if t != unifiedresources.ResourceTypePhysicalDisk {
|
|
return nil
|
|
}
|
|
return []unifiedresources.Resource{
|
|
{
|
|
ID: "disk-1",
|
|
Name: "sda",
|
|
Type: unifiedresources.ResourceTypePhysicalDisk,
|
|
PhysicalDisk: &unifiedresources.PhysicalDiskMeta{
|
|
DevPath: "/dev/sda",
|
|
Model: "sda",
|
|
Health: "PASSED",
|
|
},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
summary := triageBuildSummaryState(state, map[string]bool{"qemu/100": true})
|
|
if summary.TotalNodes != 1 || summary.TotalStoragePools != 1 || summary.TotalPhysicalDisks != 1 || summary.TotalStorage != 2 || summary.TotalDocker != 1 || summary.TotalTrueNAS != 1 || summary.TotalPBS != 1 || summary.TotalPMG != 1 {
|
|
t.Fatalf("unexpected non-guest summary counts from readState: %#v", summary)
|
|
}
|
|
if summary.TotalGuests != 3 || summary.RunningGuests != 2 || summary.StoppedGuests != 1 {
|
|
t.Fatalf("unexpected guest summary counts from readState: %#v", summary)
|
|
}
|
|
if summary.FlaggedCount != 1 {
|
|
t.Fatalf("expected flagged count to be preserved, got %#v", summary)
|
|
}
|
|
}
|
|
|
|
func TestTriageAlertChecksState_ResolvesTrueNASHostsToCanonicalResourceType(t *testing.T) {
|
|
state := patrolRuntimeState{
|
|
Hosts: []models.Host{
|
|
{
|
|
ID: "truenas-1",
|
|
Hostname: "truenas-main",
|
|
DisplayName: "TrueNAS Main",
|
|
Platform: "truenas",
|
|
Status: "online",
|
|
},
|
|
},
|
|
ActiveAlerts: []models.Alert{
|
|
{
|
|
ResourceID: "truenas-1",
|
|
ResourceName: "TrueNAS Main",
|
|
Type: "offline",
|
|
Level: "critical",
|
|
Message: "TrueNAS is offline",
|
|
},
|
|
},
|
|
}
|
|
|
|
flags := triageAlertChecksState(state, nil)
|
|
if len(flags) != 1 {
|
|
t.Fatalf("expected 1 alert-backed triage flag, got %d", len(flags))
|
|
}
|
|
if flags[0].ResourceType != "agent" {
|
|
t.Fatalf("expected canonical agent resource type, got %#v", flags[0])
|
|
}
|
|
}
|
|
|
|
func TestTriageRuntimeResourceType_CanonicalizesTrueNASConnectionIDsToAgent(t *testing.T) {
|
|
if got := triageRuntimeResourceType(patrolRuntimeState{}, "", "truenas-main"); got != "agent" {
|
|
t.Fatalf("expected truenas connection IDs to canonicalize to agent, got %q", got)
|
|
}
|
|
}
|
|
|
|
func triageFindFlag(flags []TriageFlag, predicate func(TriageFlag) bool) *TriageFlag {
|
|
for _, flag := range flags {
|
|
if predicate(flag) {
|
|
flagCopy := flag
|
|
return &flagCopy
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func triageFilterFlags(flags []TriageFlag, predicate func(TriageFlag) bool) []TriageFlag {
|
|
filtered := make([]TriageFlag, 0)
|
|
for _, flag := range flags {
|
|
if predicate(flag) {
|
|
filtered = append(filtered, flag)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|