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
This commit is contained in:
rcourtman 2025-12-13 23:14:00 +00:00
parent cc5586c5c0
commit 2bce08925f
2 changed files with 148 additions and 0 deletions

View file

@ -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) {

View file

@ -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
)