mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 08:33:03 +00:00
593 lines
22 KiB
Go
593 lines
22 KiB
Go
package unifiedresources
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ResolveHosts — core dedup logic
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestResolveHosts_Empty(t *testing.T) {
|
|
result := ResolveHosts(nil)
|
|
if len(result.Hosts) != 0 {
|
|
t.Fatalf("expected 0 hosts, got %d", len(result.Hosts))
|
|
}
|
|
}
|
|
|
|
func TestResolveHosts_NoDuplicates(t *testing.T) {
|
|
candidates := []HostCandidate{
|
|
{ID: "pve:n1", Name: "pve1", Type: "proxmox-pve", Source: "proxmox", Status: "online",
|
|
Identity: ResourceIdentity{Hostnames: []string{"pve1"}}},
|
|
{ID: "agent:h1", Name: "standalone", Type: "agent", Source: "agent", Status: "online",
|
|
Identity: ResourceIdentity{MachineID: "machine-2", Hostnames: []string{"standalone"}}},
|
|
}
|
|
result := ResolveHosts(candidates)
|
|
if len(result.Hosts) != 2 {
|
|
t.Fatalf("expected 2 hosts, got %d", len(result.Hosts))
|
|
}
|
|
}
|
|
|
|
func TestResolveHosts_MachineIDMerge(t *testing.T) {
|
|
candidates := []HostCandidate{
|
|
{ID: "pve:n1", Name: "pve1", Type: "proxmox-pve", Source: "proxmox", Status: "online",
|
|
Identity: ResourceIdentity{MachineID: "machine-1", Hostnames: []string{"pve1"}}},
|
|
{ID: "agent:h1", Name: "pve1", Type: "agent", Source: "agent", Status: "online",
|
|
Identity: ResourceIdentity{MachineID: "machine-1", Hostnames: []string{"pve1"}}},
|
|
}
|
|
result := ResolveHosts(candidates)
|
|
if len(result.Hosts) != 1 {
|
|
t.Fatalf("expected 1 merged host, got %d", len(result.Hosts))
|
|
}
|
|
h := result.Hosts[0]
|
|
if h.Name != "pve1" {
|
|
t.Fatalf("expected name pve1, got %q", h.Name)
|
|
}
|
|
if h.PrimaryType != "proxmox-pve" {
|
|
t.Fatalf("expected primary type proxmox-pve, got %q", h.PrimaryType)
|
|
}
|
|
if len(h.Sources) != 2 {
|
|
t.Fatalf("expected 2 sources, got %d", len(h.Sources))
|
|
}
|
|
if len(h.SourceLabels) != 2 {
|
|
t.Fatalf("expected 2 source labels, got %d", len(h.SourceLabels))
|
|
}
|
|
}
|
|
|
|
func TestResolveHosts_DMIUUIDMerge(t *testing.T) {
|
|
candidates := []HostCandidate{
|
|
{ID: "pve:n1", Name: "pve1", Type: "proxmox-pve", Source: "proxmox", Status: "online",
|
|
Identity: ResourceIdentity{DMIUUID: "uuid-1", Hostnames: []string{"pve1"}}},
|
|
{ID: "docker:d1", Name: "pve1", Type: "docker", Source: "docker", Status: "online",
|
|
Identity: ResourceIdentity{DMIUUID: "uuid-1", Hostnames: []string{"pve1"}}},
|
|
}
|
|
result := ResolveHosts(candidates)
|
|
if len(result.Hosts) != 1 {
|
|
t.Fatalf("expected 1 merged host, got %d", len(result.Hosts))
|
|
}
|
|
}
|
|
|
|
func TestResolveHosts_HostnameAndMACMerge(t *testing.T) {
|
|
candidates := []HostCandidate{
|
|
{ID: "pve:n1", Name: "pve1", Type: "proxmox-pve", Source: "proxmox", Status: "online",
|
|
Identity: ResourceIdentity{Hostnames: []string{"pve1"}, MACAddresses: []string{"00:11:22:33:44:55"}}},
|
|
{ID: "agent:h1", Name: "pve1", Type: "agent", Source: "agent", Status: "online",
|
|
Identity: ResourceIdentity{Hostnames: []string{"pve1"}, MACAddresses: []string{"00:11:22:33:44:55"}}},
|
|
}
|
|
result := ResolveHosts(candidates)
|
|
if len(result.Hosts) != 1 {
|
|
t.Fatalf("expected 1 merged host (hostname+MAC), got %d", len(result.Hosts))
|
|
}
|
|
}
|
|
|
|
func TestResolveHosts_HostnameOnlyDoesNotMerge(t *testing.T) {
|
|
candidates := []HostCandidate{
|
|
{ID: "pve:n1", Name: "pve1", Type: "proxmox-pve", Source: "proxmox", Status: "online",
|
|
Identity: ResourceIdentity{Hostnames: []string{"pve1"}}},
|
|
{ID: "agent:h1", Name: "pve1", Type: "agent", Source: "agent", Status: "online",
|
|
Identity: ResourceIdentity{Hostnames: []string{"pve1"}}},
|
|
}
|
|
result := ResolveHosts(candidates)
|
|
if len(result.Hosts) != 2 {
|
|
t.Fatalf("hostname-only match should not merge (below threshold), got %d hosts", len(result.Hosts))
|
|
}
|
|
}
|
|
|
|
func TestResolveHosts_IPOnlyDoesNotMerge(t *testing.T) {
|
|
candidates := []HostCandidate{
|
|
{ID: "pve:n1", Name: "pve1", Type: "proxmox-pve", Source: "proxmox", Status: "online",
|
|
Identity: ResourceIdentity{IPAddresses: []string{"192.168.1.10"}}},
|
|
{ID: "agent:h1", Name: "host1", Type: "agent", Source: "agent", Status: "online",
|
|
Identity: ResourceIdentity{IPAddresses: []string{"192.168.1.10"}}},
|
|
}
|
|
result := ResolveHosts(candidates)
|
|
if len(result.Hosts) != 2 {
|
|
t.Fatalf("IP-only match should not merge, got %d hosts", len(result.Hosts))
|
|
}
|
|
}
|
|
|
|
func TestResolveHosts_ThreeWayMerge(t *testing.T) {
|
|
// PVE node + host agent + docker all on the same machine.
|
|
candidates := []HostCandidate{
|
|
{ID: "pve:n1", Name: "pve1", Type: "proxmox-pve", Source: "proxmox", Status: "online",
|
|
Identity: ResourceIdentity{MachineID: "machine-1", Hostnames: []string{"pve1"}}},
|
|
{ID: "agent:h1", Name: "pve1", Type: "agent", Source: "agent", Status: "online",
|
|
Identity: ResourceIdentity{MachineID: "machine-1", Hostnames: []string{"pve1"}}},
|
|
{ID: "docker:d1", Name: "pve1", Type: "docker", Source: "docker", Status: "online",
|
|
Identity: ResourceIdentity{MachineID: "machine-1", Hostnames: []string{"pve1"}}},
|
|
}
|
|
result := ResolveHosts(candidates)
|
|
if len(result.Hosts) != 1 {
|
|
t.Fatalf("expected 1 merged host from 3 sources, got %d", len(result.Hosts))
|
|
}
|
|
h := result.Hosts[0]
|
|
if len(h.Sources) != 3 {
|
|
t.Fatalf("expected 3 sources, got %d", len(h.Sources))
|
|
}
|
|
if h.PrimaryType != "proxmox-pve" {
|
|
t.Fatalf("expected PVE as primary type, got %q", h.PrimaryType)
|
|
}
|
|
}
|
|
|
|
func TestResolveHosts_StatusPromotion(t *testing.T) {
|
|
candidates := []HostCandidate{
|
|
{ID: "pve:n1", Name: "pve1", Type: "proxmox-pve", Source: "proxmox", Status: "unknown",
|
|
Identity: ResourceIdentity{MachineID: "machine-1"}},
|
|
{ID: "agent:h1", Name: "pve1", Type: "agent", Source: "agent", Status: "online",
|
|
Identity: ResourceIdentity{MachineID: "machine-1"}},
|
|
}
|
|
result := ResolveHosts(candidates)
|
|
if result.Hosts[0].Status != "online" {
|
|
t.Fatalf("expected status online, got %q", result.Hosts[0].Status)
|
|
}
|
|
}
|
|
|
|
func TestResolveHosts_ProvisionalClearedByRuntime(t *testing.T) {
|
|
candidates := []HostCandidate{
|
|
{ID: "config-pve:p1", Name: "pve1", Type: "proxmox-pve", Source: "proxmox",
|
|
Status: "unknown", Provisional: true,
|
|
Identity: ResourceIdentity{MachineID: "machine-1"}},
|
|
{ID: "agent:h1", Name: "pve1", Type: "agent", Source: "agent",
|
|
Status: "online", Provisional: false,
|
|
Identity: ResourceIdentity{MachineID: "machine-1"}},
|
|
}
|
|
result := ResolveHosts(candidates)
|
|
if len(result.Hosts) != 1 {
|
|
t.Fatalf("expected 1 host, got %d", len(result.Hosts))
|
|
}
|
|
if result.Hosts[0].Provisional {
|
|
t.Fatalf("expected provisional=false when runtime source present")
|
|
}
|
|
}
|
|
|
|
func TestResolveHosts_SortOrder(t *testing.T) {
|
|
candidates := []HostCandidate{
|
|
{ID: "agent:h1", Name: "zulu", Type: "agent", Source: "agent", Status: "online",
|
|
Identity: ResourceIdentity{MachineID: "m1"}},
|
|
{ID: "docker:d1", Name: "alpha", Type: "docker", Source: "docker", Status: "online",
|
|
Identity: ResourceIdentity{MachineID: "m2"}},
|
|
{ID: "pve:n1", Name: "bravo", Type: "proxmox-pve", Source: "proxmox", Status: "online",
|
|
Identity: ResourceIdentity{MachineID: "m3"}},
|
|
}
|
|
result := ResolveHosts(candidates)
|
|
// Sorted by type then name: agent:zulu, docker:alpha, proxmox-pve:bravo
|
|
if result.Hosts[0].PrimaryType != "agent" {
|
|
t.Fatalf("expected agent first, got %q", result.Hosts[0].PrimaryType)
|
|
}
|
|
if result.Hosts[1].PrimaryType != "docker" {
|
|
t.Fatalf("expected docker second, got %q", result.Hosts[1].PrimaryType)
|
|
}
|
|
if result.Hosts[2].PrimaryType != "proxmox-pve" {
|
|
t.Fatalf("expected proxmox-pve third, got %q", result.Hosts[2].PrimaryType)
|
|
}
|
|
}
|
|
|
|
func TestResolveHosts_UnknownConnectorTypeDoesNotOverrideKnownPrimaryType(t *testing.T) {
|
|
candidates := []HostCandidate{
|
|
{
|
|
ID: "agent:a1",
|
|
Name: "node-a",
|
|
Type: "agent",
|
|
Source: "agent",
|
|
Status: "online",
|
|
FirstSeen: "2026-03-13T10:00:00Z",
|
|
Identity: ResourceIdentity{
|
|
MachineID: "mid-1",
|
|
},
|
|
},
|
|
{
|
|
ID: "mystery:x1",
|
|
Name: "node-a",
|
|
Type: "mystery-connector",
|
|
Source: "mystery",
|
|
Status: "online",
|
|
FirstSeen: "2026-03-13T10:01:00Z",
|
|
Identity: ResourceIdentity{
|
|
MachineID: "mid-1",
|
|
},
|
|
},
|
|
}
|
|
|
|
result := ResolveHosts(candidates)
|
|
if len(result.Hosts) != 1 {
|
|
t.Fatalf("expected one merged host, got %d", len(result.Hosts))
|
|
}
|
|
if result.Hosts[0].PrimaryType != "agent" {
|
|
t.Fatalf("expected known connector type to remain primary, got %q", result.Hosts[0].PrimaryType)
|
|
}
|
|
if len(result.Hosts[0].Sources) != 2 {
|
|
t.Fatalf("expected both sources to be preserved, got %d", len(result.Hosts[0].Sources))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CollectHostCandidates
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestCollectHostCandidates_PVENodesFromRuntime(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{
|
|
{ID: "n1", Name: "pve1", Host: "https://pve1.local:8006", Status: "online",
|
|
LastSeen: time.Date(2026, 2, 25, 10, 0, 0, 0, time.UTC)},
|
|
{ID: "n2", Name: "pve2", Host: "https://pve2.local:8006", Status: "offline"},
|
|
},
|
|
}
|
|
candidates := CollectHostCandidates(state, nil, nil, nil, nil)
|
|
if len(candidates) != 2 {
|
|
t.Fatalf("expected 2 PVE candidates, got %d", len(candidates))
|
|
}
|
|
if candidates[0].Type != "proxmox-pve" {
|
|
t.Fatalf("expected proxmox-pve type, got %q", candidates[0].Type)
|
|
}
|
|
if candidates[0].LastSeen == "" {
|
|
t.Fatalf("expected non-empty LastSeen for online node")
|
|
}
|
|
}
|
|
|
|
func TestCollectHostCandidates_PVENodeEndpointIPBecomesIdentityIP(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{
|
|
{ID: "n1", Name: "minipc", Host: "https://10.0.0.5:8006", Status: "online"},
|
|
},
|
|
}
|
|
|
|
candidates := CollectHostCandidates(state, nil, nil, nil, nil)
|
|
if len(candidates) != 1 {
|
|
t.Fatalf("expected 1 candidate, got %d", len(candidates))
|
|
}
|
|
if len(candidates[0].Identity.Hostnames) != 1 || candidates[0].Identity.Hostnames[0] != "minipc" {
|
|
t.Fatalf("Hostnames = %v, want [minipc]", candidates[0].Identity.Hostnames)
|
|
}
|
|
if len(candidates[0].Identity.IPAddresses) != 1 || candidates[0].Identity.IPAddresses[0] != "10.0.0.5" {
|
|
t.Fatalf("IPAddresses = %v, want [10.0.0.5]", candidates[0].Identity.IPAddresses)
|
|
}
|
|
}
|
|
|
|
func TestCollectHostCandidates_PVEFallbackToConfig(t *testing.T) {
|
|
state := models.StateSnapshot{}
|
|
configPVE := []ConfigEntry{{ID: "p1", Name: "my-pve", Host: "https://pve.local:8006"}}
|
|
candidates := CollectHostCandidates(state, configPVE, nil, nil, nil)
|
|
if len(candidates) != 1 {
|
|
t.Fatalf("expected 1 PVE config candidate, got %d", len(candidates))
|
|
}
|
|
if !candidates[0].Provisional {
|
|
t.Fatalf("config-only PVE should be provisional")
|
|
}
|
|
}
|
|
|
|
func TestCollectHostCandidates_AllSourceTypes(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{{ID: "n1", Name: "pve1", Status: "online"}},
|
|
Hosts: []models.Host{{ID: "h1", Hostname: "host1", Status: "online", MachineID: "m1"}},
|
|
DockerHosts: []models.DockerHost{{ID: "d1", Hostname: "docker1", Status: "online", MachineID: "m2"}},
|
|
KubernetesClusters: []models.KubernetesCluster{
|
|
{ID: "k1", Name: "k8s-prod", Status: "online",
|
|
Nodes: []models.KubernetesNode{{UID: "kn1", Name: "k8s-node1", Ready: true}}},
|
|
},
|
|
}
|
|
configPBS := []ConfigEntry{{ID: "pbs1", Name: "backup-srv", Host: "https://pbs.local:8007"}}
|
|
configPMG := []ConfigEntry{{ID: "pmg1", Name: "mail-gw", Host: "https://pmg.local:8006"}}
|
|
configTrueNAS := []ConfigEntry{{ID: "tn1", Name: "nas1", Host: "https://nas.local"}}
|
|
|
|
candidates := CollectHostCandidates(state, nil, configPBS, configPMG, configTrueNAS)
|
|
// 1 PVE + 1 PBS + 1 PMG + 1 TrueNAS + 1 host + 1 docker + 1 k8s node = 7
|
|
if len(candidates) != 7 {
|
|
t.Fatalf("expected 7 candidates, got %d", len(candidates))
|
|
}
|
|
|
|
types := make(map[string]int)
|
|
for _, c := range candidates {
|
|
types[c.Type]++
|
|
}
|
|
expected := map[string]int{
|
|
"proxmox-pve": 1,
|
|
"proxmox-pbs": 1,
|
|
"proxmox-pmg": 1,
|
|
"truenas": 1,
|
|
"agent": 1,
|
|
"docker": 1,
|
|
"kubernetes": 1,
|
|
}
|
|
for typ, want := range expected {
|
|
if types[typ] != want {
|
|
t.Errorf("type %q: expected %d, got %d", typ, want, types[typ])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCollectHostCandidates_K8sNoNodesCountsAsOne(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
KubernetesClusters: []models.KubernetesCluster{
|
|
{ID: "k1", Name: "k8s-prod", Status: "online", Nodes: nil},
|
|
},
|
|
}
|
|
candidates := CollectHostCandidates(state, nil, nil, nil, nil)
|
|
if len(candidates) != 1 {
|
|
t.Fatalf("expected 1 K8s cluster candidate, got %d", len(candidates))
|
|
}
|
|
if !candidates[0].Provisional {
|
|
t.Fatalf("K8s cluster with no nodes should be provisional")
|
|
}
|
|
}
|
|
|
|
func TestCollectHostCandidates_K8sMultipleNodes(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
KubernetesClusters: []models.KubernetesCluster{
|
|
{ID: "k1", Name: "k8s-prod", Status: "online", Nodes: []models.KubernetesNode{
|
|
{UID: "kn1", Name: "node1", Ready: true},
|
|
{UID: "kn2", Name: "node2", Ready: true},
|
|
{UID: "kn3", Name: "node3", Ready: false},
|
|
}},
|
|
},
|
|
}
|
|
candidates := CollectHostCandidates(state, nil, nil, nil, nil)
|
|
if len(candidates) != 3 {
|
|
t.Fatalf("expected 3 K8s node candidates, got %d", len(candidates))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// K8s node identity enrichment
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestEnrichK8sNodeIdentity_MatchesHostAgent(t *testing.T) {
|
|
hosts := []models.Host{
|
|
{ID: "h1", Hostname: "k8s-node1", MachineID: "machine-abc",
|
|
NetworkInterfaces: []models.HostNetworkInterface{
|
|
{Name: "eth0", MAC: "00:11:22:33:44:55"},
|
|
}},
|
|
}
|
|
identity := ResourceIdentity{Hostnames: []string{"k8s-node1"}}
|
|
enrichK8sNodeIdentity(&identity, "k8s-node1", hosts)
|
|
|
|
if identity.MachineID != "machine-abc" {
|
|
t.Fatalf("expected machine-id enrichment, got %q", identity.MachineID)
|
|
}
|
|
if len(identity.MACAddresses) != 1 || identity.MACAddresses[0] != "00:11:22:33:44:55" {
|
|
t.Fatalf("expected MAC enrichment, got %v", identity.MACAddresses)
|
|
}
|
|
}
|
|
|
|
func TestEnrichK8sNodeIdentity_NoMatch(t *testing.T) {
|
|
hosts := []models.Host{
|
|
{ID: "h1", Hostname: "other-host", MachineID: "machine-abc"},
|
|
}
|
|
identity := ResourceIdentity{Hostnames: []string{"k8s-node1"}}
|
|
enrichK8sNodeIdentity(&identity, "k8s-node1", hosts)
|
|
|
|
if identity.MachineID != "" {
|
|
t.Fatalf("expected no machine-id enrichment, got %q", identity.MachineID)
|
|
}
|
|
}
|
|
|
|
func TestEnrichK8sNodeIdentity_NormalizesHostname(t *testing.T) {
|
|
hosts := []models.Host{
|
|
{ID: "h1", Hostname: "K8S-NODE1.local", MachineID: "machine-abc"},
|
|
}
|
|
identity := ResourceIdentity{Hostnames: []string{"k8s-node1"}}
|
|
enrichK8sNodeIdentity(&identity, "k8s-node1", hosts)
|
|
|
|
if identity.MachineID != "machine-abc" {
|
|
t.Fatalf("expected match via normalized hostname, got %q", identity.MachineID)
|
|
}
|
|
}
|
|
|
|
func TestEnrichK8sNodeIdentity_ExactHostnameMatchWinsOverAmbiguity(t *testing.T) {
|
|
hosts := []models.Host{
|
|
{ID: "h1", Hostname: "k8s-node1.local", MachineID: "machine-abc"},
|
|
{ID: "h2", Hostname: "k8s-node1.other", MachineID: "machine-def"},
|
|
}
|
|
identity := ResourceIdentity{Hostnames: []string{"k8s-node1.local"}}
|
|
enrichK8sNodeIdentity(&identity, "k8s-node1.local", hosts)
|
|
|
|
if identity.MachineID != "machine-abc" {
|
|
t.Fatalf("expected exact hostname match to enrich identity, got %q", identity.MachineID)
|
|
}
|
|
}
|
|
|
|
func TestEnrichK8sNodeIdentity_AmbiguousNormalizedHostnameSkipsEnrichment(t *testing.T) {
|
|
hosts := []models.Host{
|
|
{ID: "h1", Hostname: "k8s-node1.local", MachineID: "machine-abc"},
|
|
{ID: "h2", Hostname: "k8s-node1.other", MachineID: "machine-def"},
|
|
}
|
|
identity := ResourceIdentity{Hostnames: []string{"k8s-node1"}}
|
|
enrichK8sNodeIdentity(&identity, "k8s-node1", hosts)
|
|
|
|
if identity.MachineID != "" {
|
|
t.Fatalf("expected ambiguous normalized hostname to skip enrichment, got %q", identity.MachineID)
|
|
}
|
|
if len(identity.MACAddresses) != 0 {
|
|
t.Fatalf("expected no MAC enrichment on ambiguous normalized hostname, got %v", identity.MACAddresses)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// End-to-end dedup scenario
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestResolveHosts_EndToEnd_PVEWithAgent(t *testing.T) {
|
|
// Simulates the common case: PVE node with a host agent on the same machine.
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{
|
|
{ID: "n1", Name: "pve1", Host: "https://192.168.1.10:8006", Status: "online",
|
|
LinkedAgentID: "h1",
|
|
LastSeen: time.Date(2026, 2, 25, 10, 0, 0, 0, time.UTC)},
|
|
},
|
|
Hosts: []models.Host{
|
|
{ID: "h1", Hostname: "pve1", MachineID: "machine-1", Status: "online",
|
|
LastSeen: time.Date(2026, 2, 25, 10, 0, 0, 0, time.UTC),
|
|
NetworkInterfaces: []models.HostNetworkInterface{
|
|
{Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: []string{"192.168.1.10/24"}},
|
|
}},
|
|
},
|
|
DockerHosts: []models.DockerHost{
|
|
{ID: "d1", Hostname: "pve1", MachineID: "machine-1", Status: "online",
|
|
LastSeen: time.Date(2026, 2, 25, 10, 0, 0, 0, time.UTC),
|
|
NetworkInterfaces: []models.HostNetworkInterface{
|
|
{Name: "eth0", MAC: "00:11:22:33:44:55"},
|
|
}},
|
|
},
|
|
}
|
|
|
|
candidates := CollectHostCandidates(state, nil, nil, nil, nil)
|
|
result := ResolveHosts(candidates)
|
|
|
|
if len(result.Hosts) != 1 {
|
|
t.Fatalf("expected 1 resolved host after linked host identity propagation, got %d", len(result.Hosts))
|
|
}
|
|
if result.Hosts[0].PrimaryType != "proxmox-pve" {
|
|
t.Fatalf("expected proxmox-pve primary type, got %q", result.Hosts[0].PrimaryType)
|
|
}
|
|
if len(result.Hosts[0].Sources) != 3 {
|
|
t.Fatalf("expected 3 merged sources, got %d", len(result.Hosts[0].Sources))
|
|
}
|
|
if result.Hosts[0].Identity.MachineID != "machine-1" {
|
|
t.Fatalf("expected propagated machine-id, got %q", result.Hosts[0].Identity.MachineID)
|
|
}
|
|
}
|
|
|
|
func TestResolveHosts_EndToEnd_PVEWithAsymmetricLinkedNodeViews(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{
|
|
{ID: "n1", Name: "minipc", ClusterName: "homelab", Host: "https://10.0.0.5:8006", Status: "online",
|
|
LinkedAgentID: "h1",
|
|
LastSeen: time.Date(2026, 3, 7, 10, 0, 0, 0, time.UTC)},
|
|
{ID: "n2", Name: "minipc", ClusterName: "homelab", Host: "https://10.0.0.5:8006", Status: "online",
|
|
LastSeen: time.Date(2026, 3, 7, 9, 59, 0, 0, time.UTC)},
|
|
},
|
|
Hosts: []models.Host{
|
|
{ID: "h1", Hostname: "minipc", MachineID: "machine-1", Status: "online",
|
|
LastSeen: time.Date(2026, 3, 7, 10, 0, 0, 0, time.UTC),
|
|
NetworkInterfaces: []models.HostNetworkInterface{
|
|
{Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: []string{"10.0.0.5/24"}},
|
|
}},
|
|
},
|
|
}
|
|
|
|
candidates := CollectHostCandidates(state, nil, nil, nil, nil)
|
|
result := ResolveHosts(candidates)
|
|
|
|
if len(result.Hosts) != 1 {
|
|
t.Fatalf("expected 1 resolved host after asymmetric linked-host propagation, got %d", len(result.Hosts))
|
|
}
|
|
if result.Hosts[0].Identity.MachineID != "machine-1" {
|
|
t.Fatalf("expected propagated machine-id, got %q", result.Hosts[0].Identity.MachineID)
|
|
}
|
|
if len(result.Hosts[0].Sources) != 3 {
|
|
t.Fatalf("expected 3 merged sources, got %d", len(result.Hosts[0].Sources))
|
|
}
|
|
}
|
|
|
|
func TestResolveHosts_EndToEnd_PVEWithHostLinkedMixedEndpointForms(t *testing.T) {
|
|
state := models.StateSnapshot{
|
|
Nodes: []models.Node{
|
|
{ID: "n1", Name: "minipc", Host: "https://10.0.0.5:8006", Status: "online",
|
|
LastSeen: time.Date(2026, 3, 7, 10, 0, 0, 0, time.UTC)},
|
|
{ID: "n2", Name: "minipc", Host: "https://minipc.local:8006", Status: "online",
|
|
LastSeen: time.Date(2026, 3, 7, 9, 59, 0, 0, time.UTC)},
|
|
},
|
|
Hosts: []models.Host{
|
|
{ID: "h1", Hostname: "minipc.local", MachineID: "machine-1", ReportIP: "10.0.0.5", Status: "online",
|
|
LinkedNodeID: "n1",
|
|
LastSeen: time.Date(2026, 3, 7, 10, 0, 0, 0, time.UTC),
|
|
NetworkInterfaces: []models.HostNetworkInterface{
|
|
{Name: "eth0", MAC: "00:11:22:33:44:55", Addresses: []string{"10.0.0.5/24"}},
|
|
}},
|
|
},
|
|
}
|
|
|
|
candidates := CollectHostCandidates(state, nil, nil, nil, nil)
|
|
result := ResolveHosts(candidates)
|
|
|
|
if len(result.Hosts) != 1 {
|
|
t.Fatalf("expected 1 resolved host across mixed endpoint forms, got %d", len(result.Hosts))
|
|
}
|
|
if result.Hosts[0].Identity.MachineID != "machine-1" {
|
|
t.Fatalf("expected propagated machine-id, got %q", result.Hosts[0].Identity.MachineID)
|
|
}
|
|
if len(result.Hosts[0].Sources) != 3 {
|
|
t.Fatalf("expected 3 merged sources, got %d", len(result.Hosts[0].Sources))
|
|
}
|
|
}
|
|
|
|
func TestResolveHosts_EndToEnd_MachineIDPropagation(t *testing.T) {
|
|
// When PVE node and host agent both report the same machine-id, they merge.
|
|
candidates := []HostCandidate{
|
|
{ID: "pve:n1", Name: "pve1", Type: "proxmox-pve", Source: "proxmox", Status: "online",
|
|
Identity: ResourceIdentity{MachineID: "machine-1", Hostnames: []string{"pve1"}}},
|
|
{ID: "agent:h1", Name: "pve1", Type: "agent", Source: "agent", Status: "online",
|
|
Identity: ResourceIdentity{MachineID: "machine-1", Hostnames: []string{"pve1"},
|
|
MACAddresses: []string{"00:11:22:33:44:55"}}},
|
|
{ID: "docker:d1", Name: "pve1", Type: "docker", Source: "docker", Status: "online",
|
|
Identity: ResourceIdentity{MachineID: "machine-1", Hostnames: []string{"pve1"}}},
|
|
}
|
|
result := ResolveHosts(candidates)
|
|
if len(result.Hosts) != 1 {
|
|
t.Fatalf("expected all 3 to merge via machine-id, got %d", len(result.Hosts))
|
|
}
|
|
h := result.Hosts[0]
|
|
if h.PrimaryType != "proxmox-pve" {
|
|
t.Fatalf("PVE should be primary, got %q", h.PrimaryType)
|
|
}
|
|
if len(h.SourceLabels) != 3 {
|
|
t.Fatalf("expected 3 source labels, got %v", h.SourceLabels)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// betterStatus
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestBetterStatus(t *testing.T) {
|
|
cases := []struct{ a, b, want string }{
|
|
{"unknown", "offline", "offline"},
|
|
{"offline", "online", "online"},
|
|
{"online", "unknown", "online"},
|
|
{"online", "online", "online"},
|
|
{"unknown", "unknown", "unknown"},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := betterStatus(tc.a, tc.b); got != tc.want {
|
|
t.Errorf("betterStatus(%q, %q) = %q, want %q", tc.a, tc.b, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resolvedFormatTime
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestResolvedFormatTime(t *testing.T) {
|
|
if got := resolvedFormatTime(time.Time{}); got != "" {
|
|
t.Fatalf("zero time should return empty, got %q", got)
|
|
}
|
|
ts := time.Date(2026, 2, 25, 10, 30, 0, 0, time.UTC)
|
|
got := resolvedFormatTime(ts)
|
|
if got != "2026-02-25T10:30:00Z" {
|
|
t.Fatalf("expected RFC3339, got %q", got)
|
|
}
|
|
}
|