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
This commit is contained in:
pulse-triage[bot]
2026-08-28 18:03:55 +01:00
parent 1d15ab60a3
commit afaf128950
2 changed files with 130 additions and 6 deletions
+47 -6
View File
@@ -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(),
@@ -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)