Files
pulse/internal/models/state_additional_test.go
T
pulse-triage[bot] 449f3b1bc8 fix(monitoring): persist operator host node link intent
Commit link and unlink journal updates before publishing state, preserve manual selections across report and provider refresh boundaries, and reserve dormant owners across restart. Re-evaluate known automatic associations using provider names while retaining legacy unknown links.

Change-source: pulse-maintainer
2026-09-06 11:13:59 +01:00

741 lines
22 KiB
Go

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 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")
}
}
func TestSetHostNodeLinkIntentTransaction(t *testing.T) {
s := &State{
Hosts: []Host{{ID: "a", LinkedNodeID: "n", NodeLinkSource: "manual"}, {ID: "b"}},
Nodes: []Node{{ID: "n", LinkedAgentID: "a"}},
}
failure := errors.New("disk unavailable")
err := s.SetHostNodeLinkIntent("b", "n", func(changed []Host) error {
if len(changed) != 2 {
t.Fatalf("changed hosts = %d", len(changed))
}
if changed[0].NodeLinkSource != "unlinked" || changed[1].NodeLinkSource != "manual" {
t.Fatalf("replacement intent not explicit: %+v", changed)
}
return failure
})
if !errors.Is(err, failure) {
t.Fatalf("error = %v", err)
}
if s.Hosts[0].LinkedNodeID != "n" || s.Hosts[1].LinkedNodeID != "" || s.Nodes[0].LinkedAgentID != "a" {
t.Fatal("failed persistence changed visible state")
}
if err := s.SetHostNodeLinkIntent("b", "n", func([]Host) error { return nil }); err != nil {
t.Fatal(err)
}
if s.Hosts[0].NodeLinkSource != "unlinked" || s.Hosts[1].NodeLinkSource != "manual" || s.Nodes[0].LinkedAgentID != "b" {
t.Fatal("replacement did not commit both intents")
}
called := false
if err := s.SetHostNodeLinkIntent("b", "missing", func([]Host) error { called = true; return nil }); err == nil || called {
t.Fatal("invalid target reached persistence")
}
}
func TestUpsertHostClearsOnlyObsoleteReverseLinks(t *testing.T) {
s := &State{Nodes: []Node{
{ID: "old", LinkedAgentID: "agent"},
{ID: "current", LinkedAgentID: "agent"},
{ID: "other", LinkedAgentID: "other-agent"},
}}
s.UpsertHost(Host{ID: "agent", LinkedNodeID: "current", NodeLinkSource: "automatic"})
if s.Nodes[0].LinkedAgentID != "" || s.Nodes[1].LinkedAgentID != "agent" || s.Nodes[2].LinkedAgentID != "other-agent" {
t.Fatalf("incorrect reverse-link cleanup: %+v", s.Nodes)
}
}
func TestUpsertHostPreservesUnknownLegacyReverseLink(t *testing.T) {
s := &State{Nodes: []Node{{ID: "legacy-node", LinkedAgentID: "legacy-agent"}}}
s.UpsertHost(Host{ID: "legacy-agent", Hostname: "legacy"})
if s.Nodes[0].LinkedAgentID != "legacy-agent" {
t.Fatal("unmarked host update erased unknown legacy reverse link")
}
}