From 2bce08925f59f19da6223f84aa8c410f520295ed Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 13 Dec 2025 23:14:00 +0000 Subject: [PATCH] feat: link host agents to PVE nodes by hostname to prevent duplication When a host agent registers, it now searches for a PVE node with a matching hostname and links them together. Similarly, when PVE nodes are discovered, they check for existing host agents with matching hostnames. This prevents the confusion of seeing duplicate entries when users install agents on PVE cluster nodes that were already discovered via the cluster API. - Added LinkedHostAgentID field to Node struct - Added LinkedNodeID/LinkedVMID/LinkedContainerID fields to Host struct - Added findLinkedProxmoxEntity() to match by hostname (with domain stripping) - Updated UpdateNodesForInstance() to preserve and auto-set links --- internal/models/models.go | 56 +++++++++++++++++++++ internal/monitoring/monitor.go | 92 ++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/internal/models/models.go b/internal/models/models.go index cf82b88..4d70352 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -87,6 +87,9 @@ type Node struct { ConnectionHealth string `json:"connectionHealth"` IsClusterMember bool `json:"isClusterMember"` // True if part of a cluster ClusterName string `json:"clusterName"` // Name of cluster (empty if standalone) + + // Linking: When a host agent is running on this PVE node, link them together + LinkedHostAgentID string `json:"linkedHostAgentId,omitempty"` // ID of the host agent running on this node } // VM represents a virtual machine @@ -184,6 +187,11 @@ type Host struct { TokenLastUsedAt *time.Time `json:"tokenLastUsedAt,omitempty"` Tags []string `json:"tags,omitempty"` IsLegacy bool `json:"isLegacy,omitempty"` + + // Linking: When this host agent is running on a known PVE node/VM/container + LinkedNodeID string `json:"linkedNodeId,omitempty"` // ID of the PVE node this agent is running on + LinkedVMID string `json:"linkedVmId,omitempty"` // ID of the VM this agent is running inside + LinkedContainerID string `json:"linkedContainerId,omitempty"` // ID of the container this agent is running inside } // HostNetworkInterface describes a host network adapter summary. @@ -1260,15 +1268,47 @@ func (s *State) UpdateNodesForInstance(instanceName string, nodes []Node) { defer s.mu.Unlock() // Create a map of existing nodes, excluding those from this instance + // Also preserve LinkedHostAgentID for nodes that are being updated + existingNodeLinks := make(map[string]string) // nodeID -> linkedHostAgentID nodeMap := make(map[string]Node) for _, node := range s.Nodes { if node.Instance != instanceName { nodeMap[node.ID] = node + } else if node.LinkedHostAgentID != "" { + // Preserve the link for nodes from this instance + existingNodeLinks[node.ID] = node.LinkedHostAgentID + } + } + + // Build hostname-to-hostAgentID map for linking new nodes to existing host agents + hostAgentByHostname := make(map[string]string) // lowercase hostname -> hostAgentID + for _, host := range s.Hosts { + if host.ID != "" { + hostAgentByHostname[strings.ToLower(host.Hostname)] = host.ID + // Also index by short hostname + if idx := strings.Index(host.Hostname, "."); idx > 0 { + hostAgentByHostname[strings.ToLower(host.Hostname[:idx])] = host.ID + } } } // Add or update nodes from this instance for _, node := range nodes { + // Preserve existing link if we had one + if existingLink, ok := existingNodeLinks[node.ID]; ok { + node.LinkedHostAgentID = existingLink + } + // If no existing link, try to match by hostname + if node.LinkedHostAgentID == "" { + nodeName := strings.ToLower(node.Name) + if hostID, ok := hostAgentByHostname[nodeName]; ok { + node.LinkedHostAgentID = hostID + } else if idx := strings.Index(nodeName, "."); idx > 0 { + if hostID, ok := hostAgentByHostname[nodeName[:idx]]; ok { + node.LinkedHostAgentID = hostID + } + } + } nodeMap[node.ID] = node } @@ -1930,6 +1970,22 @@ func (s *State) ClearAllHosts() int { return count } +// LinkNodeToHostAgent updates a PVE node to link to its host agent. +// This is called when a host agent registers and matches a known PVE node by hostname. +func (s *State) LinkNodeToHostAgent(nodeID, hostAgentID string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + for i, node := range s.Nodes { + if node.ID == nodeID { + s.Nodes[i].LinkedHostAgentID = hostAgentID + s.LastUpdate = time.Now() + return true + } + } + return false +} + // UpsertCephCluster inserts or updates a Ceph cluster in the state. // Uses ID (typically the FSID) for matching. func (s *State) UpsertCephCluster(cluster CephCluster) { diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 0f52edf..a0277ec 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -2147,9 +2147,42 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config. host.TokenLastUsedAt = previous.TokenLastUsedAt } + // Link host agent to matching PVE node/VM/container by hostname + // This prevents duplication when users install agents on PVE cluster nodes + linkedNodeID, linkedVMID, linkedContainerID := m.findLinkedProxmoxEntity(hostname) + if linkedNodeID != "" { + host.LinkedNodeID = linkedNodeID + log.Info(). + Str("hostId", identifier). + Str("hostname", hostname). + Str("linkedNodeId", linkedNodeID). + Msg("Linked host agent to PVE node") + } + if linkedVMID != "" { + host.LinkedVMID = linkedVMID + log.Info(). + Str("hostId", identifier). + Str("hostname", hostname). + Str("linkedVmId", linkedVMID). + Msg("Linked host agent to VM") + } + if linkedContainerID != "" { + host.LinkedContainerID = linkedContainerID + log.Info(). + Str("hostId", identifier). + Str("hostname", hostname). + Str("linkedContainerId", linkedContainerID). + Msg("Linked host agent to container") + } + m.state.UpsertHost(host) m.state.SetConnectionHealth(hostConnectionPrefix+host.ID, true) + // Update the linked PVE node to point back to this host agent + if host.LinkedNodeID != "" { + m.linkNodeToHostAgent(host.LinkedNodeID, host.ID) + } + // If host reports Ceph data, also update the global CephClusters state if report.Ceph != nil { cephCluster := convertAgentCephToGlobalCluster(report.Ceph, hostname, identifier, timestamp) @@ -2170,6 +2203,65 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config. return host, nil } +// findLinkedProxmoxEntity searches for a PVE node, VM, or container with a matching hostname. +// Returns the IDs of matched entities (empty string if no match). +func (m *Monitor) findLinkedProxmoxEntity(hostname string) (nodeID, vmID, containerID string) { + if hostname == "" { + return "", "", "" + } + + // Normalize hostname for comparison (lowercase, strip domain) + normalizedHostname := strings.ToLower(hostname) + shortHostname := normalizedHostname + if idx := strings.Index(normalizedHostname, "."); idx > 0 { + shortHostname = normalizedHostname[:idx] + } + + matchHostname := func(name string) bool { + normalized := strings.ToLower(name) + if normalized == normalizedHostname || normalized == shortHostname { + return true + } + // Also check short version of the candidate + if idx := strings.Index(normalized, "."); idx > 0 { + if normalized[:idx] == shortHostname { + return true + } + } + return false + } + + state := m.GetState() + + // Check PVE nodes first + for _, node := range state.Nodes { + if matchHostname(node.Name) { + return node.ID, "", "" + } + } + + // Check VMs + for _, vm := range state.VMs { + if matchHostname(vm.Name) { + return "", vm.ID, "" + } + } + + // Check containers + for _, ct := range state.Containers { + if matchHostname(ct.Name) { + return "", "", ct.ID + } + } + + return "", "", "" +} + +// linkNodeToHostAgent updates a PVE node to link to its host agent. +func (m *Monitor) linkNodeToHostAgent(nodeID, hostAgentID string) { + m.state.LinkNodeToHostAgent(nodeID, hostAgentID) +} + const ( removedDockerHostsTTL = 24 * time.Hour // Clean up removed hosts tracking after 24 hours )