Repair Proxmox workload parent identity

This commit is contained in:
rcourtman 2026-05-14 12:12:06 +01:00
parent be7c1639e0
commit a4d29fa92d
3 changed files with 257 additions and 17 deletions

View file

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

View file

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

View file

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