diff --git a/internal/servicediscovery/service.go b/internal/servicediscovery/service.go index e0491396cb..3ef730f9aa 100644 --- a/internal/servicediscovery/service.go +++ b/internal/servicediscovery/service.go @@ -2022,7 +2022,19 @@ func (s *Service) normalizeDiscoveryRequest(req DiscoveryRequest, aliasIDs *[]st req = normalizeDiscoveryRequestAliases(req) requestTargetID := req.TargetID - if req.ResourceType != ResourceTypeAgent { + switch req.ResourceType { + case ResourceTypeVM, ResourceTypeSystemContainer: + // Proxmox guests are canonically keyed by node name + VMID. The browser + // action path addresses them by the linked agent UUID (the + // discoveryTarget.agentId action-authorization target); resolve that back + // to the hosting node so the stored/looked-up record key matches the + // background fingerprint loop and the Assistant prefetch (which already + // use node name). See canonicalizeProxmoxGuestDiscoveryTarget. + return s.canonicalizeProxmoxGuestDiscoveryTarget(req, requestTargetID) + case ResourceTypeAgent: + // Agent discovery is canonically keyed by the agent UUID; the logic below + // redirects a node-name/hostname target to its linked agent. + default: return req } snap, ok := s.getSnapshot() @@ -2081,6 +2093,75 @@ func (s *Service) normalizeDiscoveryRequest(req DiscoveryRequest, aliasIDs *[]st return req } +// canonicalizeProxmoxGuestDiscoveryTarget canonicalizes a Proxmox guest (VM / +// system-container) discovery request to the stable NODE-NAME key. Discovery +// records for guests are canonically keyed by node name + VMID — the form the +// background fingerprint loop and the Assistant prefetch already use. The browser +// action path, however, addresses guests by the linked agent UUID (the +// discoveryTarget.agentId action-authorization target). Left unnormalized, a +// UUID-targeted request stored and looked records up under a second, divergent +// key, so the resource drawer reported "not discovered" for a guest the +// background loop had already discovered. Resolve a UUID-form target back to its +// hosting node name; the caller has already registered the original (UUID-form) +// ID as an alias, so any record previously stored under it is still found by +// lookups and consolidated onto the canonical key on the next discovery run. +// +// The agent UUID is unchanged as the action target — this only governs the +// record key. Resolution prefers the node→linked-agent map, then falls back to +// the agent host's hostname when that hostname is itself a known node (a +// Proxmox-node agent reports hostname == node name, which is how the command +// executor already resolves targets). +func (s *Service) canonicalizeProxmoxGuestDiscoveryTarget(req DiscoveryRequest, requestTargetID string) DiscoveryRequest { + if strings.TrimSpace(requestTargetID) == "" { + return req + } + snap, ok := s.getSnapshot() + if !ok { + return req + } + + nodeNames := make(map[string]struct{}, len(snap.Nodes)) + nodeByLinkedAgent := make(map[string]string, len(snap.Nodes)) + for _, node := range snap.Nodes { + if node.Name != "" { + nodeNames[node.Name] = struct{}{} + } + if node.LinkedAgentID != "" && node.Name != "" { + nodeByLinkedAgent[node.LinkedAgentID] = node.Name + } + } + + // Already a node name — canonical, nothing to resolve. + if _, ok := nodeNames[requestTargetID]; ok { + return req + } + + // The target is the linked agent UUID: resolve it to the hosting node name. + if nodeName := nodeByLinkedAgent[requestTargetID]; nodeName != "" { + req.TargetID = nodeName + if req.Hostname == "" { + req.Hostname = nodeName + } + return req + } + + // Fallback: match the agent host by ID and adopt its hostname when that + // hostname is a known node. + for _, host := range snap.Hosts { + if host.ID == requestTargetID && host.Hostname != "" { + if _, ok := nodeNames[host.Hostname]; ok { + req.TargetID = host.Hostname + if req.Hostname == "" { + req.Hostname = host.Hostname + } + } + return req + } + } + + return req +} + func (s *Service) cleanupAliasedDiscoveries(canonicalID string, aliasIDs []string) { seen := make(map[string]struct{}, len(aliasIDs)) for _, aliasID := range aliasIDs { diff --git a/internal/servicediscovery/service_test.go b/internal/servicediscovery/service_test.go index 3f32a5cc57..7f1221119e 100644 --- a/internal/servicediscovery/service_test.go +++ b/internal/servicediscovery/service_test.go @@ -1663,6 +1663,55 @@ func TestParseDockerMounts(t *testing.T) { } } +func TestService_ProxmoxGuestDiscoveryCanonicalizesToNodeKey(t *testing.T) { + store, err := NewStore(t.TempDir()) + if err != nil { + t.Fatalf("NewStore error: %v", err) + } + store.crypto = nil + + // A Proxmox node "delly" whose linked agent is a UUID, hosting CT 101. + const nodeName = "delly" + const agentUUID = "7d465a78-80b8-4255-999c-88224ae57b4f" + state := StateSnapshot{ + Nodes: []Node{{ID: "delly-id", Name: nodeName, LinkedAgentID: agentUUID}}, + Hosts: []Host{{ID: agentUUID, Hostname: nodeName}}, + } + service := NewService(store, nil, DefaultConfig()) + service.SetReadState(readStateFromSnapshot(state)) + + // The background loop stored the discovery under the canonical node-name key. + canonicalID := MakeResourceID(ResourceTypeSystemContainer, nodeName, "101") + if err := store.Save(&ResourceDiscovery{ + ID: canonicalID, + ResourceType: ResourceTypeSystemContainer, + TargetID: nodeName, + ResourceID: "101", + ServiceName: "Home Assistant", + }); err != nil { + t.Fatalf("seed discovery: %v", err) + } + + // The drawer/readiness path looks the record up by the linked agent UUID (the + // discoveryTarget.agentId action target). It must canonicalize to the + // node-name key and find the record, not report "not discovered". + got, err := service.GetDiscoveryByResource(ResourceTypeSystemContainer, agentUUID, "101") + if err != nil { + t.Fatalf("GetDiscoveryByResource(agent-uuid) error: %v", err) + } + if got == nil { + t.Fatal("expected the node-name discovery record to be found via the agent-UUID target") + } + if got.ID != canonicalID { + t.Fatalf("found discovery ID = %q, want canonical node-name key %q", got.ID, canonicalID) + } + + // The node-name target (background loop / Assistant) still finds it directly. + if got2, err := service.GetDiscoveryByResource(ResourceTypeSystemContainer, nodeName, "101"); err != nil || got2 == nil { + t.Fatalf("node-name lookup failed: got=%v err=%v", got2, err) + } +} + func TestService_Redirection(t *testing.T) { // Setup store store, err := NewStore(t.TempDir())