mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
ff5a0b4957
f62f35e24restored the v5 used | cache | free memory split for Proxmox nodes and guests, but standalone host agents still reported a flat used/free pair, so the Machines page memory bar could not show the reclaimable segment. Flagged by the Machines page v5 parity audit. - Host agent reports cacheBytes (gopsutil Available minus Free); the ZFS ARC adjustment recomputes free so used + cache + free still covers the total. - ApplyHostReport maps the field into models.Memory.Cache and clamps inconsistent or older-agent reports so used + cache never exceeds total. - AgentMemoryMeta carries cache onto unified resources so the frontend agent payload exposes it. - Mock generic hosts split a third of non-used pages as cache, and the node-linked host conversion now holds the invariant instead of stacking the node's cache on top of a recomputed free. - Contracts: monitoring, unified-resources, and storage-recovery now document the split (also covering thef62f35e24node/guest surface, which landed without contract deltas).
772 lines
22 KiB
Go
772 lines
22 KiB
Go
package mock
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
)
|
|
|
|
func TestBuildFixtureStateIncludesDockerHosts(t *testing.T) {
|
|
cfg := DefaultConfig
|
|
cfg.DockerHostCount = 2
|
|
cfg.DockerContainersPerHost = 5
|
|
|
|
data := buildFixtureState(cfg)
|
|
|
|
if len(data.DockerHosts) != cfg.DockerHostCount {
|
|
t.Fatalf("expected %d docker hosts, got %d", cfg.DockerHostCount, len(data.DockerHosts))
|
|
}
|
|
|
|
for _, host := range data.DockerHosts {
|
|
if host.ID == "" {
|
|
t.Fatalf("docker host missing id: %+v", host)
|
|
}
|
|
if len(host.Containers) == 0 {
|
|
t.Fatalf("docker host %s has no containers", host.Hostname)
|
|
}
|
|
if len(host.Images) == 0 {
|
|
t.Fatalf("docker host %s has no image inventory", host.Hostname)
|
|
}
|
|
if len(host.Volumes) == 0 {
|
|
t.Fatalf("docker host %s has no volume inventory", host.Hostname)
|
|
}
|
|
if len(host.Networks) == 0 {
|
|
t.Fatalf("docker host %s has no network inventory", host.Hostname)
|
|
}
|
|
if host.StorageUsage == nil || host.StorageUsage.Images.TotalCount == 0 || host.StorageUsage.Volumes.TotalCount == 0 {
|
|
t.Fatalf("docker host %s has no engine storage usage inventory: %+v", host.Hostname, host.StorageUsage)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestComputeGuestCountsHandlesZeroBaselines(t *testing.T) {
|
|
cfg := MockConfig{
|
|
VMsPerNode: 0,
|
|
LXCsPerNode: 0,
|
|
}
|
|
|
|
roles := []string{"vm-heavy", "container-heavy", "light", "mixed"}
|
|
for _, role := range roles {
|
|
vmCount, lxcCount := computeGuestCounts(cfg, role)
|
|
if vmCount < 0 || lxcCount < 0 {
|
|
t.Fatalf("expected non-negative counts for role %q, got vm=%d lxc=%d", role, vmCount, lxcCount)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildFixtureStateWithZeroGuestBaselinesDoesNotPanic(t *testing.T) {
|
|
cfg := DefaultConfig
|
|
cfg.NodeCount = 4
|
|
cfg.VMsPerNode = 0
|
|
cfg.LXCsPerNode = 0
|
|
cfg.DockerHostCount = 0
|
|
cfg.GenericHostCount = 0
|
|
cfg.K8sClusterCount = 0
|
|
|
|
for i := 0; i < 20; i++ {
|
|
func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
t.Fatalf("buildFixtureState panicked on iteration %d: %v", i, r)
|
|
}
|
|
}()
|
|
_ = buildFixtureState(cfg)
|
|
}()
|
|
}
|
|
}
|
|
|
|
func TestBuildFixtureStateIncludesSwarmServices(t *testing.T) {
|
|
cfg := DefaultConfig
|
|
cfg.DockerHostCount = 4
|
|
cfg.DockerContainersPerHost = 6
|
|
cfg.RandomMetrics = false
|
|
|
|
data := buildFixtureState(cfg)
|
|
|
|
found := false
|
|
for _, host := range data.DockerHosts {
|
|
if len(host.Services) == 0 {
|
|
continue
|
|
}
|
|
if host.Swarm == nil {
|
|
t.Fatalf("expected swarm metadata for host %s", host.ID)
|
|
}
|
|
if len(host.Tasks) == 0 {
|
|
t.Fatalf("expected tasks for service host %s", host.ID)
|
|
}
|
|
if len(host.Secrets) == 0 {
|
|
t.Fatalf("expected secrets for service host %s", host.ID)
|
|
}
|
|
if len(host.Configs) == 0 {
|
|
t.Fatalf("expected configs for service host %s", host.ID)
|
|
}
|
|
if len(host.Nodes) == 0 {
|
|
t.Fatalf("expected swarm nodes for service host %s", host.ID)
|
|
}
|
|
found = true
|
|
break
|
|
}
|
|
|
|
if !found {
|
|
t.Fatalf("expected at least one docker host with swarm services")
|
|
}
|
|
}
|
|
|
|
func TestBuildFixtureStateIncludesHostAgents(t *testing.T) {
|
|
cfg := DefaultConfig
|
|
cfg.GenericHostCount = 5
|
|
cfg.K8sClusterCount = 0
|
|
cfg.RandomMetrics = false
|
|
|
|
data := buildFixtureState(cfg)
|
|
|
|
expectedMin := cfg.GenericHostCount + cfg.NodeCount
|
|
if len(data.Hosts) < expectedMin {
|
|
t.Fatalf("expected at least %d host agents, got %d", expectedMin, len(data.Hosts))
|
|
}
|
|
|
|
for _, host := range data.Hosts {
|
|
if host.ID == "" {
|
|
t.Fatalf("host agent missing id: %+v", host)
|
|
}
|
|
if host.Hostname == "" {
|
|
t.Fatalf("host agent missing hostname: %+v", host)
|
|
}
|
|
if host.Status == "" {
|
|
t.Fatalf("host agent missing status: %+v", host)
|
|
}
|
|
if host.Memory.Total > 0 {
|
|
if host.Memory.Cache <= 0 {
|
|
t.Fatalf("host agent %s should report reclaimable cache so the memory split is exercisable: %+v", host.ID, host.Memory)
|
|
}
|
|
if sum := host.Memory.Used + host.Memory.Cache + host.Memory.Free; sum > host.Memory.Total {
|
|
t.Fatalf("host agent %s memory used+cache+free %d exceeds total %d", host.ID, sum, host.Memory.Total)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildFixtureStatePreservesStandaloneHostAgentsWhenLinkingNodes(t *testing.T) {
|
|
cfg := DefaultConfig
|
|
cfg.NodeCount = 3
|
|
cfg.GenericHostCount = 4
|
|
cfg.K8sClusterCount = 0
|
|
cfg.RandomMetrics = false
|
|
|
|
data := buildFixtureState(cfg)
|
|
|
|
standalone := 0
|
|
proxmoxLinked := 0
|
|
for _, host := range data.Hosts {
|
|
if host.LinkedNodeID != "" {
|
|
proxmoxLinked++
|
|
} else {
|
|
standalone++
|
|
}
|
|
}
|
|
|
|
if standalone != cfg.GenericHostCount {
|
|
t.Fatalf("expected %d standalone agent hosts, got %d", cfg.GenericHostCount, standalone)
|
|
}
|
|
if proxmoxLinked != cfg.NodeCount {
|
|
t.Fatalf("expected %d Proxmox-linked agent hosts, got %d", cfg.NodeCount, proxmoxLinked)
|
|
}
|
|
}
|
|
|
|
func TestGenerateVMStoppedPreservesConfiguredCapacity(t *testing.T) {
|
|
cfg := DefaultConfig
|
|
cfg.StoppedPercent = 1
|
|
|
|
vm := generateVM("node-01", "mock-cluster", 1001, cfg)
|
|
|
|
if vm.Status != "stopped" {
|
|
t.Fatalf("expected stopped VM, got %q", vm.Status)
|
|
}
|
|
if vm.Memory.Total <= 0 {
|
|
t.Fatalf("expected stopped VM to keep configured memory total, got %d", vm.Memory.Total)
|
|
}
|
|
if vm.Memory.Used != 0 || vm.Memory.Usage != 0 {
|
|
t.Fatalf("expected stopped VM memory usage to be zero, got used=%d usage=%f", vm.Memory.Used, vm.Memory.Usage)
|
|
}
|
|
if vm.Memory.Free != vm.Memory.Total {
|
|
t.Fatalf("expected stopped VM free memory %d to equal total %d", vm.Memory.Free, vm.Memory.Total)
|
|
}
|
|
}
|
|
|
|
func TestGenerateContainerStoppedPreservesConfiguredCapacity(t *testing.T) {
|
|
cfg := DefaultConfig
|
|
cfg.StoppedPercent = 1
|
|
|
|
ct := generateContainer("node-01", "mock-cluster", 2001, cfg)
|
|
|
|
if ct.Status != "stopped" {
|
|
t.Fatalf("expected stopped container, got %q", ct.Status)
|
|
}
|
|
if ct.Memory.Total <= 0 {
|
|
t.Fatalf("expected stopped container to keep configured memory total, got %d", ct.Memory.Total)
|
|
}
|
|
if ct.Memory.Used != 0 || ct.Memory.Usage != 0 {
|
|
t.Fatalf("expected stopped container memory usage to be zero, got used=%d usage=%f", ct.Memory.Used, ct.Memory.Usage)
|
|
}
|
|
if ct.Memory.Free != ct.Memory.Total {
|
|
t.Fatalf("expected stopped container free memory %d to equal total %d", ct.Memory.Free, ct.Memory.Total)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeMockBlendWeight_ComposesAcrossUpdateInterval(t *testing.T) {
|
|
perMinuteWeight := 0.22
|
|
perTickWeight := normalizeMockBlendWeight(perMinuteWeight, defaultMockUpdateInterval, time.Minute)
|
|
compounded := 1 - math.Pow(1-perTickWeight, float64(time.Minute/defaultMockUpdateInterval))
|
|
|
|
if perTickWeight >= perMinuteWeight {
|
|
t.Fatalf("expected per-tick weight %.6f to be less than per-minute weight %.6f", perTickWeight, perMinuteWeight)
|
|
}
|
|
if math.Abs(compounded-perMinuteWeight) > 0.01 {
|
|
t.Fatalf("expected compounded weight %.6f to stay close to %.6f", compounded, perMinuteWeight)
|
|
}
|
|
}
|
|
|
|
func TestBuildFixtureStateLinksAllNodesToHostAgents(t *testing.T) {
|
|
cfg := DefaultConfig
|
|
cfg.NodeCount = 7
|
|
cfg.GenericHostCount = 2
|
|
cfg.RandomMetrics = false
|
|
|
|
data := buildFixtureState(cfg)
|
|
|
|
if len(data.Hosts) < cfg.NodeCount {
|
|
t.Fatalf("expected enough hosts to link all nodes, got hosts=%d nodes=%d", len(data.Hosts), cfg.NodeCount)
|
|
}
|
|
|
|
hostsByID := make(map[string]models.Host, len(data.Hosts))
|
|
for _, host := range data.Hosts {
|
|
hostsByID[host.ID] = host
|
|
}
|
|
|
|
for _, node := range data.Nodes {
|
|
if node.LinkedAgentID == "" {
|
|
t.Fatalf("node %s is missing linked host agent id", node.ID)
|
|
}
|
|
host, ok := hostsByID[node.LinkedAgentID]
|
|
if !ok {
|
|
t.Fatalf("node %s linked host %s not found", node.ID, node.LinkedAgentID)
|
|
}
|
|
if host.LinkedNodeID != node.ID {
|
|
t.Fatalf("host %s linkedNodeID=%q, want %q", host.ID, host.LinkedNodeID, node.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildFixtureStatePopulatesHostIORates(t *testing.T) {
|
|
cfg := DefaultConfig
|
|
cfg.NodeCount = 6
|
|
cfg.GenericHostCount = 1
|
|
cfg.RandomMetrics = true
|
|
|
|
data := buildFixtureState(cfg)
|
|
|
|
for _, host := range data.Hosts {
|
|
if host.Status == "offline" {
|
|
if host.NetInRate != 0 || host.NetOutRate != 0 || host.DiskReadRate != 0 || host.DiskWriteRate != 0 {
|
|
t.Fatalf("offline host %s should have zero I/O rates", host.ID)
|
|
}
|
|
continue
|
|
}
|
|
if host.NetInRate <= 0 || host.NetOutRate <= 0 {
|
|
t.Fatalf("host %s missing network rates: in=%f out=%f", host.ID, host.NetInRate, host.NetOutRate)
|
|
}
|
|
if host.DiskReadRate <= 0 || host.DiskWriteRate <= 0 {
|
|
t.Fatalf("host %s missing disk rates: read=%f write=%f", host.ID, host.DiskReadRate, host.DiskWriteRate)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildFixtureStatePopulatesDockerHostIORates(t *testing.T) {
|
|
cfg := DefaultConfig
|
|
cfg.DockerHostCount = 3
|
|
cfg.RandomMetrics = true
|
|
|
|
data := buildFixtureState(cfg)
|
|
|
|
if len(data.DockerHosts) == 0 {
|
|
t.Fatal("expected docker hosts in mock data")
|
|
}
|
|
|
|
for _, host := range data.DockerHosts {
|
|
if host.Status == "offline" {
|
|
if host.NetInRate != 0 || host.NetOutRate != 0 || host.DiskReadRate != 0 || host.DiskWriteRate != 0 {
|
|
t.Fatalf("offline docker host %s should have zero I/O rates", host.ID)
|
|
}
|
|
if host.Temperature != nil {
|
|
t.Fatalf("offline docker host %s should not report temperature", host.ID)
|
|
}
|
|
continue
|
|
}
|
|
if host.NetInRate <= 0 || host.NetOutRate <= 0 {
|
|
t.Fatalf("docker host %s missing network rates: in=%f out=%f", host.ID, host.NetInRate, host.NetOutRate)
|
|
}
|
|
if host.DiskReadRate <= 0 || host.DiskWriteRate <= 0 {
|
|
t.Fatalf("docker host %s missing disk rates: read=%f write=%f", host.ID, host.DiskReadRate, host.DiskWriteRate)
|
|
}
|
|
if host.Temperature == nil {
|
|
t.Fatalf("docker host %s missing temperature", host.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildFixtureStatePopulatesKubernetesUsageMetrics(t *testing.T) {
|
|
cfg := DefaultConfig
|
|
cfg.K8sClusterCount = 1
|
|
cfg.K8sNodesPerCluster = 4
|
|
cfg.K8sPodsPerCluster = 24
|
|
cfg.RandomMetrics = false
|
|
|
|
data := buildFixtureState(cfg)
|
|
if len(data.KubernetesClusters) != 1 {
|
|
t.Fatalf("expected exactly one kubernetes cluster, got %d", len(data.KubernetesClusters))
|
|
}
|
|
|
|
cluster := data.KubernetesClusters[0]
|
|
readyNodeNames := make(map[string]struct{}, len(cluster.Nodes))
|
|
for _, node := range cluster.Nodes {
|
|
if node.Ready {
|
|
readyNodeNames[strings.TrimSpace(node.Name)] = struct{}{}
|
|
}
|
|
}
|
|
|
|
runningPodsWithUsage := 0
|
|
for _, pod := range cluster.Pods {
|
|
if !strings.EqualFold(pod.Phase, "running") {
|
|
continue
|
|
}
|
|
if _, ok := readyNodeNames[strings.TrimSpace(pod.NodeName)]; !ok {
|
|
continue
|
|
}
|
|
if pod.UsageCPUMilliCores <= 0 || pod.UsageMemoryBytes <= 0 {
|
|
t.Fatalf("running pod %s missing cpu/memory usage: %+v", pod.Name, pod)
|
|
}
|
|
if pod.NetInRate <= 0 || pod.NetOutRate <= 0 {
|
|
t.Fatalf("running pod %s missing network rates: in=%f out=%f", pod.Name, pod.NetInRate, pod.NetOutRate)
|
|
}
|
|
if pod.EphemeralStorageCapacityBytes <= 0 || pod.EphemeralStorageUsedBytes <= 0 {
|
|
t.Fatalf("running pod %s missing ephemeral storage usage: %+v", pod.Name, pod)
|
|
}
|
|
if pod.DiskUsagePercent <= 0 {
|
|
t.Fatalf("running pod %s missing disk usage percent: %+v", pod.Name, pod)
|
|
}
|
|
runningPodsWithUsage++
|
|
}
|
|
if runningPodsWithUsage == 0 {
|
|
t.Fatal("expected at least one running kubernetes pod with usage metrics")
|
|
}
|
|
|
|
readyNodesWithUsage := 0
|
|
for _, node := range cluster.Nodes {
|
|
if !node.Ready {
|
|
continue
|
|
}
|
|
if node.UsageCPUMilliCores <= 0 || node.UsageMemoryBytes <= 0 {
|
|
t.Fatalf("ready node %s missing usage metrics: %+v", node.Name, node)
|
|
}
|
|
readyNodesWithUsage++
|
|
}
|
|
if readyNodesWithUsage == 0 {
|
|
t.Fatal("expected at least one ready kubernetes node with usage metrics")
|
|
}
|
|
}
|
|
|
|
func TestBuildFixtureStateIncludesKubernetesDeploymentAPIMetadata(t *testing.T) {
|
|
cfg := DefaultConfig
|
|
cfg.K8sClusterCount = 1
|
|
cfg.K8sNodesPerCluster = 3
|
|
cfg.K8sPodsPerCluster = 8
|
|
cfg.K8sDeploymentsPerCluster = 6
|
|
cfg.RandomMetrics = false
|
|
|
|
data := buildFixtureState(cfg)
|
|
if len(data.KubernetesClusters) != 1 {
|
|
t.Fatalf("expected exactly one kubernetes cluster, got %d", len(data.KubernetesClusters))
|
|
}
|
|
|
|
cluster := data.KubernetesClusters[0]
|
|
if len(cluster.Deployments) == 0 {
|
|
t.Fatal("expected kubernetes deployment inventory")
|
|
}
|
|
for _, deployment := range cluster.Deployments {
|
|
if deployment.UID == "" {
|
|
t.Fatalf("deployment missing uid: %+v", deployment)
|
|
}
|
|
if deployment.CreatedAt.IsZero() {
|
|
t.Fatalf("deployment missing creation metadata: %+v", deployment)
|
|
}
|
|
if deployment.ObservedGeneration == 0 {
|
|
t.Fatalf("deployment missing observed generation: %+v", deployment)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildFixtureStateCreatesHostEntriesForKubernetesNodes(t *testing.T) {
|
|
cfg := DefaultConfig
|
|
cfg.K8sClusterCount = 1
|
|
cfg.K8sNodesPerCluster = 3
|
|
cfg.K8sPodsPerCluster = 8
|
|
cfg.NodeCount = 0
|
|
cfg.RandomMetrics = false
|
|
|
|
data := buildFixtureState(cfg)
|
|
if len(data.KubernetesClusters) == 0 {
|
|
t.Fatal("expected kubernetes clusters in mock data")
|
|
}
|
|
|
|
hostsByName := make(map[string]models.Host, len(data.Hosts))
|
|
for _, host := range data.Hosts {
|
|
hostsByName[strings.ToLower(strings.TrimSpace(host.Hostname))] = host
|
|
}
|
|
|
|
for _, node := range data.KubernetesClusters[0].Nodes {
|
|
host, ok := hostsByName[strings.ToLower(strings.TrimSpace(node.Name))]
|
|
if !ok {
|
|
t.Fatalf("expected host entry for kubernetes node %s", node.Name)
|
|
}
|
|
if host.NetInRate <= 0 || host.NetOutRate <= 0 {
|
|
t.Fatalf("kubernetes node host %s missing network rates: in=%f out=%f", host.Hostname, host.NetInRate, host.NetOutRate)
|
|
}
|
|
if host.DiskReadRate <= 0 || host.DiskWriteRate <= 0 {
|
|
t.Fatalf("kubernetes node host %s missing disk rates: read=%f write=%f", host.Hostname, host.DiskReadRate, host.DiskWriteRate)
|
|
}
|
|
if len(host.Sensors.TemperatureCelsius) == 0 {
|
|
t.Fatalf("kubernetes node host %s missing temperature sensors", host.Hostname)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildFixtureStateIncludesKubernetesStorageInventory(t *testing.T) {
|
|
cfg := DefaultConfig
|
|
cfg.K8sClusterCount = 1
|
|
cfg.K8sNodesPerCluster = 3
|
|
cfg.K8sPodsPerCluster = 8
|
|
cfg.RandomMetrics = false
|
|
|
|
data := buildFixtureState(cfg)
|
|
if len(data.KubernetesClusters) != 1 {
|
|
t.Fatalf("expected exactly one kubernetes cluster, got %d", len(data.KubernetesClusters))
|
|
}
|
|
|
|
cluster := data.KubernetesClusters[0]
|
|
if len(cluster.StorageClasses) == 0 {
|
|
t.Fatal("expected kubernetes storage class inventory")
|
|
}
|
|
if len(cluster.PersistentVolumes) == 0 {
|
|
t.Fatal("expected kubernetes persistent volume inventory")
|
|
}
|
|
if len(cluster.PersistentVolumeClaims) == 0 {
|
|
t.Fatal("expected kubernetes persistent volume claim inventory")
|
|
}
|
|
}
|
|
|
|
func TestBuildFixtureStateIncludesKubernetesNetworkingInventory(t *testing.T) {
|
|
cfg := DefaultConfig
|
|
cfg.K8sClusterCount = 1
|
|
cfg.K8sNodesPerCluster = 3
|
|
cfg.K8sPodsPerCluster = 8
|
|
cfg.RandomMetrics = false
|
|
|
|
data := buildFixtureState(cfg)
|
|
if len(data.KubernetesClusters) != 1 {
|
|
t.Fatalf("expected exactly one kubernetes cluster, got %d", len(data.KubernetesClusters))
|
|
}
|
|
|
|
cluster := data.KubernetesClusters[0]
|
|
if len(cluster.Services) == 0 {
|
|
t.Fatal("expected kubernetes service inventory")
|
|
}
|
|
foundPublishedService := false
|
|
for _, service := range cluster.Services {
|
|
if service.ServiceType != "NodePort" || len(service.ExternalIPs) == 0 {
|
|
continue
|
|
}
|
|
for _, port := range service.Ports {
|
|
if port.NodePort > 0 {
|
|
foundPublishedService = true
|
|
}
|
|
}
|
|
}
|
|
if !foundPublishedService {
|
|
t.Fatal("expected kubernetes service inventory to include node-port/external service metadata")
|
|
}
|
|
if len(cluster.Ingresses) == 0 {
|
|
t.Fatal("expected kubernetes ingress inventory")
|
|
}
|
|
if len(cluster.EndpointSlices) == 0 {
|
|
t.Fatal("expected kubernetes endpoint slice inventory")
|
|
}
|
|
}
|
|
|
|
func TestMockStateIncludesHostAgents(t *testing.T) {
|
|
mustSetEnabled(t, true)
|
|
t.Cleanup(func() {
|
|
mustSetEnabled(t, false)
|
|
})
|
|
|
|
state := CurrentFixtureGraph().State
|
|
if len(state.Hosts) == 0 {
|
|
t.Fatalf("expected hosts in mock state, got %d", len(state.Hosts))
|
|
}
|
|
|
|
frontend := state.ToFrontend()
|
|
if frontend.Resources == nil {
|
|
t.Fatal("expected canonical frontend resources slice to be initialized")
|
|
}
|
|
if frontend.ConnectedInfrastructure == nil {
|
|
t.Fatal("expected canonical connectedInfrastructure slice to be initialized")
|
|
}
|
|
}
|
|
|
|
func TestUpdateMetricsMaintainsServiceHealth(t *testing.T) {
|
|
cfg := DefaultConfig
|
|
cfg.DockerHostCount = 3
|
|
cfg.DockerContainersPerHost = 6
|
|
|
|
data := buildFixtureState(cfg)
|
|
updateFixtureStateMetrics(&data, cfg)
|
|
|
|
for _, host := range data.DockerHosts {
|
|
if len(host.Services) == 0 {
|
|
continue
|
|
}
|
|
if host.Swarm == nil {
|
|
t.Fatalf("expected swarm metadata for host %s after update", host.ID)
|
|
}
|
|
|
|
for _, svc := range host.Services {
|
|
if svc.DesiredTasks < 0 {
|
|
t.Fatalf("service %s has negative desired tasks", svc.Name)
|
|
}
|
|
if svc.RunningTasks < 0 {
|
|
t.Fatalf("service %s has negative running tasks", svc.Name)
|
|
}
|
|
if svc.RunningTasks > svc.DesiredTasks && svc.DesiredTasks > 0 {
|
|
t.Fatalf("service %s has running (%d) > desired (%d)", svc.Name, svc.RunningTasks, svc.DesiredTasks)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildFixtureStateIncludesPMGInstances(t *testing.T) {
|
|
cfg := DefaultConfig
|
|
|
|
data := buildFixtureState(cfg)
|
|
|
|
if len(data.PMGInstances) == 0 {
|
|
t.Fatalf("expected PMG instances in mock data")
|
|
}
|
|
|
|
for _, inst := range data.PMGInstances {
|
|
if inst.Name == "" {
|
|
t.Fatalf("PMG instance missing name: %+v", inst)
|
|
}
|
|
if inst.Status == "" {
|
|
t.Fatalf("PMG instance missing status: %+v", inst)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCloneStateCopiesPMGInstances(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
PMGInstances: []models.PMGInstance{
|
|
{ID: "pmg-test", Name: "pmg-test", Status: "online"},
|
|
},
|
|
}
|
|
|
|
cloned := cloneState(state)
|
|
|
|
if len(cloned.PMGInstances) != 1 {
|
|
t.Fatalf("expected cloned state to include PMG instances, got %d", len(cloned.PMGInstances))
|
|
}
|
|
|
|
cloned.PMGInstances[0].Name = "modified"
|
|
if state.PMGInstances[0].Name == "modified" {
|
|
t.Fatal("expected PMG instances to be deep-copied")
|
|
}
|
|
}
|
|
|
|
func TestCloneStatePreservesIgnoredInfrastructureEntries(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
RemovedDockerHosts: []models.RemovedDockerHost{
|
|
{ID: "docker-1", Hostname: "docker.local"},
|
|
},
|
|
RemovedHostAgents: []models.RemovedHostAgent{
|
|
{ID: "agent-1", Hostname: "host.local"},
|
|
},
|
|
RemovedKubernetesClusters: []models.RemovedKubernetesCluster{
|
|
{ID: "cluster-1", Name: "cluster.local"},
|
|
},
|
|
}
|
|
|
|
cloned := cloneState(state)
|
|
|
|
if len(cloned.RemovedDockerHosts) != 1 {
|
|
t.Fatalf("expected cloned state to include removed docker hosts, got %d", len(cloned.RemovedDockerHosts))
|
|
}
|
|
if len(cloned.RemovedHostAgents) != 1 {
|
|
t.Fatalf("expected cloned state to include removed host agents, got %d", len(cloned.RemovedHostAgents))
|
|
}
|
|
if len(cloned.RemovedKubernetesClusters) != 1 {
|
|
t.Fatalf("expected cloned state to include removed kubernetes clusters, got %d", len(cloned.RemovedKubernetesClusters))
|
|
}
|
|
|
|
cloned.RemovedDockerHosts[0].Hostname = "mutated-docker.local"
|
|
cloned.RemovedHostAgents[0].Hostname = "mutated-host.local"
|
|
cloned.RemovedKubernetesClusters[0].Name = "mutated-cluster.local"
|
|
|
|
if state.RemovedDockerHosts[0].Hostname == "mutated-docker.local" {
|
|
t.Fatal("expected removed docker hosts to be copied")
|
|
}
|
|
if state.RemovedHostAgents[0].Hostname == "mutated-host.local" {
|
|
t.Fatal("expected removed host agents to be copied")
|
|
}
|
|
if state.RemovedKubernetesClusters[0].Name == "mutated-cluster.local" {
|
|
t.Fatal("expected removed kubernetes clusters to be copied")
|
|
}
|
|
}
|
|
|
|
func TestBuildFixtureStateInitializesIgnoredInfrastructureSlices(t *testing.T) {
|
|
data := buildFixtureState(DefaultConfig)
|
|
|
|
if data.RemovedDockerHosts == nil {
|
|
t.Fatal("expected buildFixtureState to initialize RemovedDockerHosts")
|
|
}
|
|
if data.RemovedHostAgents == nil {
|
|
t.Fatal("expected buildFixtureState to initialize RemovedHostAgents")
|
|
}
|
|
if data.RemovedKubernetesClusters == nil {
|
|
t.Fatal("expected buildFixtureState to initialize RemovedKubernetesClusters")
|
|
}
|
|
}
|
|
|
|
// collectFixtureIdentities gathers every identity-bearing ID the fixture
|
|
// graph produces, keyed by category. Metric series resource IDs in the mock
|
|
// metrics store derive from these, so any per-boot churn here orphans stored
|
|
// history and breaks resource continuity across backend restarts.
|
|
func collectFixtureIdentities(graph FixtureGraph) map[string][]string {
|
|
ids := map[string][]string{}
|
|
add := func(category, id string) {
|
|
ids[category] = append(ids[category], id)
|
|
}
|
|
|
|
state := graph.State
|
|
for _, node := range state.Nodes {
|
|
add("node", node.ID)
|
|
}
|
|
for _, vm := range state.VMs {
|
|
add("vm", vm.ID)
|
|
}
|
|
for _, ct := range state.Containers {
|
|
add("container", ct.ID)
|
|
}
|
|
for _, host := range state.Hosts {
|
|
add("host", host.ID)
|
|
add("host-hostname", host.Hostname)
|
|
}
|
|
for _, dockerHost := range state.DockerHosts {
|
|
add("docker-host", dockerHost.ID)
|
|
for _, c := range dockerHost.Containers {
|
|
add("docker-container", c.ID)
|
|
}
|
|
}
|
|
for _, cluster := range state.KubernetesClusters {
|
|
add("k8s-cluster", cluster.ID)
|
|
for _, node := range cluster.Nodes {
|
|
add("k8s-node", node.UID)
|
|
}
|
|
for _, pod := range cluster.Pods {
|
|
add("k8s-pod", pod.UID)
|
|
}
|
|
for _, deployment := range cluster.Deployments {
|
|
add("k8s-deployment", deployment.UID)
|
|
}
|
|
for _, svc := range cluster.Services {
|
|
add("k8s-service", svc.UID)
|
|
}
|
|
}
|
|
for _, storage := range state.Storage {
|
|
add("storage", storage.ID)
|
|
}
|
|
for _, ceph := range state.CephClusters {
|
|
add("ceph-cluster", ceph.ID)
|
|
add("ceph-fsid", ceph.FSID)
|
|
}
|
|
for _, disk := range state.PhysicalDisks {
|
|
add("physical-disk", disk.ID)
|
|
}
|
|
|
|
for category := range ids {
|
|
sort.Strings(ids[category])
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// TestFixtureIdentityStableAcrossBoots simulates two backend boots (the
|
|
// package-global rand source is in a different state for each build, exactly
|
|
// as two freshly seeded processes would be) and requires every identity set
|
|
// to match. Guards the generator's "stable IDs" contract end to end.
|
|
func TestFixtureIdentityStableAcrossBoots(t *testing.T) {
|
|
now := time.Date(2026, time.June, 10, 12, 0, 0, 0, time.UTC)
|
|
|
|
first := collectFixtureIdentities(buildFixtureGraph(DefaultConfig, now))
|
|
second := collectFixtureIdentities(buildFixtureGraph(DefaultConfig, now))
|
|
|
|
categories := map[string]struct{}{}
|
|
for category := range first {
|
|
categories[category] = struct{}{}
|
|
}
|
|
for category := range second {
|
|
categories[category] = struct{}{}
|
|
}
|
|
|
|
for category := range categories {
|
|
diff := diffIdentitySets(first[category], second[category])
|
|
if diff != "" {
|
|
t.Errorf("%s identities churned across boots:\n%s", category, diff)
|
|
}
|
|
}
|
|
}
|
|
|
|
func diffIdentitySets(first, second []string) string {
|
|
firstSet := make(map[string]struct{}, len(first))
|
|
for _, id := range first {
|
|
firstSet[id] = struct{}{}
|
|
}
|
|
secondSet := make(map[string]struct{}, len(second))
|
|
for _, id := range second {
|
|
secondSet[id] = struct{}{}
|
|
}
|
|
|
|
var lines []string
|
|
for _, id := range first {
|
|
if _, ok := secondSet[id]; !ok {
|
|
lines = append(lines, fmt.Sprintf(" only in first boot: %s", id))
|
|
}
|
|
}
|
|
for _, id := range second {
|
|
if _, ok := firstSet[id]; !ok {
|
|
lines = append(lines, fmt.Sprintf(" only in second boot: %s", id))
|
|
}
|
|
}
|
|
|
|
if len(lines) == 0 {
|
|
return ""
|
|
}
|
|
sort.Strings(lines)
|
|
out := ""
|
|
for _, line := range lines {
|
|
out += line + "\n"
|
|
}
|
|
return out
|
|
}
|