package models import ( "errors" "strings" "testing" "time" ) func TestStateClearAllDockerHosts(t *testing.T) { state := &State{ DockerHosts: []DockerHost{ {ID: "h1"}, {ID: "h2"}, }, } count := state.ClearAllDockerHosts() if count != 2 { t.Fatalf("count = %d, want 2", count) } if len(state.DockerHosts) != 0 { t.Fatalf("DockerHosts = %#v, want empty", state.DockerHosts) } if state.DockerHosts == nil { t.Fatal("DockerHosts should remain an initialized empty slice") } } func TestStateMergePVETagStyleReplacesInstanceStyle(t *testing.T) { state := NewState() state.MergePVETagStyle("pve-a", PVETagStyle{ Colors: map[string]string{"Production": "#00ff00"}, CaseSensitive: true, }) state.MergePVETagStyle("pve-b", PVETagStyle{ Colors: map[string]string{"backup": "#112233"}, }) snapshot := state.GetSnapshot() if got := snapshot.PVETagStyles["pve-a"].Colors["Production"]; got != "#00ff00" { t.Fatalf("pve-a Production color = %q, want #00ff00", got) } if !snapshot.PVETagStyles["pve-a"].CaseSensitive { t.Fatal("expected pve-a style to preserve case-sensitive mode") } if got := snapshot.PVETagColors["backup"]; got != "#112233" { t.Fatalf("aggregate backup color = %q, want #112233", got) } state.MergePVETagStyle("pve-a", PVETagStyle{}) snapshot = state.GetSnapshot() if _, ok := snapshot.PVETagStyles["pve-a"].Colors["Production"]; ok { t.Fatalf("expected pve-a Production color to be removed, got %#v", snapshot.PVETagStyles["pve-a"]) } if _, ok := snapshot.PVETagColors["Production"]; ok { t.Fatalf("expected aggregate Production color to be removed, got %#v", snapshot.PVETagColors) } if got := snapshot.PVETagColors["backup"]; got != "#112233" { t.Fatalf("aggregate backup color after replacement = %q, want #112233", got) } } func TestStateKubernetesClusterLifecycle(t *testing.T) { state := &State{} initial := KubernetesCluster{ ID: "c1", Name: "alpha", CustomDisplayName: "keep", Hidden: true, PendingUninstall: true, Status: "init", } state.UpsertKubernetesCluster(initial) update := KubernetesCluster{ ID: "c1", Name: "alpha", CustomDisplayName: "", Hidden: false, PendingUninstall: false, Status: "ready", } state.UpsertKubernetesCluster(update) clusters := state.GetKubernetesClusters() if len(clusters) != 1 { t.Fatalf("clusters = %#v, want 1", clusters) } if clusters[0].CustomDisplayName != "keep" { t.Fatalf("CustomDisplayName = %q, want keep", clusters[0].CustomDisplayName) } if !clusters[0].Hidden || !clusters[0].PendingUninstall { t.Fatalf("expected Hidden and PendingUninstall preserved") } if ok := state.SetKubernetesClusterStatus("c1", "ok"); !ok { t.Fatalf("SetKubernetesClusterStatus returned false") } if _, ok := state.SetKubernetesClusterHidden("c1", false); !ok { t.Fatalf("SetKubernetesClusterHidden returned false") } if _, ok := state.SetKubernetesClusterPendingUninstall("c1", false); !ok { t.Fatalf("SetKubernetesClusterPendingUninstall returned false") } if _, ok := state.SetKubernetesClusterCustomDisplayName("c1", "custom"); !ok { t.Fatalf("SetKubernetesClusterCustomDisplayName returned false") } removed, ok := state.RemoveKubernetesCluster("c1") if !ok || removed.ID != "c1" { t.Fatalf("RemoveKubernetesCluster = (%v, %v), want c1", removed, ok) } if _, ok := state.RemoveKubernetesCluster("missing"); ok { t.Fatalf("expected RemoveKubernetesCluster to fail for missing") } } func TestStateRemovedKubernetesClusters(t *testing.T) { state := &State{} t1 := time.Date(2024, 1, 1, 1, 0, 0, 0, time.UTC) t2 := time.Date(2024, 1, 2, 1, 0, 0, 0, time.UTC) state.AddRemovedKubernetesCluster(RemovedKubernetesCluster{ID: "c1", RemovedAt: t1}) state.AddRemovedKubernetesCluster(RemovedKubernetesCluster{ID: "c2", RemovedAt: t2}) state.AddRemovedKubernetesCluster(RemovedKubernetesCluster{ID: "c1", DisplayName: "updated", RemovedAt: t1}) entries := state.GetRemovedKubernetesClusters() if len(entries) != 2 { t.Fatalf("entries = %#v, want 2", entries) } if entries[0].ID != "c2" { t.Fatalf("entries[0].ID = %q, want c2", entries[0].ID) } state.RemoveRemovedKubernetesCluster("c1") entries = state.GetRemovedKubernetesClusters() if len(entries) != 1 || entries[0].ID != "c2" { t.Fatalf("entries = %#v, want c2 only", entries) } } func TestStateClearAllHosts(t *testing.T) { state := &State{ Hosts: []Host{{ID: "h1"}, {ID: "h2"}}, } count := state.ClearAllHosts() if count != 2 { t.Fatalf("count = %d, want 2", count) } if len(state.Hosts) != 0 { t.Fatalf("Hosts = %#v, want empty", state.Hosts) } if state.Hosts == nil { t.Fatal("Hosts should remain an initialized empty slice") } } func TestStateWholeSliceUpdatesPreserveInitializedEmptySlices(t *testing.T) { state := NewState() state.UpdateRecentlyResolved([]ResolvedAlert{{Alert: Alert{ID: "resolved-1"}}}) state.UpdateNodes([]Node{{ID: "node-1"}}) state.UpdateVMs([]VM{{ID: "vm-1"}}) state.UpdateContainers([]Container{{ID: "ct-1"}}) state.UpdateStorage([]Storage{{ID: "storage-1"}}) state.UpdatePBSInstances([]PBSInstance{{ID: "pbs-1"}}) state.UpdatePMGInstances([]PMGInstance{{ID: "pmg-1"}}) state.UpdateRecentlyResolved(nil) state.UpdateNodes(nil) state.UpdateVMs(nil) state.UpdateContainers(nil) state.UpdateStorage(nil) state.UpdatePBSInstances(nil) state.UpdatePMGInstances(nil) if state.RecentlyResolved == nil { t.Fatal("RecentlyResolved should remain an initialized empty slice") } if len(state.RecentlyResolved) != 0 { t.Fatalf("RecentlyResolved = %#v, want empty", state.RecentlyResolved) } if state.Nodes == nil { t.Fatal("Nodes should remain an initialized empty slice") } if len(state.Nodes) != 0 { t.Fatalf("Nodes = %#v, want empty", state.Nodes) } if state.VMs == nil { t.Fatal("VMs should remain an initialized empty slice") } if len(state.VMs) != 0 { t.Fatalf("VMs = %#v, want empty", state.VMs) } if state.Containers == nil { t.Fatal("Containers should remain an initialized empty slice") } if len(state.Containers) != 0 { t.Fatalf("Containers = %#v, want empty", state.Containers) } if state.Storage == nil { t.Fatal("Storage should remain an initialized empty slice") } if len(state.Storage) != 0 { t.Fatalf("Storage = %#v, want empty", state.Storage) } if state.PBSInstances == nil { t.Fatal("PBSInstances should remain an initialized empty slice") } if len(state.PBSInstances) != 0 { t.Fatalf("PBSInstances = %#v, want empty", state.PBSInstances) } if state.PMGInstances == nil { t.Fatal("PMGInstances should remain an initialized empty slice") } if len(state.PMGInstances) != 0 { t.Fatalf("PMGInstances = %#v, want empty", state.PMGInstances) } } func TestStateLinkNodeToHostAgent(t *testing.T) { state := &State{ Nodes: []Node{{ID: "n1"}}, } if ok := state.LinkNodeToHostAgent("n1", "h1"); !ok { t.Fatalf("LinkNodeToHostAgent returned false") } if state.Nodes[0].LinkedAgentID != "h1" { t.Fatalf("LinkedAgentID = %q, want h1", state.Nodes[0].LinkedAgentID) } if ok := state.LinkNodeToHostAgent("missing", "h1"); ok { t.Fatalf("expected false for missing node") } } func TestStateUnlinkNodesFromHostAgent(t *testing.T) { state := &State{ Nodes: []Node{ {ID: "n1", LinkedAgentID: "h1"}, {ID: "n2", LinkedAgentID: "h1"}, {ID: "n3", LinkedAgentID: "h2"}, }, } count := state.UnlinkNodesFromHostAgent("h1") if count != 2 { t.Fatalf("count = %d, want 2", count) } for _, node := range state.Nodes[:2] { if node.LinkedAgentID != "" { t.Fatalf("expected LinkedAgentID cleared, got %q", node.LinkedAgentID) } } } func TestStateUpdateNodesForInstanceSkipsAmbiguousHostAgentHostnameMatch(t *testing.T) { state := &State{ Hosts: []Host{ {ID: "host-a", Hostname: "pve1"}, {ID: "host-b", Hostname: "pve1.local"}, }, } state.UpdateNodesForInstance("cluster-a", []Node{ {ID: "node-1", Name: "pve1", Instance: "cluster-a"}, }) nodes := state.Nodes if len(nodes) != 1 { t.Fatalf("nodes = %#v, want 1", nodes) } if nodes[0].LinkedAgentID != "" { t.Fatalf("expected no auto-link for ambiguous hostname, got %q", nodes[0].LinkedAgentID) } } func TestStateUpdateNodesForInstanceLinksUniqueHostname(t *testing.T) { state := &State{ Hosts: []Host{ {ID: "host-a", Hostname: "pve1.local"}, }, } state.UpdateNodesForInstance("cluster-a", []Node{ {ID: "node-1", Name: "pve1", Instance: "cluster-a"}, }) nodes := state.Nodes if len(nodes) != 1 { t.Fatalf("nodes = %#v, want 1", nodes) } if nodes[0].LinkedAgentID != "host-a" { t.Fatalf("LinkedAgentID = %q, want host-a", nodes[0].LinkedAgentID) } if state.Hosts[0].LinkedNodeID != "node-1" { t.Fatalf("LinkedNodeID = %q, want node-1", state.Hosts[0].LinkedNodeID) } if state.Hosts[0].LinkedVMID != "" || state.Hosts[0].LinkedContainerID != "" { t.Fatalf("expected guest links cleared when host links to node") } } func TestUpdateNodesForInstancePreservesLinkWhenNodeIDChanges(t *testing.T) { state := &State{ Hosts: []Host{ {ID: "host-1", Hostname: "pve01", LinkedNodeID: "cluster-a-pve01-old"}, }, Nodes: []Node{ { ID: "cluster-a-pve01-old", Name: "pve01", Instance: "cluster-entry-a", ClusterName: "cluster-a", IsClusterMember: true, LinkedAgentID: "host-1", }, }, } state.UpdateNodesForInstance("cluster-entry-a", []Node{ { ID: "cluster-a-pve01-new", Name: "pve01", Instance: "cluster-entry-a", ClusterName: "cluster-a", IsClusterMember: true, Status: "online", }, }) if len(state.Nodes) != 1 { t.Fatalf("nodes = %#v, want exactly 1 node", state.Nodes) } if state.Nodes[0].ID != "cluster-a-pve01-new" { t.Fatalf("node id = %q, want cluster-a-pve01-new", state.Nodes[0].ID) } if state.Nodes[0].LinkedAgentID != "host-1" { t.Fatalf("linkedAgentID = %q, want host-1", state.Nodes[0].LinkedAgentID) } if state.Hosts[0].LinkedNodeID != "cluster-a-pve01-new" { t.Fatalf("host LinkedNodeID = %q, want cluster-a-pve01-new", state.Hosts[0].LinkedNodeID) } } func TestUpdateNodesForInstanceMergesStandaloneNodeIntoClusterNodeByEndpoint(t *testing.T) { state := &State{ Hosts: []Host{ { ID: "host-1", Hostname: "minipc.local", ReportIP: "10.0.0.5", LinkedNodeID: "cluster-homelab-minipc", NetworkInterfaces: []HostNetworkInterface{{Name: "eth0", Addresses: []string{"10.0.0.5/24"}}}, }, }, Nodes: []Node{ { ID: "cluster-homelab-minipc", Name: "minipc", Instance: "homelab-entry", ClusterName: "homelab", IsClusterMember: true, Host: "https://10.0.0.5:8006", LinkedAgentID: "host-1", Status: "online", }, }, } state.UpdateNodesForInstance("minipc-standalone", []Node{ { ID: "minipc-standalone-minipc", Name: "minipc", Instance: "minipc-standalone", Host: "https://10.0.0.5:8006", Status: "online", }, }) if len(state.Nodes) != 1 { t.Fatalf("nodes = %#v, want exactly 1 node", state.Nodes) } if state.Nodes[0].ID != "cluster-homelab-minipc" { t.Fatalf("node ID = %q, want cluster-homelab-minipc", state.Nodes[0].ID) } if state.Nodes[0].LinkedAgentID != "host-1" { t.Fatalf("LinkedAgentID = %q, want host-1", state.Nodes[0].LinkedAgentID) } if state.Hosts[0].LinkedNodeID != "cluster-homelab-minipc" { t.Fatalf("host LinkedNodeID = %q, want cluster-homelab-minipc", state.Hosts[0].LinkedNodeID) } } func TestStateUpdateNodesForInstancePrefersUniqueEndpointIPOverAmbiguousHostname(t *testing.T) { state := &State{ Hosts: []Host{ { ID: "host-a", Hostname: "minipc.local", ReportIP: "10.0.0.5", NetworkInterfaces: []HostNetworkInterface{ {Name: "eth0", Addresses: []string{"10.0.0.5/24"}}, }, }, { ID: "host-b", Hostname: "minipc.lab", ReportIP: "10.0.0.6", NetworkInterfaces: []HostNetworkInterface{ {Name: "eth0", Addresses: []string{"10.0.0.6/24"}}, }, }, }, } state.UpdateNodesForInstance("cluster-a", []Node{ { ID: "node-1", Name: "minipc", Instance: "cluster-a", Host: "https://10.0.0.5:8006", }, }) if len(state.Nodes) != 1 { t.Fatalf("nodes = %#v, want 1", state.Nodes) } if state.Nodes[0].LinkedAgentID != "host-a" { t.Fatalf("LinkedAgentID = %q, want host-a", state.Nodes[0].LinkedAgentID) } } func TestUpdateNodesForInstanceMergesNodeViewsAcrossEndpointFormsWhenLinkedHostMatches(t *testing.T) { state := &State{ Hosts: []Host{ { ID: "host-1", Hostname: "minipc.local", ReportIP: "10.0.0.5", LinkedNodeID: "minipc-ip-view", NetworkInterfaces: []HostNetworkInterface{ {Name: "eth0", Addresses: []string{"10.0.0.5/24"}}, }, }, }, Nodes: []Node{ { ID: "minipc-ip-view", Name: "minipc", Instance: "standalone-ip", Host: "https://10.0.0.5:8006", LinkedAgentID: "host-1", Status: "online", }, }, } state.UpdateNodesForInstance("standalone-hostname", []Node{ { ID: "minipc-hostname-view", Name: "minipc", Instance: "standalone-hostname", Host: "https://minipc.local:8006", Status: "online", }, }) if len(state.Nodes) != 1 { t.Fatalf("nodes = %#v, want exactly 1 node", state.Nodes) } if state.Nodes[0].ID != "minipc-ip-view" { t.Fatalf("node ID = %q, want minipc-ip-view", state.Nodes[0].ID) } if state.Nodes[0].LinkedAgentID != "host-1" { t.Fatalf("LinkedAgentID = %q, want host-1", state.Nodes[0].LinkedAgentID) } if state.Hosts[0].LinkedNodeID != "minipc-ip-view" { t.Fatalf("host LinkedNodeID = %q, want minipc-ip-view", state.Hosts[0].LinkedNodeID) } } func TestUpdateNodesForInstanceDeduplicatesLogicalNodeByClusterName(t *testing.T) { state := NewState() older := time.Now().Add(-2 * time.Minute) newer := time.Now() state.UpdateNodesForInstance("instance-a", []Node{ { ID: "node-old", Name: "pve01", Instance: "instance-a", ClusterName: "cluster-a", IsClusterMember: true, Status: "unknown", ConnectionHealth: "unknown", LastSeen: older, }, { ID: "node-new", Name: "pve01", Instance: "instance-a", ClusterName: "cluster-a", IsClusterMember: true, Status: "online", ConnectionHealth: "healthy", LastSeen: newer, }, }) if len(state.Nodes) != 1 { t.Fatalf("expected one logical node after dedupe, got %d (%#v)", len(state.Nodes), state.Nodes) } if state.Nodes[0].ID != "node-new" { t.Fatalf("node id = %q, want node-new", state.Nodes[0].ID) } if state.Nodes[0].Status != "online" { t.Fatalf("status = %q, want online", state.Nodes[0].Status) } } func TestUpdateNodesForInstancePrefersHealthyOnCrossInstanceClusterCollisionDifferentIDs(t *testing.T) { now := time.Now() state := &State{ Nodes: []Node{ { ID: "node-from-b", Name: "pve01", Instance: "instance-b", ClusterName: "cluster-a", IsClusterMember: true, Status: "online", ConnectionHealth: "healthy", LastSeen: now, }, }, } state.UpdateNodesForInstance("instance-a", []Node{ { ID: "node-from-a", Name: "pve01", Instance: "instance-a", ClusterName: "cluster-a", IsClusterMember: true, Status: "unknown", ConnectionHealth: "unknown", LastSeen: now.Add(-time.Minute), }, }) if len(state.Nodes) != 1 { t.Fatalf("nodes = %#v, want exactly one merged node", state.Nodes) } if state.Nodes[0].ID != "node-from-b" { t.Fatalf("node id = %q, want node-from-b", state.Nodes[0].ID) } if state.Nodes[0].Instance != "instance-b" { t.Fatalf("instance = %q, want instance-b", state.Nodes[0].Instance) } if state.Nodes[0].Status != "online" { t.Fatalf("status = %q, want online", state.Nodes[0].Status) } } func TestStateLinkHostAgentToNode(t *testing.T) { state := &State{ Hosts: []Host{ {ID: "h1", LinkedNodeID: "n1"}, {ID: "h2", LinkedVMID: "vm1", LinkedContainerID: "ct1"}, }, Nodes: []Node{ {ID: "n1", LinkedAgentID: "h1"}, {ID: "n2"}, }, } if err := state.LinkHostAgentToNode("h2", "n2"); err != nil { t.Fatalf("LinkHostAgentToNode error: %v", err) } if state.Hosts[1].LinkedNodeID != "n2" { t.Fatalf("LinkedNodeID = %q, want n2", state.Hosts[1].LinkedNodeID) } if state.Nodes[1].LinkedAgentID != "h2" { t.Fatalf("LinkedAgentID = %q, want h2", state.Nodes[1].LinkedAgentID) } if state.Hosts[1].LinkedVMID != "" || state.Hosts[1].LinkedContainerID != "" { t.Fatalf("expected VM/container links cleared") } if err := state.LinkHostAgentToNode("missing", "n2"); err == nil || !errors.Is(err, ErrHostAgentNotFound) || !strings.Contains(err.Error(), "missing") { t.Fatalf("expected host not found error, got %v", err) } if err := state.LinkHostAgentToNode("h2", "missing"); err == nil || !errors.Is(err, ErrNodeNotFound) || !strings.Contains(err.Error(), "missing") { t.Fatalf("expected node not found error, got %v", err) } } func TestStateUnlinkHostAgent(t *testing.T) { state := &State{ Hosts: []Host{{ID: "h1", LinkedNodeID: "n1", LinkedVMID: "vm", LinkedContainerID: "ct"}}, Nodes: []Node{{ID: "n1", LinkedAgentID: "h1"}}, } if ok := state.UnlinkHostAgent("h1"); !ok { t.Fatalf("UnlinkHostAgent returned false") } if state.Hosts[0].LinkedNodeID != "" || state.Hosts[0].LinkedVMID != "" || state.Hosts[0].LinkedContainerID != "" { t.Fatalf("expected host links cleared") } if state.Nodes[0].LinkedAgentID != "" { t.Fatalf("expected node link cleared") } if ok := state.UnlinkHostAgent("missing"); ok { t.Fatalf("expected false for missing host") } } func TestStateUpsertCephCluster(t *testing.T) { state := &State{} state.UpsertCephCluster(CephCluster{ID: "c1", Name: "b"}) state.UpsertCephCluster(CephCluster{ID: "c2", Name: "a"}) state.UpsertCephCluster(CephCluster{ID: "c1", Name: "c"}) if len(state.CephClusters) != 2 { t.Fatalf("clusters = %#v, want 2", state.CephClusters) } if state.CephClusters[0].Name != "a" || state.CephClusters[1].Name != "c" { t.Fatalf("clusters order = %#v, want a then c", state.CephClusters) } } func TestStateUpsertCephClusterMergesSourcesByFSID(t *testing.T) { state := &State{} state.UpsertCephCluster(CephCluster{ ID: "ceph-fsid-1", Instance: "pve5", Source: CephClusterSourceHostAgent, Name: "pve5 Ceph", FSID: "ceph-fsid-1", Pools: []CephPool{{ Name: "data_replication", PercentUsed: 61.1, }}, }) stored := state.UpsertCephCluster(CephCluster{ ID: "ceph-fsid-1", Instance: "pve-api", Source: CephClusterSourceProxmoxAPI, Name: "Ceph", FSID: "ceph-fsid-1", Pools: []CephPool{{ Name: "data_replication", PercentUsed: 61.1, }}, }) if stored.Instance != "pve-api" { t.Fatalf("stored instance = %q, want API instance pve-api", stored.Instance) } if stored.Source != CephClusterSourceProxmoxAPI { t.Fatalf("stored source = %q, want %q", stored.Source, CephClusterSourceProxmoxAPI) } if !containsString(stored.InstanceAliases, "pve5") { t.Fatalf("stored aliases = %#v, want host-agent instance pve5", stored.InstanceAliases) } snapshot := state.GetSnapshot() if len(snapshot.CephClusters) != 1 { t.Fatalf("CephClusters = %#v, want one merged physical cluster", snapshot.CephClusters) } storage := StorageFromCephPool(snapshot.CephClusters[0], snapshot.CephClusters[0].Pools[0]) wantAliases := []string{ CephPoolStorageID("pve5", "data_replication"), CephPoolStorageID("agent:pve5", "data_replication"), CephPoolStorageID("agent:pve-api", "data_replication"), } for _, want := range wantAliases { if !containsString(storage.AliasIDs, want) { t.Fatalf("storage aliases = %#v, want %q", storage.AliasIDs, want) } } } func TestStateSetHostCommandsEnabled(t *testing.T) { state := &State{ Hosts: []Host{{ID: "h1", CommandsEnabled: false}}, } if ok := state.SetHostCommandsEnabled("h1", true); !ok { t.Fatalf("SetHostCommandsEnabled returned false") } if !state.Hosts[0].CommandsEnabled { t.Fatalf("CommandsEnabled not updated") } if ok := state.SetHostCommandsEnabled("missing", true); ok { t.Fatalf("expected false for missing host") } } func TestStateContainers(t *testing.T) { now := time.Now() state := &State{ Containers: []Container{{ID: "ct1"}}, } containers := state.GetContainers() if len(containers) != 1 || containers[0].ID != "ct1" { t.Fatalf("containers = %#v, want ct1", containers) } containers[0].ID = "changed" if state.Containers[0].ID != "ct1" { t.Fatalf("state containers should not be modified by copy") } if ok := state.UpdateContainerDockerStatus("ct1", true, now); !ok { t.Fatalf("UpdateContainerDockerStatus returned false") } if !state.Containers[0].HasDocker { t.Fatalf("HasDocker not updated") } if ok := state.UpdateContainerDockerStatus("missing", true, now); ok { t.Fatalf("expected false for missing container") } }