mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
883 lines
28 KiB
Go
883 lines
28 KiB
Go
package ai
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/truenas"
|
|
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
)
|
|
|
|
func TestBuildUnifiedResourceContext_NilProvider(t *testing.T) {
|
|
s := &Service{}
|
|
if got := s.buildUnifiedResourceContext(); got != "" {
|
|
t.Errorf("expected empty context, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildUnifiedResourceContext_FullContext(t *testing.T) {
|
|
nodeWithAgent := unifiedresources.Resource{
|
|
ID: "node-1",
|
|
Name: "pve-node",
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Status: unifiedresources.StatusOnline,
|
|
Identity: unifiedresources.ResourceIdentity{
|
|
ClusterName: "cluster-a",
|
|
},
|
|
Metrics: &unifiedresources.ResourceMetrics{
|
|
CPU: &unifiedresources.MetricValue{Percent: 12.3},
|
|
Memory: &unifiedresources.MetricValue{Percent: 45.6},
|
|
},
|
|
Proxmox: &unifiedresources.ProxmoxData{
|
|
NodeName: "pve-node",
|
|
ClusterName: "cluster-a",
|
|
},
|
|
}
|
|
nodeNoAgent := unifiedresources.Resource{
|
|
ID: "node-2",
|
|
Name: "minipc",
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Status: unifiedresources.StatusWarning,
|
|
Proxmox: &unifiedresources.ProxmoxData{NodeName: "minipc"},
|
|
}
|
|
dockerNode := unifiedresources.Resource{
|
|
ID: "dock-node",
|
|
Name: "dock-node",
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Status: unifiedresources.StatusOnline,
|
|
Docker: &unifiedresources.DockerData{Hostname: "dock-node"},
|
|
}
|
|
host := unifiedresources.Resource{
|
|
ID: "host-1",
|
|
Name: "barehost",
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Status: unifiedresources.StatusOnline,
|
|
Identity: unifiedresources.ResourceIdentity{
|
|
IPAddresses: []string{"192.168.1.10"},
|
|
},
|
|
Metrics: &unifiedresources.ResourceMetrics{
|
|
CPU: &unifiedresources.MetricValue{Percent: 5},
|
|
Memory: &unifiedresources.MetricValue{Percent: 10},
|
|
},
|
|
Agent: &unifiedresources.AgentData{
|
|
Hostname: "barehost",
|
|
},
|
|
}
|
|
dockerHost := unifiedresources.Resource{
|
|
ID: "docker-1",
|
|
Name: "dockhost",
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Status: unifiedresources.StatusOnline,
|
|
Docker: &unifiedresources.DockerData{Hostname: "dockhost"},
|
|
}
|
|
|
|
nodeWithAgentID := nodeWithAgent.ID
|
|
dockerHostID := dockerHost.ID
|
|
unknownParentID := "unknown-parent"
|
|
vm := unifiedresources.Resource{
|
|
ID: "vm-100",
|
|
Name: "web-vm",
|
|
Type: unifiedresources.ResourceTypeVM,
|
|
ParentID: &nodeWithAgentID,
|
|
Status: unifiedresources.StatusOnline,
|
|
Identity: unifiedresources.ResourceIdentity{
|
|
IPAddresses: []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"},
|
|
},
|
|
Metrics: &unifiedresources.ResourceMetrics{
|
|
CPU: &unifiedresources.MetricValue{Percent: 65.4},
|
|
Memory: &unifiedresources.MetricValue{Percent: 70.2},
|
|
},
|
|
Proxmox: &unifiedresources.ProxmoxData{
|
|
VMID: 100,
|
|
},
|
|
}
|
|
ct := unifiedresources.Resource{
|
|
ID: "ct-200",
|
|
Name: "db-ct",
|
|
Type: unifiedresources.ResourceTypeSystemContainer,
|
|
ParentID: &nodeWithAgentID,
|
|
Status: unifiedresources.StatusOffline,
|
|
Proxmox: &unifiedresources.ProxmoxData{
|
|
VMID: 200,
|
|
},
|
|
}
|
|
dockerContainer := unifiedresources.Resource{
|
|
ID: "dock-300",
|
|
Name: "redis",
|
|
Type: unifiedresources.ResourceTypeAppContainer,
|
|
ParentID: &dockerHostID,
|
|
Status: unifiedresources.StatusOnline,
|
|
Metrics: &unifiedresources.ResourceMetrics{
|
|
Disk: &unifiedresources.MetricValue{Percent: 70},
|
|
},
|
|
}
|
|
dockerStopped := unifiedresources.Resource{
|
|
ID: "dock-301",
|
|
Name: "cache",
|
|
Type: unifiedresources.ResourceTypeAppContainer,
|
|
ParentID: &dockerHostID,
|
|
Status: unifiedresources.StatusOffline,
|
|
}
|
|
unknownParent := unifiedresources.Resource{
|
|
ID: "vm-999",
|
|
Name: "mystery",
|
|
Type: unifiedresources.ResourceTypeVM,
|
|
ParentID: &unknownParentID,
|
|
Status: unifiedresources.StatusOnline,
|
|
Proxmox: &unifiedresources.ProxmoxData{
|
|
VMID: 999,
|
|
},
|
|
}
|
|
orphan := unifiedresources.Resource{
|
|
ID: "orphan-1",
|
|
Name: "orphan",
|
|
Type: unifiedresources.ResourceTypeSystemContainer,
|
|
Status: unifiedresources.StatusOnline,
|
|
Identity: unifiedresources.ResourceIdentity{
|
|
IPAddresses: []string{"172.16.0.5"},
|
|
},
|
|
}
|
|
|
|
infrastructure := []unifiedresources.Resource{nodeWithAgent, nodeNoAgent, host, dockerHost, dockerNode}
|
|
workloads := []unifiedresources.Resource{vm, ct, dockerContainer, dockerStopped, unknownParent, orphan}
|
|
all := append(append([]unifiedresources.Resource{}, infrastructure...), workloads...)
|
|
|
|
stats := unifiedresources.ResourceStats{
|
|
Total: len(all),
|
|
ByType: map[unifiedresources.ResourceType]int{
|
|
unifiedresources.ResourceTypeAgent: 5,
|
|
unifiedresources.ResourceTypeVM: 2,
|
|
unifiedresources.ResourceTypeSystemContainer: 2,
|
|
unifiedresources.ResourceTypeAppContainer: 2,
|
|
},
|
|
ByStatus: map[unifiedresources.ResourceStatus]int{
|
|
unifiedresources.StatusOnline: 7,
|
|
unifiedresources.StatusWarning: 1,
|
|
unifiedresources.StatusOffline: 3,
|
|
},
|
|
BySource: map[unifiedresources.DataSource]int{
|
|
unifiedresources.SourceProxmox: 4,
|
|
unifiedresources.SourceAgent: 1,
|
|
unifiedresources.SourceDocker: 2,
|
|
},
|
|
}
|
|
|
|
mockURP := &mockUnifiedResourceProvider{
|
|
getStatsFunc: func() unifiedresources.ResourceStats {
|
|
return stats
|
|
},
|
|
getInfrastructureFunc: func() []unifiedresources.Resource {
|
|
return infrastructure
|
|
},
|
|
getWorkloadsFunc: func() []unifiedresources.Resource {
|
|
return workloads
|
|
},
|
|
getAllFunc: func() []unifiedresources.Resource {
|
|
return all
|
|
},
|
|
getTopCPUFunc: func(limit int, types []unifiedresources.ResourceType) []unifiedresources.Resource {
|
|
return []unifiedresources.Resource{vm}
|
|
},
|
|
getTopMemoryFunc: func(limit int, types []unifiedresources.ResourceType) []unifiedresources.Resource {
|
|
return []unifiedresources.Resource{host}
|
|
},
|
|
getTopDiskFunc: func(limit int, types []unifiedresources.ResourceType) []unifiedresources.Resource {
|
|
return []unifiedresources.Resource{dockerContainer}
|
|
},
|
|
}
|
|
|
|
s := &Service{
|
|
unifiedResourceProvider: mockURP,
|
|
alertProvider: &resourceContextAlertProvider{
|
|
active: []AlertInfo{
|
|
{
|
|
ResourceID: vm.ID,
|
|
ResourceName: vm.Name,
|
|
Message: "CPU high",
|
|
Level: "critical",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.agentServer = &mockAgentServer{
|
|
agents: []agentexec.ConnectedAgent{
|
|
{AgentID: "agent-1", Hostname: "pve-node"},
|
|
},
|
|
}
|
|
|
|
got := s.buildUnifiedResourceContext()
|
|
if got == "" {
|
|
t.Fatal("expected non-empty context")
|
|
}
|
|
|
|
assertContains := func(substr string) {
|
|
t.Helper()
|
|
if !strings.Contains(got, substr) {
|
|
t.Fatalf("expected context to contain %q", substr)
|
|
}
|
|
}
|
|
|
|
assertContains("## Unified Infrastructure View")
|
|
assertContains("Total resources: 11 (Infrastructure: 5, Workloads: 6)")
|
|
assertContains("Data Governance")
|
|
expectedSensitivity := strings.Join(unifiedresources.ResourcePolicySensitivitySummaryFromCounts(map[unifiedresources.ResourceSensitivity]int{
|
|
unifiedresources.ResourceSensitivityPublic: 0,
|
|
unifiedresources.ResourceSensitivityInternal: 5,
|
|
unifiedresources.ResourceSensitivitySensitive: 6,
|
|
unifiedresources.ResourceSensitivityRestricted: 0,
|
|
}), ", ")
|
|
assertContains("Sensitivity: " + expectedSensitivity)
|
|
expectedRouting := strings.Join(unifiedresources.ResourcePolicyRoutingSummaryFromCounts(map[unifiedresources.ResourceRoutingScope]int{
|
|
unifiedresources.ResourceRoutingScopeCloudSummary: 5,
|
|
unifiedresources.ResourceRoutingScopeLocalFirst: 6,
|
|
unifiedresources.ResourceRoutingScopeLocalOnly: 0,
|
|
}), ", ")
|
|
assertContains("Routing: " + expectedRouting)
|
|
assertContains("Policy Redaction Hints")
|
|
assertContains("Redactions in use: Hostname, IP Address, Platform ID, Alias")
|
|
if count := strings.Count(got, "### Policy Redaction Hints"); count != 1 {
|
|
t.Fatalf("expected one policy redaction hints section, got %d in %q", count, got)
|
|
}
|
|
assertContains("Proxmox VE Nodes")
|
|
assertContains("HAS AGENT")
|
|
assertContains("NO AGENT")
|
|
assertContains("cluster: cluster-a")
|
|
assertContains("Standalone Hosts")
|
|
assertContains("192.168.1.10")
|
|
assertContains("Docker/Podman Hosts")
|
|
assertContains("1/2 containers running")
|
|
assertContains("Workloads (VMs & Containers)")
|
|
assertContains("On pve-node")
|
|
assertContains("On unresolved parent resource")
|
|
assertContains("Other workloads")
|
|
assertContains("virtual machine resource; status online; linked to parent resource; redacted for cloud summary")
|
|
assertContains("IPs " + unifiedresources.ResourcePolicyRedactedLabel)
|
|
assertContains("Resources with Active Alerts")
|
|
assertContains("CPU high")
|
|
assertContains("Infrastructure Summary")
|
|
assertContains("Resources with alerts: 1")
|
|
assertContains("Average utilization by type")
|
|
assertContains("Top CPU Consumers")
|
|
assertContains("virtual machine resource; status online; linked to parent resource; redacted for cloud summary** (vm): 65.4%")
|
|
assertContains("Top Memory Consumers")
|
|
assertContains("Top Disk Usage")
|
|
assertContains("application container resource; status online; linked to parent resource; redacted for cloud summary** (app-container): 70.0%")
|
|
if strings.Contains(got, "web-vm") {
|
|
t.Fatalf("expected sensitive VM name to be redacted, got %q", got)
|
|
}
|
|
if strings.Contains(got, "10.0.0.1") || strings.Contains(got, "10.0.0.2") {
|
|
t.Fatalf("expected sensitive workload IPs to be redacted, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildUnifiedResourceContextUsesAISafeSummaryForLocalOnlyResources(t *testing.T) {
|
|
restricted := unifiedresources.Resource{
|
|
ID: "mail-1",
|
|
Name: "customer-mail-gateway",
|
|
Type: unifiedresources.ResourceTypePMG,
|
|
Status: unifiedresources.StatusWarning,
|
|
PMG: &unifiedresources.PMGData{
|
|
Hostname: "mail-gateway.internal",
|
|
},
|
|
}
|
|
unifiedresources.RefreshCanonicalMetadata(&restricted)
|
|
|
|
stats := unifiedresources.ResourceStats{
|
|
Total: 1,
|
|
ByType: map[unifiedresources.ResourceType]int{
|
|
unifiedresources.ResourceTypePMG: 1,
|
|
},
|
|
ByStatus: map[unifiedresources.ResourceStatus]int{
|
|
unifiedresources.StatusWarning: 1,
|
|
},
|
|
}
|
|
|
|
mockURP := &mockUnifiedResourceProvider{
|
|
getStatsFunc: func() unifiedresources.ResourceStats { return stats },
|
|
getInfrastructureFunc: func() []unifiedresources.Resource {
|
|
return []unifiedresources.Resource{restricted}
|
|
},
|
|
getAllFunc: func() []unifiedresources.Resource {
|
|
return []unifiedresources.Resource{restricted}
|
|
},
|
|
}
|
|
|
|
s := &Service{unifiedResourceProvider: mockURP}
|
|
got := s.buildUnifiedResourceContext()
|
|
|
|
if !strings.Contains(got, "mail gateway resource; status warning; local-only context") {
|
|
t.Fatalf("expected AI-safe summary in context, got %q", got)
|
|
}
|
|
if strings.Contains(got, "customer-mail-gateway") {
|
|
t.Fatalf("expected raw restricted resource name to be hidden, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildUnifiedResourceContextUsesAISafeSummaryForSensitiveResources(t *testing.T) {
|
|
sensitiveVM := unifiedresources.Resource{
|
|
ID: "vm-1",
|
|
Name: "finance-db",
|
|
Type: unifiedresources.ResourceTypeVM,
|
|
Status: unifiedresources.StatusOnline,
|
|
Identity: unifiedresources.ResourceIdentity{
|
|
ClusterName: "prod-west",
|
|
IPAddresses: []string{"10.10.0.5"},
|
|
},
|
|
Proxmox: &unifiedresources.ProxmoxData{
|
|
NodeName: "pve-prod",
|
|
ClusterName: "prod-west",
|
|
VMID: 101,
|
|
},
|
|
}
|
|
unifiedresources.RefreshCanonicalMetadata(&sensitiveVM)
|
|
|
|
stats := unifiedresources.ResourceStats{
|
|
Total: 1,
|
|
ByType: map[unifiedresources.ResourceType]int{
|
|
unifiedresources.ResourceTypeVM: 1,
|
|
},
|
|
ByStatus: map[unifiedresources.ResourceStatus]int{
|
|
unifiedresources.StatusOnline: 1,
|
|
},
|
|
}
|
|
|
|
mockURP := &mockUnifiedResourceProvider{
|
|
getStatsFunc: func() unifiedresources.ResourceStats { return stats },
|
|
getWorkloadsFunc: func() []unifiedresources.Resource {
|
|
return []unifiedresources.Resource{sensitiveVM}
|
|
},
|
|
getAllFunc: func() []unifiedresources.Resource {
|
|
return []unifiedresources.Resource{sensitiveVM}
|
|
},
|
|
getTopCPUFunc: func(limit int, types []unifiedresources.ResourceType) []unifiedresources.Resource {
|
|
return []unifiedresources.Resource{sensitiveVM}
|
|
},
|
|
}
|
|
|
|
s := &Service{unifiedResourceProvider: mockURP}
|
|
got := s.buildUnifiedResourceContext()
|
|
|
|
if !strings.Contains(got, "virtual machine resource; status online; redacted for cloud summary") {
|
|
t.Fatalf("expected AI-safe summary for sensitive resource, got %q", got)
|
|
}
|
|
if !strings.Contains(got, "IPs "+unifiedresources.ResourcePolicyRedactedLabel) {
|
|
t.Fatalf("expected sensitive IP redaction marker, got %q", got)
|
|
}
|
|
if strings.Contains(got, "finance-db") {
|
|
t.Fatalf("expected sensitive resource name to be hidden, got %q", got)
|
|
}
|
|
if strings.Contains(got, "10.10.0.5") {
|
|
t.Fatalf("expected sensitive IP to be hidden, got %q", got)
|
|
}
|
|
if strings.Contains(got, "prod-west") {
|
|
t.Fatalf("expected sensitive cluster name to be hidden, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildUnifiedResourceContextUsesAISafeSummaryForPathRedactedResources(t *testing.T) {
|
|
storage := unifiedresources.Resource{
|
|
ID: "storage-1",
|
|
Name: "backup-volume",
|
|
Type: unifiedresources.ResourceTypeStorage,
|
|
Status: unifiedresources.StatusOnline,
|
|
Storage: &unifiedresources.StorageMeta{
|
|
Path: "/mnt/pve/backups",
|
|
},
|
|
}
|
|
unifiedresources.RefreshCanonicalMetadata(&storage)
|
|
|
|
stats := unifiedresources.ResourceStats{
|
|
Total: 1,
|
|
ByType: map[unifiedresources.ResourceType]int{
|
|
unifiedresources.ResourceTypeStorage: 1,
|
|
},
|
|
ByStatus: map[unifiedresources.ResourceStatus]int{
|
|
unifiedresources.StatusOnline: 1,
|
|
},
|
|
}
|
|
|
|
mockURP := &mockUnifiedResourceProvider{
|
|
getStatsFunc: func() unifiedresources.ResourceStats { return stats },
|
|
getInfrastructureFunc: func() []unifiedresources.Resource {
|
|
return []unifiedresources.Resource{storage}
|
|
},
|
|
getAllFunc: func() []unifiedresources.Resource {
|
|
return []unifiedresources.Resource{storage}
|
|
},
|
|
}
|
|
|
|
s := &Service{unifiedResourceProvider: mockURP}
|
|
got := s.buildUnifiedResourceContext()
|
|
|
|
if !strings.Contains(got, "storage resource; status online; redacted for cloud summary") {
|
|
t.Fatalf("expected AI-safe summary for path-redacted resource, got %q", got)
|
|
}
|
|
if strings.Contains(got, "backup-volume") {
|
|
t.Fatalf("expected path-redacted resource name to be hidden, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestResourcePolicyHelpersShareAISafeSummaryDecision(t *testing.T) {
|
|
policy := &unifiedresources.ResourcePolicy{
|
|
Sensitivity: unifiedresources.ResourceSensitivitySensitive,
|
|
Routing: unifiedresources.ResourceRoutingPolicy{
|
|
Scope: unifiedresources.ResourceRoutingScopeLocalFirst,
|
|
Redact: []unifiedresources.ResourceRedactionHint{
|
|
unifiedresources.ResourceRedactionHostname,
|
|
unifiedresources.ResourceRedactionIPAddress,
|
|
},
|
|
},
|
|
}
|
|
|
|
if !unifiedresources.ResourcePolicyUsesAISafeSummary("virtual machine resource; status online; redacted for cloud summary", policy) {
|
|
t.Fatal("expected AI-safe summary helper to honor shared policy rules")
|
|
}
|
|
if !unifiedresources.ResourcePolicyRedacts(policy, unifiedresources.ResourceRedactionHostname) {
|
|
t.Fatal("expected hostname redaction helper to honor shared policy rules")
|
|
}
|
|
if unifiedresources.ResourcePolicyRedacts(policy, unifiedresources.ResourceRedactionAlias) {
|
|
t.Fatal("did not expect alias redaction helper to match")
|
|
}
|
|
}
|
|
|
|
func TestUnifiedResourceDisplayNameUsesSharedHelper(t *testing.T) {
|
|
if got := unifiedresources.ResourceDisplayName(unifiedresources.Resource{Name: " node-a ", ID: "id-a"}); got != "node-a" {
|
|
t.Fatalf("ResourceDisplayName() with name = %q, want node-a", got)
|
|
}
|
|
if got := unifiedresources.ResourceDisplayName(unifiedresources.Resource{Name: " ", ID: " id-b "}); got != "id-b" {
|
|
t.Fatalf("ResourceDisplayName() with fallback ID = %q, want id-b", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildUnifiedResourceContext_UnifiedPath(t *testing.T) {
|
|
clusterID := "k8s-cluster-1"
|
|
k8sCluster := unifiedresources.Resource{
|
|
ID: clusterID,
|
|
Name: "prod-cluster",
|
|
Type: unifiedresources.ResourceTypeK8sCluster,
|
|
Status: unifiedresources.StatusOnline,
|
|
Identity: unifiedresources.ResourceIdentity{
|
|
ClusterName: "prod-cluster",
|
|
},
|
|
Kubernetes: &unifiedresources.K8sData{
|
|
ClusterID: "prod-cluster",
|
|
ClusterName: "prod-cluster",
|
|
},
|
|
}
|
|
k8sNode := unifiedresources.Resource{
|
|
ID: "k8s-node-1",
|
|
Name: "worker-1",
|
|
Type: unifiedresources.ResourceTypeK8sNode,
|
|
Status: unifiedresources.StatusWarning,
|
|
ParentID: &clusterID,
|
|
Identity: unifiedresources.ResourceIdentity{
|
|
ClusterName: "prod-cluster",
|
|
},
|
|
Metrics: &unifiedresources.ResourceMetrics{
|
|
CPU: &unifiedresources.MetricValue{Percent: 91},
|
|
Memory: &unifiedresources.MetricValue{Percent: 77},
|
|
},
|
|
Kubernetes: &unifiedresources.K8sData{
|
|
ClusterID: "prod-cluster",
|
|
ClusterName: "prod-cluster",
|
|
},
|
|
}
|
|
|
|
all := []unifiedresources.Resource{k8sCluster, k8sNode}
|
|
stats := unifiedresources.ResourceStats{
|
|
Total: len(all),
|
|
ByType: map[unifiedresources.ResourceType]int{
|
|
unifiedresources.ResourceTypeK8sCluster: 1,
|
|
unifiedresources.ResourceTypeK8sNode: 1,
|
|
},
|
|
ByStatus: map[unifiedresources.ResourceStatus]int{
|
|
unifiedresources.StatusOnline: 1,
|
|
unifiedresources.StatusWarning: 1,
|
|
},
|
|
BySource: map[unifiedresources.DataSource]int{
|
|
unifiedresources.SourceK8s: 2,
|
|
},
|
|
}
|
|
|
|
unifiedProvider := &mockUnifiedResourceProvider{
|
|
getStatsFunc: func() unifiedresources.ResourceStats { return stats },
|
|
getInfrastructureFunc: func() []unifiedresources.Resource {
|
|
return all
|
|
},
|
|
getAllFunc: func() []unifiedresources.Resource {
|
|
return all
|
|
},
|
|
getTopCPUFunc: func(limit int, types []unifiedresources.ResourceType) []unifiedresources.Resource {
|
|
return []unifiedresources.Resource{k8sNode}
|
|
},
|
|
}
|
|
|
|
s := &Service{
|
|
unifiedResourceProvider: unifiedProvider,
|
|
alertProvider: &resourceContextAlertProvider{
|
|
active: []AlertInfo{
|
|
{
|
|
ResourceID: k8sNode.ID,
|
|
ResourceName: "wrong-fallback-name",
|
|
Message: "Node not ready",
|
|
Level: "warning",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
got := s.buildUnifiedResourceContext()
|
|
if got == "" {
|
|
t.Fatal("expected non-empty unified context")
|
|
}
|
|
|
|
assertContains := func(substr string) {
|
|
t.Helper()
|
|
if !strings.Contains(got, substr) {
|
|
t.Fatalf("expected context to contain %q", substr)
|
|
}
|
|
}
|
|
|
|
assertContains("Total resources: 2 (Infrastructure: 2, Workloads: 0)")
|
|
assertContains("Kubernetes")
|
|
assertContains("prod-cluster")
|
|
assertContains("worker-1")
|
|
assertContains("Resources with Active Alerts")
|
|
assertContains("Node not ready")
|
|
}
|
|
|
|
func TestBuildUnifiedResourceContextIncludesTrueNASResources(t *testing.T) {
|
|
previous := truenas.IsFeatureEnabled()
|
|
truenas.SetFeatureEnabled(true)
|
|
t.Cleanup(func() {
|
|
truenas.SetFeatureEnabled(previous)
|
|
})
|
|
|
|
registry := unifiedresources.NewRegistry(unifiedresources.NewMemoryStore())
|
|
records := truenas.NewDefaultProvider().Records()
|
|
if len(records) == 0 {
|
|
t.Fatal("expected truenas fixture records")
|
|
}
|
|
registry.IngestRecords(unifiedresources.SourceTrueNAS, records)
|
|
|
|
adapter := unifiedresources.NewUnifiedAIAdapter(registry)
|
|
s := &Service{}
|
|
s.SetUnifiedResourceProvider(adapter)
|
|
|
|
got := s.buildUnifiedResourceContext()
|
|
if got == "" {
|
|
t.Fatal("expected non-empty context")
|
|
}
|
|
if !strings.Contains(got, "TrueNAS Systems") {
|
|
t.Fatalf("expected context to include TrueNAS section, got %q", got)
|
|
}
|
|
if !strings.Contains(got, "### Storage") {
|
|
t.Fatalf("expected context to include storage section, got %q", got)
|
|
}
|
|
if !strings.Contains(got, "Storage Pools") {
|
|
t.Fatalf("expected context to include storage pools section, got %q", got)
|
|
}
|
|
if !strings.Contains(got, "Physical Disks Needing Attention") {
|
|
t.Fatalf("expected context to include disk attention section, got %q", got)
|
|
}
|
|
if strings.Contains(got, "tank/apps") {
|
|
t.Fatalf("expected storage pool section to exclude dataset names, got %q", got)
|
|
}
|
|
if !strings.Contains(got, "agent resource; status warning; sources truenas") {
|
|
t.Fatalf("expected context to include governed truenas summary, got %q", got)
|
|
}
|
|
if strings.Contains(got, "truenas-main") {
|
|
t.Fatalf("expected raw truenas host name to be redacted, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBuildUnifiedResourceContext_TruncatesLargeContext(t *testing.T) {
|
|
largeName := strings.Repeat("a", 60000)
|
|
|
|
node := unifiedresources.Resource{
|
|
ID: "node-1",
|
|
Name: largeName,
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Status: unifiedresources.StatusOnline,
|
|
}
|
|
|
|
stats := unifiedresources.ResourceStats{
|
|
Total: 1,
|
|
ByType: map[unifiedresources.ResourceType]int{
|
|
unifiedresources.ResourceTypeAgent: 1,
|
|
},
|
|
}
|
|
|
|
mockURP := &mockUnifiedResourceProvider{
|
|
getStatsFunc: func() unifiedresources.ResourceStats {
|
|
return stats
|
|
},
|
|
getInfrastructureFunc: func() []unifiedresources.Resource {
|
|
return []unifiedresources.Resource{node}
|
|
},
|
|
getWorkloadsFunc: func() []unifiedresources.Resource {
|
|
return nil
|
|
},
|
|
getAllFunc: func() []unifiedresources.Resource {
|
|
return []unifiedresources.Resource{node}
|
|
},
|
|
}
|
|
|
|
s := &Service{unifiedResourceProvider: mockURP}
|
|
got := s.buildUnifiedResourceContext()
|
|
if !strings.Contains(got, "[... Context truncated ...]") {
|
|
t.Fatal("expected context to be truncated")
|
|
}
|
|
if len(got) <= 50000 {
|
|
t.Fatalf("expected truncated context length > 50000, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestResourceContextUnifiedProvider_NilRegistry(t *testing.T) {
|
|
adapter := unifiedresources.NewUnifiedAIAdapter(nil)
|
|
|
|
if got := adapter.GetAll(); len(got) != 0 {
|
|
t.Fatalf("GetAll() len = %d, want 0", len(got))
|
|
}
|
|
if got := adapter.GetInfrastructure(); len(got) != 0 {
|
|
t.Fatalf("GetInfrastructure() len = %d, want 0", len(got))
|
|
}
|
|
if got := adapter.GetWorkloads(); len(got) != 0 {
|
|
t.Fatalf("GetWorkloads() len = %d, want 0", len(got))
|
|
}
|
|
if got := adapter.GetByType(unifiedresources.ResourceTypeAgent); len(got) != 0 {
|
|
t.Fatalf("GetByType(host) len = %d, want 0", len(got))
|
|
}
|
|
if got := adapter.GetTopByCPU(3, nil); len(got) != 0 {
|
|
t.Fatalf("GetTopByCPU() len = %d, want 0", len(got))
|
|
}
|
|
if got := adapter.GetRelated("missing"); len(got) != 0 {
|
|
t.Fatalf("GetRelated(missing) len = %d, want 0", len(got))
|
|
}
|
|
if got := adapter.FindContainerHost("missing"); got != "" {
|
|
t.Fatalf("FindContainerHost(missing) = %q, want empty", got)
|
|
}
|
|
if stats := adapter.GetStats(); stats.Total != 0 {
|
|
t.Fatalf("GetStats().Total = %d, want 0", stats.Total)
|
|
}
|
|
}
|
|
|
|
func TestResourceContextUnifiedProvider_ResourceCounts(t *testing.T) {
|
|
registry := unifiedresources.NewRegistry(nil)
|
|
now := time.Now().UTC()
|
|
|
|
registry.IngestRecords(unifiedresources.SourceAgent, []unifiedresources.IngestRecord{
|
|
{
|
|
SourceID: "host-1",
|
|
Resource: unifiedresources.Resource{
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Name: "host-1",
|
|
Status: unifiedresources.StatusOnline,
|
|
LastSeen: now,
|
|
},
|
|
},
|
|
})
|
|
registry.IngestRecords(unifiedresources.SourceProxmox, []unifiedresources.IngestRecord{
|
|
{
|
|
SourceID: "vm-1",
|
|
Resource: unifiedresources.Resource{
|
|
Type: unifiedresources.ResourceTypeVM,
|
|
Name: "vm-1",
|
|
Status: unifiedresources.StatusOnline,
|
|
LastSeen: now,
|
|
},
|
|
},
|
|
{
|
|
SourceID: "ct-1",
|
|
Resource: unifiedresources.Resource{
|
|
Type: unifiedresources.ResourceTypeSystemContainer,
|
|
Name: "ct-1",
|
|
Status: unifiedresources.StatusOnline,
|
|
LastSeen: now,
|
|
},
|
|
},
|
|
})
|
|
unified := unifiedresources.NewUnifiedAIAdapter(registry)
|
|
|
|
if got, want := len(unified.GetAll()), 3; got != want {
|
|
t.Fatalf("GetAll() count mismatch: got=%d want=%d", got, want)
|
|
}
|
|
}
|
|
|
|
func TestResourceContextUnifiedProvider_InfrastructureWorkloadSplit(t *testing.T) {
|
|
registry := unifiedresources.NewRegistry(nil)
|
|
now := time.Now().UTC()
|
|
|
|
registry.IngestRecords(unifiedresources.SourceAgent, []unifiedresources.IngestRecord{
|
|
{
|
|
SourceID: "host-1",
|
|
Resource: unifiedresources.Resource{
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Name: "host-1",
|
|
Status: unifiedresources.StatusOnline,
|
|
LastSeen: now,
|
|
},
|
|
},
|
|
})
|
|
registry.IngestRecords(unifiedresources.SourceProxmox, []unifiedresources.IngestRecord{
|
|
{
|
|
SourceID: "vm-1",
|
|
Resource: unifiedresources.Resource{
|
|
Type: unifiedresources.ResourceTypeVM,
|
|
Name: "vm-1",
|
|
Status: unifiedresources.StatusOnline,
|
|
LastSeen: now,
|
|
},
|
|
},
|
|
{
|
|
SourceID: "ct-1",
|
|
Resource: unifiedresources.Resource{
|
|
Type: unifiedresources.ResourceTypeSystemContainer,
|
|
Name: "ct-1",
|
|
Status: unifiedresources.StatusOnline,
|
|
LastSeen: now,
|
|
},
|
|
},
|
|
})
|
|
unified := unifiedresources.NewUnifiedAIAdapter(registry)
|
|
|
|
if got, want := len(unified.GetInfrastructure()), 1; got != want {
|
|
t.Fatalf("GetInfrastructure() count mismatch: got=%d want=%d", got, want)
|
|
}
|
|
if got, want := len(unified.GetWorkloads()), 2; got != want {
|
|
t.Fatalf("GetWorkloads() count mismatch: got=%d want=%d", got, want)
|
|
}
|
|
}
|
|
|
|
func TestResourceContextUnifiedProvider_TopCPU(t *testing.T) {
|
|
registry := unifiedresources.NewRegistry(nil)
|
|
now := time.Now().UTC()
|
|
registry.IngestRecords(unifiedresources.SourceAgent, []unifiedresources.IngestRecord{
|
|
{
|
|
SourceID: "host-low",
|
|
Resource: unifiedresources.Resource{
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Name: "host-low",
|
|
Status: unifiedresources.StatusOnline,
|
|
LastSeen: now,
|
|
Metrics: &unifiedresources.ResourceMetrics{
|
|
CPU: &unifiedresources.MetricValue{Percent: 25},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
registry.IngestRecords(unifiedresources.SourceProxmox, []unifiedresources.IngestRecord{
|
|
{
|
|
SourceID: "vm-high",
|
|
Resource: unifiedresources.Resource{
|
|
Type: unifiedresources.ResourceTypeVM,
|
|
Name: "vm-high",
|
|
Status: unifiedresources.StatusOnline,
|
|
LastSeen: now,
|
|
Metrics: &unifiedresources.ResourceMetrics{
|
|
CPU: &unifiedresources.MetricValue{Percent: 92},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
registry.IngestRecords(unifiedresources.SourceDocker, []unifiedresources.IngestRecord{
|
|
{
|
|
SourceID: "ct-mid",
|
|
Resource: unifiedresources.Resource{
|
|
Type: unifiedresources.ResourceTypeAppContainer,
|
|
Name: "ct-mid",
|
|
Status: unifiedresources.StatusOnline,
|
|
LastSeen: now,
|
|
Metrics: &unifiedresources.ResourceMetrics{
|
|
CPU: &unifiedresources.MetricValue{Percent: 61},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
unified := unifiedresources.NewUnifiedAIAdapter(registry)
|
|
top := unified.GetTopByCPU(2, []unifiedresources.ResourceType{
|
|
unifiedresources.ResourceTypeAgent,
|
|
unifiedresources.ResourceTypeVM,
|
|
unifiedresources.ResourceTypeAppContainer,
|
|
})
|
|
if len(top) != 2 {
|
|
t.Fatalf("GetTopByCPU() len = %d, want 2", len(top))
|
|
}
|
|
if top[0].Name != "vm-high" || top[1].Name != "ct-mid" {
|
|
t.Fatalf("unexpected top CPU ordering: got [%s, %s]", top[0].Name, top[1].Name)
|
|
}
|
|
}
|
|
|
|
func TestResourceContextUnifiedProvider_FindContainerHost(t *testing.T) {
|
|
registry := unifiedresources.NewRegistry(nil)
|
|
now := time.Now().UTC()
|
|
registry.IngestRecords(unifiedresources.SourceDocker, []unifiedresources.IngestRecord{
|
|
{
|
|
SourceID: "docker-host-1",
|
|
Resource: unifiedresources.Resource{
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Name: "docker-node-1",
|
|
Status: unifiedresources.StatusOnline,
|
|
LastSeen: now,
|
|
},
|
|
},
|
|
{
|
|
SourceID: "container-1",
|
|
ParentSourceID: "docker-host-1",
|
|
Resource: unifiedresources.Resource{
|
|
Type: unifiedresources.ResourceTypeAppContainer,
|
|
Name: "web",
|
|
Status: unifiedresources.StatusOnline,
|
|
LastSeen: now,
|
|
Docker: &unifiedresources.DockerData{ContainerID: "abc123"},
|
|
},
|
|
},
|
|
})
|
|
|
|
unified := unifiedresources.NewUnifiedAIAdapter(registry)
|
|
if got := unified.FindContainerHost("web"); got != "docker-node-1" {
|
|
t.Fatalf("FindContainerHost(web) = %q, want docker-node-1", got)
|
|
}
|
|
if got := unified.FindContainerHost("abc123"); got != "docker-node-1" {
|
|
t.Fatalf("FindContainerHost(abc123) = %q, want docker-node-1", got)
|
|
}
|
|
}
|
|
|
|
type resourceContextAlertProvider struct {
|
|
active []AlertInfo
|
|
history []ResolvedAlertInfo
|
|
}
|
|
|
|
func (m *resourceContextAlertProvider) GetActiveAlerts() []AlertInfo {
|
|
return m.active
|
|
}
|
|
|
|
func (m *resourceContextAlertProvider) GetRecentlyResolved(minutes int) []ResolvedAlertInfo {
|
|
return m.history
|
|
}
|
|
|
|
func (m *resourceContextAlertProvider) GetAlertsByResource(resourceID string) []AlertInfo {
|
|
out := make([]AlertInfo, 0)
|
|
for _, alert := range m.active {
|
|
if alert.ResourceID == resourceID {
|
|
out = append(out, alert)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m *resourceContextAlertProvider) GetAlertHistory(resourceID string, limit int) []ResolvedAlertInfo {
|
|
out := make([]ResolvedAlertInfo, 0)
|
|
for _, alert := range m.history {
|
|
if alert.ResourceID == resourceID {
|
|
out = append(out, alert)
|
|
}
|
|
if limit > 0 && len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|