Canonicalize Proxmox-guest discovery records to the node-name key

Discovery records for Proxmox guests (VM / system-container) are canonically
keyed by node name + VMID — the form the background fingerprint loop and the
Assistant prefetch already use. But the browser action path addresses guests by
the linked agent UUID (discoveryTarget.agentId, the action-authorization target),
and normalizeDiscoveryRequest was a no-op for guest types — so a UUID-targeted
trigger/lookup stored and read records under a second, divergent key. Result: the
resource drawer reported "Not discovered" for a guest the background loop had
already discovered, and duplicate records accumulated (node-keyed + UUID-keyed).

normalizeDiscoveryRequest now canonicalizes guest targets to the node name:
resolve a linked-agent-UUID target back to its hosting node (node->linked-agent
map, falling back to the agent host's hostname when it is a known node). Both
creation (DiscoverResource) and lookup (GetDiscoveryByResource) funnel through
this chokepoint, so every path converges on the node-name key; the caller already
aliases the original target, so pre-existing UUID-keyed records are still found
and consolidated onto the canonical key on the next run.

The agent UUID is unchanged as the action-authorization target — this governs
only the record key, so the discoveryTarget security contract is untouched.

- Test: TestService_ProxmoxGuestDiscoveryCanonicalizesToNodeKey.
- Live-verified on the dev instance: the Home Assistant LXC drawer readiness went
  missing -> fresh (discoveryId system-container:delly:101).
This commit is contained in:
rcourtman 2026-06-09 14:33:12 +01:00
parent d29d9684db
commit 5321c8b7a2
2 changed files with 131 additions and 1 deletions

View file

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

View file

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