From a4d29fa92d4e8a1bff1bc444e8d1a015b2e72145 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 14 May 2026 12:12:06 +0100 Subject: [PATCH] Repair Proxmox workload parent identity --- .../internal/subsystems/unified-resources.md | 6 + internal/unifiedresources/registry.go | 113 +++++++++++-- internal/unifiedresources/registry_test.go | 155 ++++++++++++++++++ 3 files changed, 257 insertions(+), 17 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index df8d054ad..24a71fdc8 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -214,6 +214,12 @@ cross-source deduplication. the same host coalesces into one hybrid infrastructure resource, while same-name agent-only records remain separate until stronger identity or platform evidence exists. + Proxmox child resources must derive their canonical parent from the owning + node identity across all supported source-key shapes. Guests, storage pools, + and physical disks may arrive with instance-node, cluster-node, or bare-node + parent evidence, and both snapshot ingest and already-unified registry seed + paths must attach them to the same merged host resource before REST, + websocket, Workloads, or Infrastructure consumers render the estate. Resource detail mappers now reuse the shared `frontend-modern/src/utils/textPresentation.ts` title-case helper for sensor diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index ed6ece361..b31d1c41c 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -876,11 +876,11 @@ func (rr *ResourceRegistry) ingestPMGInstance(instance models.PMGInstance) { func (rr *ResourceRegistry) ingestVM(vm models.VM, clusterByInstance map[string]string) { resource, identity := resourceFromVM(vm) sourceID := proxmoxVMSourceID(vm) - parentSourceID := proxmoxNodeSourceID(vm.Instance, vm.Node) - if parentID, ok := rr.bySource[SourceProxmox][parentSourceID]; ok { + clusterName := clusterByInstance[vm.Instance] + if parentID := rr.proxmoxNodeParentID(vm.Instance, clusterName, vm.Node, ""); parentID != "" { resource.ParentID = &parentID } - if clusterName := clusterByInstance[vm.Instance]; clusterName != "" && resource.Proxmox != nil { + if clusterName != "" && resource.Proxmox != nil { resource.Proxmox.ClusterName = clusterName } rr.ingest(SourceProxmox, sourceID, resource, identity) @@ -889,11 +889,11 @@ func (rr *ResourceRegistry) ingestVM(vm models.VM, clusterByInstance map[string] func (rr *ResourceRegistry) ingestContainer(ct models.Container, clusterByInstance map[string]string) { resource, identity := resourceFromContainer(ct) sourceID := proxmoxContainerSourceID(ct) - parentSourceID := proxmoxNodeSourceID(ct.Instance, ct.Node) - if parentID, ok := rr.bySource[SourceProxmox][parentSourceID]; ok { + clusterName := clusterByInstance[ct.Instance] + if parentID := rr.proxmoxNodeParentID(ct.Instance, clusterName, ct.Node, ""); parentID != "" { resource.ParentID = &parentID } - if clusterName := clusterByInstance[ct.Instance]; clusterName != "" && resource.Proxmox != nil { + if clusterName != "" && resource.Proxmox != nil { resource.Proxmox.ClusterName = clusterName } rr.ingest(SourceProxmox, sourceID, resource, identity) @@ -1768,17 +1768,15 @@ func (rr *ResourceRegistry) resolveCanonicalParentID(resource *Resource) *string } if resource.parentBySource == nil { - if resource.ParentID == nil { - return nil + if resource.ParentID != nil { + canonicalParentID := CanonicalResourceID(strings.TrimSpace(*resource.ParentID)) + if canonicalParentID != "" { + if _, ok := rr.resources[canonicalParentID]; ok { + return &canonicalParentID + } + } } - canonicalParentID := CanonicalResourceID(strings.TrimSpace(*resource.ParentID)) - if canonicalParentID == "" { - return nil - } - if _, ok := rr.resources[canonicalParentID]; !ok { - return nil - } - return &canonicalParentID + return rr.resolveDerivedParentIDLocked(resource) } bestPriority := -1 @@ -1798,11 +1796,92 @@ func (rr *ResourceRegistry) resolveCanonicalParentID(resource *Resource) *string } } if bestParentID == "" { - return nil + return rr.resolveDerivedParentIDLocked(resource) } return &bestParentID } +func (rr *ResourceRegistry) resolveDerivedParentIDLocked(resource *Resource) *string { + if resource == nil || resource.Proxmox == nil { + return nil + } + switch CanonicalResourceType(resource.Type) { + case ResourceTypeVM, ResourceTypeSystemContainer, ResourceTypeStorage, ResourceTypePhysicalDisk: + default: + return nil + } + + parentID := rr.proxmoxNodeParentIDLocked( + resource.Proxmox.Instance, + resource.Proxmox.ClusterName, + resource.Proxmox.NodeName, + resource.ID, + ) + if parentID == "" { + return nil + } + return &parentID +} + +func (rr *ResourceRegistry) proxmoxNodeParentID(instance, clusterName, nodeName, excludeID string) string { + rr.mu.RLock() + defer rr.mu.RUnlock() + return rr.proxmoxNodeParentIDLocked(instance, clusterName, nodeName, excludeID) +} + +func (rr *ResourceRegistry) proxmoxNodeParentIDLocked(instance, clusterName, nodeName, excludeID string) string { + nodeName = strings.TrimSpace(nodeName) + if nodeName == "" { + return "" + } + + mapping := rr.bySource[SourceProxmox] + if len(mapping) == 0 { + return "" + } + + excludeID = CanonicalResourceID(strings.TrimSpace(excludeID)) + for _, sourceID := range proxmoxNodeParentSourceIDCandidates(instance, clusterName, nodeName) { + parentID := CanonicalResourceID(mapping[normalizeSourceID(sourceID)]) + if parentID == "" || parentID == excludeID { + continue + } + parent := rr.resources[parentID] + if parent == nil || CanonicalResourceType(parent.Type) != ResourceTypeAgent { + continue + } + return parentID + } + return "" +} + +func proxmoxNodeParentSourceIDCandidates(instance, clusterName, nodeName string) []string { + nodeName = strings.TrimSpace(nodeName) + if nodeName == "" { + return nil + } + + candidates := []string{ + proxmoxNodeSourceID(strings.TrimSpace(instance), nodeName), + proxmoxNodeSourceID(strings.TrimSpace(clusterName), nodeName), + nodeName, + } + out := make([]string, 0, len(candidates)) + seen := make(map[string]struct{}, len(candidates)) + for _, candidate := range candidates { + candidate = normalizeSourceID(candidate) + if candidate == "" { + continue + } + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + out = append(out, candidate) + } + return out +} + func (rr *ResourceRegistry) buildChildCounts() { // ChildCount and ParentName are derived fields. Clear prior values before // recomputing to prevent stale state after re-parenting or parent removal. diff --git a/internal/unifiedresources/registry_test.go b/internal/unifiedresources/registry_test.go index 6564473cf..5b936cb71 100644 --- a/internal/unifiedresources/registry_test.go +++ b/internal/unifiedresources/registry_test.go @@ -344,6 +344,71 @@ func TestResourceRegistry_IngestResourcesRebuildsSourceMappingsForMetricsTargets } } +func TestResourceRegistry_IngestResourcesDerivesClusterWorkloadParentFromSeededProxmoxNode(t *testing.T) { + rr := NewRegistry(nil) + now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC) + + rr.IngestResources([]Resource{ + { + ID: "agent-delly", + Type: ResourceTypeAgent, + Name: "delly", + Status: StatusOnline, + LastSeen: now, + Sources: []DataSource{SourceProxmox, SourceAgent}, + Identity: ResourceIdentity{ + MachineID: "machine-delly", + Hostnames: []string{"delly"}, + }, + Proxmox: &ProxmoxData{ + SourceID: "homelab-delly", + NodeName: "delly", + ClusterName: "homelab", + Instance: "delly", + }, + Agent: &AgentData{ + AgentID: "agent-source-delly", + Hostname: "delly", + }, + }, + { + ID: "system-container-cloudflared", + Type: ResourceTypeSystemContainer, + Name: "cloudflared", + Status: StatusOnline, + LastSeen: now, + Sources: []DataSource{SourceProxmox}, + Identity: ResourceIdentity{Hostnames: []string{"cloudflared"}}, + Proxmox: &ProxmoxData{ + SourceID: "delly:delly:104", + NodeName: "delly", + ClusterName: "homelab", + Instance: "delly", + VMID: 104, + }, + }, + }) + + child, ok := rr.Get("system-container-cloudflared") + if !ok { + t.Fatal("expected cloudflared resource") + } + if child.ParentID == nil || *child.ParentID != "agent-delly" { + t.Fatalf("expected cloudflared parent agent-delly, got %+v", child.ParentID) + } + if child.ParentName != "delly" { + t.Fatalf("expected cloudflared parent name delly, got %q", child.ParentName) + } + + parent, ok := rr.Get("agent-delly") + if !ok { + t.Fatal("expected delly parent resource") + } + if parent.ChildCount != 1 { + t.Fatalf("expected delly child count 1, got %d", parent.ChildCount) + } +} + func TestStorageRiskSemanticsPrefersUnraidParitySummaryOverGenericDiskCounts(t *testing.T) { risk := &StorageRisk{ Reasons: []StorageRiskReason{ @@ -1277,6 +1342,96 @@ func TestResourceRegistry_IngestSnapshotUnifiesHostLinkedProxmoxNodeViewsByHostI } } +func TestResourceRegistry_IngestSnapshotParentsClusterNamedProxmoxGuestsToMergedNode(t *testing.T) { + rr := NewRegistry(nil) + now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC) + + rr.IngestSnapshot(models.StateSnapshot{ + Nodes: []models.Node{ + { + ID: "homelab-delly", + Name: "delly", + Instance: "delly", + ClusterName: "homelab", + IsClusterMember: true, + Host: "https://10.0.0.9:8006", + LinkedAgentID: "host-1", + Status: "online", + LastSeen: now, + }, + }, + Hosts: []models.Host{ + { + ID: "host-1", + Hostname: "delly.local", + MachineID: "machine-delly", + ReportIP: "10.0.0.9", + Status: "online", + LastSeen: now, + LinkedNodeID: "homelab-delly", + NetworkInterfaces: []models.HostNetworkInterface{ + {Name: "eth0", MAC: "00:11:22:33:44:66", Addresses: []string{"10.0.0.9/24"}}, + }, + }, + }, + VMs: []models.VM{ + { + ID: "delly:delly:100", + VMID: 100, + Name: "docker-vm", + Node: "delly", + Instance: "delly", + Status: "running", + LastSeen: now, + }, + }, + Containers: []models.Container{ + { + ID: "delly:delly:104", + VMID: 104, + Name: "cloudflared", + Node: "delly", + Instance: "delly", + Status: "running", + LastSeen: now, + }, + }, + }) + + agents := rr.ListByType(ResourceTypeAgent) + if len(agents) != 1 { + t.Fatalf("expected 1 merged delly resource, got %d", len(agents)) + } + parentID := agents[0].ID + + vms := rr.ListByType(ResourceTypeVM) + if len(vms) != 1 { + t.Fatalf("expected 1 vm, got %d", len(vms)) + } + if vms[0].ParentID == nil || *vms[0].ParentID != parentID { + t.Fatalf("expected vm parent %q, got %+v", parentID, vms[0].ParentID) + } + if vms[0].ParentName == "" { + t.Fatal("expected vm parent name to be derived") + } + + containers := rr.ListByType(ResourceTypeSystemContainer) + if len(containers) != 1 { + t.Fatalf("expected 1 container, got %d", len(containers)) + } + if containers[0].ParentID == nil || *containers[0].ParentID != parentID { + t.Fatalf("expected container parent %q, got %+v", parentID, containers[0].ParentID) + } + + parent, ok := rr.Get(parentID) + if !ok { + t.Fatalf("expected parent %q", parentID) + } + if parent.ChildCount != 2 { + t.Fatalf("expected parent child count 2, got %d", parent.ChildCount) + } +} + func TestResourceRegistry_IngestSnapshotUnifiesHostLinkedProxmoxNodeViewsAcrossEndpointForms(t *testing.T) { rr := NewRegistry(nil) now := time.Date(2026, 3, 7, 12, 0, 0, 0, time.UTC)