From afaf1289507a0e194ebb494027b0d33679f2bc79 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:03:55 +0100 Subject: [PATCH] Disambiguate same-name Proxmox agent links Use unique provider-observed node interface addresses to restore automatic host-agent linking when standalone sites share a short hostname but use distinct FQDN endpoints. Keep reused addresses ambiguous and fail closed. Refs #1753 Contract-Neutral: narrows internal agent-to-node identity reconciliation without changing API or extension shapes --- internal/monitoring/monitor_agents.go | 53 ++++++++++-- .../monitoring/monitor_host_agents_test.go | 83 +++++++++++++++++++ 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/internal/monitoring/monitor_agents.go b/internal/monitoring/monitor_agents.go index 2b5180c8c..8a44617bb 100644 --- a/internal/monitoring/monitor_agents.go +++ b/internal/monitoring/monitor_agents.go @@ -3924,6 +3924,38 @@ func collectReportedHostIPs( return ips } +func collectNodeNetworkIPs(network []unifiedresources.NetworkInterface) map[string]struct{} { + ips := make(map[string]struct{}) + for _, nic := range network { + for _, address := range nic.Addresses { + if normalized := unifiedresources.NormalizeIP(address); normalized != "" { + ips[normalized] = struct{}{} + } + } + } + return ips +} + +// uniqueNodeNetworkMatchesReportedHints accepts only provider-observed node +// addresses that belong to one current Proxmox node. This disambiguates the +// common FQDN-endpoint/short-hostname case without guessing when separate +// sites reuse an RFC1918 address or every node exposes the same bridge IP. +func uniqueNodeNetworkMatchesReportedHints( + network []unifiedresources.NetworkInterface, + reportedIPs map[string]struct{}, + ownerCounts map[string]int, +) bool { + for ip := range collectNodeNetworkIPs(network) { + if ownerCounts[ip] != 1 { + continue + } + if _, reported := reportedIPs[ip]; reported { + return true + } + } + return false +} + func endpointHostMatchesReportedHints( endpointHost string, reportedHostname string, @@ -3977,6 +4009,7 @@ func (m *Monitor) findLinkedProxmoxEntityWithHints( if readState == nil { return "", "", "" } + nodes := readState.Nodes() type linkedEntityMatch struct { id string @@ -3984,13 +4017,21 @@ func (m *Monitor) findLinkedProxmoxEntityWithHints( } reportedIPs := collectReportedHostIPs(reportIP, network) + nodeIPOwnerCounts := make(map[string]int) + for _, node := range nodes { + for ip := range collectNodeNetworkIPs(node.NetworkInterfaces()) { + nodeIPOwnerCounts[ip]++ + } + } - // First, try to match the configured PVE node endpoint against the host report. - // This is stronger than node-name matching and can disambiguate clustered nodes - // that share the same short hostname but have different management addresses. + // First, try to match the configured PVE node endpoint or a unique address + // from the provider-observed node network inventory against the host report. + // Both are stronger than node-name matching and can disambiguate nodes that + // share the same short hostname but have different management addresses. var endpointMatchedNodes []linkedEntityMatch - for _, node := range readState.Nodes() { - if endpointHostMatchesReportedHints(extractHostname(node.HostURL()), hostname, reportedIPs) { + for _, node := range nodes { + if endpointHostMatchesReportedHints(extractHostname(node.HostURL()), hostname, reportedIPs) || + uniqueNodeNetworkMatchesReportedHints(node.NetworkInterfaces(), reportedIPs, nodeIPOwnerCounts) { endpointMatchedNodes = append(endpointMatchedNodes, linkedEntityMatch{ id: node.SourceID(), instance: node.Instance(), @@ -4011,7 +4052,7 @@ func (m *Monitor) findLinkedProxmoxEntityWithHints( // Check PVE nodes first - but detect ambiguity when multiple nodes match var matchingNodes []linkedEntityMatch - for _, node := range readState.Nodes() { + for _, node := range nodes { if matchHostname(node.Name()) { matchingNodes = append(matchingNodes, linkedEntityMatch{ id: node.SourceID(), diff --git a/internal/monitoring/monitor_host_agents_test.go b/internal/monitoring/monitor_host_agents_test.go index 921e67f4e..085e787c7 100644 --- a/internal/monitoring/monitor_host_agents_test.go +++ b/internal/monitoring/monitor_host_agents_test.go @@ -586,6 +586,89 @@ func TestFindLinkedProxmoxEntityWithHints_UsesExactEndpointHostnameBeforeNameFal } } +// The v6.4.0-rc.12 state reconciliation fix keeps standalone providers with +// the same native node name separate, but the later agent report must still be +// able to attach to the right node. In the common case the configured endpoint +// is an FQDN while Proxmox and the agent both report the short hostname. The +// provider-observed node interface is the remaining machine-local evidence. +// Refs #1753. +func TestFindLinkedProxmoxEntityWithHints_UsesUniqueNodeNetworkToDisambiguateFQDNEndpoints(t *testing.T) { + monitor := &Monitor{ + state: models.NewState(), + } + + monitor.state.UpdateNodes([]models.Node{ + { + ID: "staging-pve01", + Name: "pve01", + Instance: "staging", + Host: "https://pve01.staging.example:8006", + NetworkInterfaces: []models.HostNetworkInterface{ + {Name: "vmbr0", Addresses: []string{"192.0.2.11/24"}}, + {Name: "docker0", Addresses: []string{"172.17.0.1/16"}}, + }, + }, + { + ID: "production-pve01", + Name: "pve01", + Instance: "production", + Host: "https://pve01.production.example:8006", + NetworkInterfaces: []models.HostNetworkInterface{ + {Name: "vmbr0", Addresses: []string{"198.51.100.21/24"}}, + {Name: "docker0", Addresses: []string{"172.17.0.1/16"}}, + }, + }, + }) + + nodeID, vmID, ctID := monitor.findLinkedProxmoxEntityWithHints( + "pve01", + "", + []agentshost.NetworkInterface{ + {Name: "vmbr0", Addresses: []string{"198.51.100.21/24"}}, + {Name: "docker0", Addresses: []string{"172.17.0.1/16"}}, + }, + ) + if nodeID != "production-pve01" || vmID != "" || ctID != "" { + t.Fatalf("expected unique provider network evidence to select production node, got node=%q vm=%q ct=%q", nodeID, vmID, ctID) + } +} + +func TestFindLinkedProxmoxEntityWithHints_SharedNodeNetworkRemainsAmbiguous(t *testing.T) { + monitor := &Monitor{ + state: models.NewState(), + } + + monitor.state.UpdateNodes([]models.Node{ + { + ID: "staging-pve01", + Name: "pve01", + Instance: "staging", + Host: "https://pve01.staging.example:8006", + NetworkInterfaces: []models.HostNetworkInterface{ + {Name: "vmbr0", Addresses: []string{"192.168.1.11/24"}}, + }, + }, + { + ID: "production-pve01", + Name: "pve01", + Instance: "production", + Host: "https://pve01.production.example:8006", + NetworkInterfaces: []models.HostNetworkInterface{ + {Name: "vmbr0", Addresses: []string{"192.168.1.11/24"}}, + }, + }, + }) + + nodeID, vmID, ctID := monitor.findLinkedProxmoxEntityWithHints( + "pve01", + "", + []agentshost.NetworkInterface{{Name: "vmbr0", Addresses: []string{"192.168.1.11/24"}}}, + ) + if nodeID != "" || vmID != "" || ctID != "" { + t.Fatalf("shared cross-site address must remain ambiguous, got node=%q vm=%q ct=%q", nodeID, vmID, ctID) + } +} + func TestHostSensorsFromReadStateViewPreservesTypedSensorData(t *testing.T) { customValue := 12.5 observedAt := time.Date(2026, 7, 30, 19, 0, 0, 0, time.UTC)