diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index d1199c9224..5cd6d8ede5 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -6476,6 +6476,18 @@ no backup, snapshot, restore, retention, cleanup, or verification authority. The storage/recovery resource query and its admission, freshness, and persistence contracts are unchanged. +### Link-folded resource references open no recovery path + +`internal/unifiedresources/types.go` gains an unexported +`Resource.linkFoldedIDs`: the canonical IDs operator links folded into a +merged resource, which the registry uses to resolve availability check links +and reference reads by those IDs to the merged row. It is in-memory identity +only, never serialized, persisted or exposed on the API. Storage and recovery +code neither reads it nor resolves references through the registry's +reference resolvers, so recovery ownership, restore targeting and protection +grouping are unchanged. A folded ID never becomes a superseded ID, so no +recovery, alert or availability configuration migrates through it. + ### Token creation and deletion preserve restart-time persistence truth The shared `internal/api` token creation path treats its persisted inventory as diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 95805c44a6..73c93c7c70 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -6572,11 +6572,39 @@ separate. succeeding the folded ID re-keyed the link onto the primary itself, which split the pair on the next rebuild. That already broke snapshot links whose primary is a machine-keyless agent-type row, such as a Proxmox node. -- Fold history is registry-local: resource seeding and overlay clones do not - carry it. A reference to the folded resource's canonical ID, such as an - availability check configured against an agent later linked into its - guest, does not resolve to the primary, so the check loses its target in - the broadcast as it already did in the resources API. +- A folded resource's canonical ID resolves to the primary holding it. The + merged resource carries its folded IDs in memory, including IDs folded + into those along a chain of links, and resource seeding indexes them. The + monitor's rebuild, a registry seeded from its listing (the resources API, + which then replays availability checks) and a read-state overlay therefore + resolve them alike. The index follows the holding rows, so a holder that a + later link merges away or that is re-keyed in place keeps the answer + consistent. An availability check configured against an agent later + linked into its guest lands on the merged row, as a check configured + against the primary would. "A manual link keeps only the primary's own + checks" still holds: the merge never carries the folded resource's + projected checks; only the check's own reference resolves again. + `ResolveReferenceID` and `GetByReference` follow the fold as well, as the + folded agent's alert reference (`agent:`) already did through the + merged row's canonical aliases. Alert intent, operator-state reads and + writes through the resources API and API lookups by the folded ID + therefore reach the merged row. Patrol's finding auto-acknowledgement + still reads a row stored under the exact reference first, so an + operator-state row kept under the folded ID applies there. History does + not follow the fold: a history binding persists, and + `expandHistoryAliases` walks bindings in both directions, so binding a + folded ID would join the two journals for good. Folded IDs are never + `SupersededCanonicalIDs`, which alert-configuration and availability-link + migrations rewrite permanently; the stored link stays as the operator + wrote it. A live row with a folded ID answers for itself. Two rows holding + one folded ID resolve to neither, without falling through to weaker + matches such as a hostname alias. Folds ride in-memory clones only; a + serialized listing loses them, as it loses source parents. + `availability_link_test.go` pins both link directions through two + rebuilds in all three registries + (`TestAvailabilityLinkToALinkFoldedResourceFollowsItsPrimary`), a chain + (`TestAvailabilityLinkFollowsChainedLinkFolds`) and the index cases + (`TestLinkFoldIndexFollowsItsHolders`). `registry_merge_policy_test.go` pins the record links in both directions through two rebuilds and the live supplemental refresh, and checks that a diff --git a/internal/unifiedresources/availability_link_test.go b/internal/unifiedresources/availability_link_test.go index fcb21d0289..41c9c641db 100644 --- a/internal/unifiedresources/availability_link_test.go +++ b/internal/unifiedresources/availability_link_test.go @@ -1,9 +1,11 @@ package unifiedresources import ( + "slices" "testing" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust" ) @@ -792,3 +794,290 @@ func TestProxmoxGuestAvailabilityLinkFollowsRetiredIDs(t *testing.T) { }) } } + +// assertAvailabilityCheckAttachedTo checks that the configured check targetID +// is projected onto resourceID by its explicit link and that the check's own +// endpoint row points there. +func assertAvailabilityCheckAttachedTo(t *testing.T, stage string, rr *ResourceRegistry, targetID, resourceID string) { + t.Helper() + target, ok := rr.Get(resourceID) + if !ok { + t.Fatalf("%s: check target %s missing from %v", stage, resourceID, resourceIDs(rr.List())) + } + attached := false + for _, check := range AvailabilityChecksForResource(*target) { + if check.TargetID == targetID { + attached = check.CorrelationState == AvailabilityCorrelationAttached && check.CorrelationRule == "explicit_resource_link" + } + } + if !attached { + t.Fatalf("%s: %s checks = %+v, want %s attached by its explicit link", stage, resourceID, AvailabilityChecksForResource(*target), targetID) + } + endpoint := availabilityEndpointByTarget(t, rr, targetID) + for _, relationship := range endpoint.Relationships { + if relationship.Type == RelChecks && relationship.TargetID == resourceID { + return + } + } + t.Fatalf("%s: endpoint relationships = %+v, want a checks edge to %s", stage, endpoint.Relationships, resourceID) +} + +func linkedGuestVMRecord(now time.Time) IngestRecord { + return IngestRecord{ + SourceID: "vc-1:vm:vm-42", + Resource: Resource{ + Type: ResourceTypeVM, + Technology: "vmware", + Name: "app-guest", + Status: StatusOnline, + LastSeen: now, + VMware: &VMwareData{ConnectionID: "vc-1", ManagedObjectID: "vm-42", EntityType: "vm"}, + }, + Identity: ResourceIdentity{Hostnames: []string{"app-guest"}}, + } +} + +func linkedGuestCheck(t *testing.T, targetID, address, linkedResourceID string, now time.Time) IngestRecord { + t.Helper() + return availabilityProbeRecord(targetID, address, &AvailabilityData{ + LinkedResourceID: linkedResourceID, + Address: address, + Protocol: "icmp", + Enabled: true, + Available: true, + LastChecked: &now, + Evidence: availabilityProbeEvidence(t, targetID, now), + }) +} + +// An operator link folds the agent inside a vSphere VM into the VM, so the +// agent's own canonical ID no longer names a row. A check configured against +// that ID watches the same machine and follows the agent to the merged row in +// every registry that lists it: the monitor's rebuild, a registry seeded from +// the monitor's listing that replays the checks (the resources API) and a +// read-state overlay. Reference reads (alert intent, operator state, API +// lookups) resolve the folded ID the same way. History does not, and the +// folded ID never becomes a superseded ID, which alert and availability +// migrations would turn into a permanent rewrite of the operator's config. +func TestAvailabilityLinkToALinkFoldedResourceFollowsItsPrimary(t *testing.T) { + now := time.Now().UTC() + snapshot := models.StateSnapshot{ + Hosts: []models.Host{{ID: "host-app-guest", Hostname: "app-guest", MachineID: "machine-app-guest", Status: "online", LastSeen: now}}, + LastUpdate: now, + } + agentID := MachineIdentityCanonicalID(ResourceTypeAgent, "machine-app-guest") + checks := []IngestRecord{linkedGuestCheck(t, "probe-guest", "192.0.2.60", agentID, now)} + records := map[DataSource][]IngestRecord{ + SourceVMware: {linkedGuestVMRecord(now)}, + SourceAvailability: checks, + } + + for _, agentPrimary := range []bool{false, true} { + name := "vm-primary" + if agentPrimary { + name = "agent-primary" + } + t.Run(name, func(t *testing.T) { + store := NewMemoryStore() + adapter := NewMonitorAdapter(NewRegistry(store)) + adapter.PopulateSnapshotAndSupplemental(snapshot, records) + assertAvailabilityCheckAttachedTo(t, "unlinked", adapter.currentRegistry(), "probe-guest", agentID) + + var vmID string + for _, resource := range adapter.GetAll() { + if resource.VMware != nil { + vmID = resource.ID + } + } + if vmID == "" { + t.Fatalf("unlinked estate = %v, want the vSphere VM", resourceIDs(adapter.GetAll())) + } + primaryID := vmID + if agentPrimary { + primaryID = agentID + } + if err := store.AddLink(ResourceLink{ResourceA: vmID, ResourceB: agentID, PrimaryID: primaryID}); err != nil { + t.Fatalf("add link: %v", err) + } + + // Pins persist on the first rebuild; damage from them shows on the second. + for i, stage := range []string{"rebuild", "second rebuild"} { + next := snapshot + next.LastUpdate = now.Add(time.Duration(i+1) * time.Second) + adapter.PopulateSnapshotAndSupplemental(next, records) + if _, listed := adapter.currentRegistry().Get(agentID); listed { + t.Fatalf("%s: agent %s still listed beside the VM it is linked into", stage, agentID) + } + + rest := NewRegistry(store) + rest.IngestResources(adapter.GetAll()) + rest.IngestRecords(SourceAvailability, checks) + overlay, ok := ReadStateWithRecords(adapter, SourceAvailability, checks).(*MonitorAdapter) + if !ok { + t.Fatalf("%s: overlay is not a monitor adapter", stage) + } + + for _, view := range []struct { + name string + rr *ResourceRegistry + }{ + {"monitor", adapter.currentRegistry()}, + {"resources API", rest}, + {"overlay", overlay.currentRegistry()}, + } { + label := stage + ", " + view.name + assertAvailabilityCheckAttachedTo(t, label, view.rr, "probe-guest", vmID) + // The agent's alert reference already reached the merged + // row; its canonical ID must agree. + for _, ref := range []string{agentID, "agent:host-app-guest"} { + if got, ok := view.rr.ResolveReferenceID(ref); !ok || got != vmID { + t.Fatalf("%s: ResolveReferenceID(%s) = %q, %t, want the VM %s", label, ref, got, ok, vmID) + } + } + // History bindings persist and join both journals in either + // direction, so they would outlive the link. + if got, claimed := view.rr.resolveHistoryReference(agentID); got != "" || claimed { + t.Fatalf("%s: history reference %s resolved to %q (claimed %t), want it left to its own journal", label, agentID, got, claimed) + } + vm, _ := view.rr.Get(vmID) + if slices.Contains(vm.SupersededCanonicalIDs, agentID) || + (vm.Canonical != nil && slices.Contains(vm.Canonical.SupersededIDs, agentID)) { + t.Fatalf("%s: folded agent %s is listed as superseded by the VM", label, agentID) + } + } + } + }) + } +} + +// An old enrollment of a guest linked into its current agent, and that agent +// linked into its VM: every ID folded along the chain resolves to the final +// primary, in the monitor and in a registry seeded from it. +func TestAvailabilityLinkFollowsChainedLinkFolds(t *testing.T) { + now := time.Now().UTC() + snapshot := models.StateSnapshot{ + Hosts: []models.Host{ + {ID: "host-app-guest", Hostname: "app-guest", MachineID: "machine-app-guest", Status: "online", LastSeen: now}, + {ID: "host-app-guest-old", Hostname: "app-guest-old", MachineID: "machine-app-guest-old", Status: "online", LastSeen: now}, + }, + LastUpdate: now, + } + agentID := MachineIdentityCanonicalID(ResourceTypeAgent, "machine-app-guest") + oldAgentID := MachineIdentityCanonicalID(ResourceTypeAgent, "machine-app-guest-old") + checks := []IngestRecord{linkedGuestCheck(t, "probe-old", "192.0.2.61", oldAgentID, now)} + records := map[DataSource][]IngestRecord{ + SourceVMware: {linkedGuestVMRecord(now)}, + SourceAvailability: checks, + } + + unlinked := NewMonitorAdapter(NewRegistry(nil)) + unlinked.PopulateSnapshotAndSupplemental(snapshot, records) + var vmID string + for _, resource := range unlinked.GetAll() { + if resource.VMware != nil { + vmID = resource.ID + } + } + listed := resourceIDs(unlinked.GetAll()) + if vmID == "" || !slices.Contains(listed, agentID) || !slices.Contains(listed, oldAgentID) { + t.Fatalf("unlinked estate = %v, want the VM and both agents", listed) + } + + store := NewMemoryStore() + for _, link := range []ResourceLink{ + {ResourceA: agentID, ResourceB: oldAgentID, PrimaryID: agentID}, + {ResourceA: vmID, ResourceB: agentID, PrimaryID: vmID}, + } { + if err := store.AddLink(link); err != nil { + t.Fatalf("add link: %v", err) + } + } + adapter := NewMonitorAdapter(NewRegistry(store)) + adapter.PopulateSnapshotAndSupplemental(snapshot, records) + listed = resourceIDs(adapter.GetAll()) + if slices.Contains(listed, agentID) || slices.Contains(listed, oldAgentID) { + t.Fatalf("linked estate = %v, want both agents folded into the VM %s", listed, vmID) + } + + rest := NewRegistry(store) + rest.IngestResources(adapter.GetAll()) + rest.IngestRecords(SourceAvailability, checks) + for _, view := range []struct { + name string + rr *ResourceRegistry + }{{"monitor", adapter.currentRegistry()}, {"resources API", rest}} { + assertAvailabilityCheckAttachedTo(t, view.name, view.rr, "probe-old", vmID) + for _, folded := range []string{agentID, oldAgentID} { + if got, ok := view.rr.ResolveReferenceID(folded); !ok || got != vmID { + t.Fatalf("%s: ResolveReferenceID(%s) = %q, %t, want the VM %s", view.name, folded, got, ok, vmID) + } + } + } +} + +// The fold index follows the rows holding each folded ID. A seeded listing can +// carry one folded ID on two rows, or on a row while a live row has that ID. A +// live row answers for itself; two holders resolve to nothing, without falling +// through to weaker matches such as a hostname alias, so an explicit check link +// stays fail-closed. Holders a link later merges, and a holder re-keyed in +// place, keep resolving. +func TestLinkFoldIndexFollowsItsHolders(t *testing.T) { + now := time.Now().UTC() + vm := func(id string, folded ...string) Resource { + return Resource{ + ID: id, Type: ResourceTypeVM, Name: id, Status: StatusOnline, LastSeen: now, + Sources: []DataSource{SourceVMware}, linkFoldedIDs: folded, + } + } + + t.Run("ambiguous holders fail closed", func(t *testing.T) { + rr := NewRegistry(nil) + rr.IngestResources([]Resource{ + vm("vm-a", "agent-shared", "agent-live"), + vm("vm-b", "agent-shared"), + {ID: "agent-live", Type: ResourceTypeAgent, Name: "agent-live", Status: StatusOnline, LastSeen: now, Sources: []DataSource{SourceAgent}}, + { + ID: "vm-c", Type: ResourceTypeVM, Name: "vm-c", Status: StatusOnline, LastSeen: now, + Sources: []DataSource{SourceVMware}, Identity: ResourceIdentity{Hostnames: []string{"agent-shared"}}, + }, + }) + if got, ok := rr.ResolveReferenceID("agent-shared"); ok { + t.Fatalf("ResolveReferenceID(agent-shared) = %q, want no answer while two rows hold it", got) + } + if got, ok := rr.ResolveReferenceID("agent-live"); !ok || got != "agent-live" { + t.Fatalf("ResolveReferenceID(agent-live) = %q, %t, want the live row itself", got, ok) + } + rr.IngestRecords(SourceAvailability, []IngestRecord{linkedGuestCheck(t, "probe-shared", "192.0.2.62", "agent-shared", now)}) + endpoint := availabilityEndpointByTarget(t, rr, "probe-shared") + if endpoint.Availability.CorrelationState != AvailabilityCorrelationUnresolved { + t.Fatalf("ambiguous folded link correlation = %+v, want unresolved", endpoint.Availability) + } + }) + + t.Run("holders merged by a link resolve to the survivor", func(t *testing.T) { + store := NewMemoryStore() + if err := store.AddLink(ResourceLink{ResourceA: "vm-a", ResourceB: "vm-b", PrimaryID: "vm-a"}); err != nil { + t.Fatalf("add link: %v", err) + } + rr := NewRegistry(store) + rr.IngestResources([]Resource{vm("vm-a", "agent-shared"), vm("vm-b", "agent-shared")}) + for _, ref := range []string{"agent-shared", "vm-b"} { + if got, ok := rr.ResolveReferenceID(ref); !ok || got != "vm-a" { + t.Fatalf("ResolveReferenceID(%s) = %q, %t, want vm-a", ref, got, ok) + } + } + }) + + t.Run("re-keyed holder keeps its folds", func(t *testing.T) { + rr := NewRegistry(nil) + disk := vm("disk-old", "disk-folded") + disk.Type = ResourceTypePhysicalDisk + rr.IngestResources([]Resource{disk}) + rr.mu.Lock() + rr.rekeyPhysicalDiskLocked(rr.resources["disk-old"], "disk-new") + rr.mu.Unlock() + if got, ok := rr.ResolveReferenceID("disk-folded"); !ok || got != "disk-new" { + t.Fatalf("ResolveReferenceID(disk-folded) = %q, %t, want the re-keyed holder disk-new", got, ok) + } + }) +} diff --git a/internal/unifiedresources/clone.go b/internal/unifiedresources/clone.go index 0beca03f35..2a5d78413c 100644 --- a/internal/unifiedresources/clone.go +++ b/internal/unifiedresources/clone.go @@ -62,6 +62,7 @@ func cloneResourceData(in *Resource) Resource { out.DiscoveryReadiness = cloneResourceDiscoveryReadiness(in.DiscoveryReadiness) out.MetricsTarget = cloneMetricsTarget(in.MetricsTarget) out.SupersededCanonicalIDs = cloneStringSlice(in.SupersededCanonicalIDs) + out.linkFoldedIDs = cloneStringSlice(in.linkFoldedIDs) out.PlatformScopes = cloneStringSlice(in.PlatformScopes) out.Sources = cloneDataSourceSlice(in.Sources) out.SourceStatus = cloneSourceStatusMap(in.SourceStatus) diff --git a/internal/unifiedresources/clone_test.go b/internal/unifiedresources/clone_test.go index 63b87b54a1..25eb91dec3 100644 --- a/internal/unifiedresources/clone_test.go +++ b/internal/unifiedresources/clone_test.go @@ -269,6 +269,24 @@ func TestCloneResource_MutateParentBySource(t *testing.T) { } } +// A registry seeded from another registry's listing indexes the folds each +// cloned resource carries, so the clone must carry them, detached. +func TestCloneResourceCarriesLinkFoldedIDs(t *testing.T) { + original := &Resource{ID: "vm-1", linkFoldedIDs: []string{"agent-1", "agent-2"}} + cloned := cloneResource(original) + + if !reflect.DeepEqual(cloned.linkFoldedIDs, []string{"agent-1", "agent-2"}) { + t.Fatalf("cloned linkFoldedIDs = %v, want the original's folds", cloned.linkFoldedIDs) + } + cloned.linkFoldedIDs[0] = "MUTATED" + if original.linkFoldedIDs[0] != "agent-1" { + t.Error("mutating cloned linkFoldedIDs should not affect original") + } + if listed := cloneMaterializedResource(original); !reflect.DeepEqual(listed.linkFoldedIDs, original.linkFoldedIDs) { + t.Fatalf("listed linkFoldedIDs = %v, want %v", listed.linkFoldedIDs, original.linkFoldedIDs) + } +} + func TestCloneResource_MutateAvailabilityTimes(t *testing.T) { checkedAt := time.Date(2026, time.July, 9, 12, 0, 0, 0, time.UTC) succeededAt := checkedAt.Add(-time.Minute) diff --git a/internal/unifiedresources/registry.go b/internal/unifiedresources/registry.go index c1b036344f..93545322c1 100644 --- a/internal/unifiedresources/registry.go +++ b/internal/unifiedresources/registry.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "fmt" "log" + "slices" "sort" "strings" "sync" @@ -95,10 +96,15 @@ type ResourceRegistry struct { exclusions map[string]struct{} identityPins *identityPinIndex pbsBackups []models.PBSBackup - // linkMergedIDs holds the resources a manual link folded into their - // primary. They are still observed, so identity succession treats them - // as live. - linkMergedIDs map[string]struct{} + // linkFoldIndex maps each canonical ID a manual link folded into another + // resource to the rows that took it in (Resource.linkFoldedIDs). Folded + // resources are still observed, so identity succession treats them as + // live, and references to them resolve to the row holding them. Holders + // carry their folds, so a registry seeded from another registry's listing + // indexes the same ones. Entries are rows, not IDs, and are checked on + // read (linkFoldHoldersLocked): a holder folded away, replaced or re-keyed + // needs no bookkeeping. + linkFoldIndex map[string][]*Resource // supersededIndex maps record-declared retired canonical IDs to the live // resource that superseded them, so references persisted under a retired // ID (availability links, API reads) keep resolving. An empty value marks @@ -774,8 +780,49 @@ func (rr *ResourceRegistry) canonicalIDObservedLocked(id string) bool { if _, live := rr.resources[id]; live { return true } - _, folded := rr.linkMergedIDs[id] - return folded + return len(rr.linkFoldHoldersLocked(id)) > 0 +} + +// indexLinkFoldedIDsLocked records that holder took in canonical IDs operator +// links folded into it. +func (rr *ResourceRegistry) indexLinkFoldedIDsLocked(holder *Resource, foldedIDs []string) { + if holder == nil || len(foldedIDs) == 0 { + return + } + if rr.linkFoldIndex == nil { + rr.linkFoldIndex = make(map[string][]*Resource) + } + for _, foldedID := range foldedIDs { + if !slices.Contains(rr.linkFoldIndex[foldedID], holder) { + rr.linkFoldIndex[foldedID] = append(rr.linkFoldIndex[foldedID], holder) + } + } +} + +// linkFoldHoldersLocked lists the live rows that hold a canonical ID an +// operator link folded into them. More than one is ambiguous. +func (rr *ResourceRegistry) linkFoldHoldersLocked(ref string) []string { + ref = CanonicalResourceID(ref) + var holders []string + for _, holder := range rr.linkFoldIndex[ref] { + if rr.resources[holder.ID] != holder || !slices.Contains(holder.linkFoldedIDs, ref) || + slices.Contains(holders, holder.ID) { + continue + } + holders = append(holders, holder.ID) + } + return holders +} + +// linkFoldHolderLocked resolves a canonical ID an operator link folded into +// another resource to the row holding it. ambiguous reports two holders: the +// reference must then resolve to nothing, not fall through to weaker matches. +func (rr *ResourceRegistry) linkFoldHolderLocked(ref string) (holderID string, ambiguous bool) { + holders := rr.linkFoldHoldersLocked(ref) + if len(holders) != 1 { + return "", len(holders) > 1 + } + return holders[0], false } // applyRecordSuccessions re-keys operator-owned store rows from canonical IDs @@ -875,6 +922,7 @@ func (rr *ResourceRegistry) ingestResources(resources []Resource, thresholds map rr.seedSourceMappingsFromResourceLocked(rr.resources[resourceID]) if resource := rr.resources[resourceID]; resource != nil { rr.indexSupersededCanonicalIDsLocked(resourceID, resource.SupersededCanonicalIDs) + rr.indexLinkFoldedIDsLocked(resource, resource.linkFoldedIDs) rr.indexPhysicalDiskHardwareLocked(resource) } } @@ -1527,6 +1575,11 @@ func (rr *ResourceRegistry) resolveReferenceIDLocked(ref string) (string, bool) if resolvedID := rr.supersededResourceIDLocked(ref); rr.resources[resolvedID] != nil { return resolvedID, false } + // A resource an operator link folded into another is that row now, as + // its agent alert references ("agent:") already are. + if resolvedID, ambiguous := rr.linkFoldHolderLocked(ref); resolvedID != "" || ambiguous { + return resolvedID, false + } if resolvedID := rr.uniqueSourceResourceIDLocked(ref); rr.resources[resolvedID] != nil { return resolvedID, false } @@ -3614,10 +3667,12 @@ func (rr *ResourceRegistry) resolveAvailabilityLinkedResource(ref string, incomi return "" } - // References persisted under a retired canonical ID or under a - // node-scoped guest source ID follow the resource across identity eras - // and live migrations. These arms resolve provider-declared persistence - // keys, not display aliases, so the explicit link stays fail-closed. + // References persisted under a retired canonical ID, under a resource an + // operator link folded into another, or under a node-scoped guest source + // ID follow the resource across identity eras, links and live + // migrations. These arms resolve provider-declared persistence keys and + // operator links, not display aliases, so the explicit link stays + // fail-closed. eligible := func(candidateID string) string { candidateID = CanonicalResourceID(candidateID) existing := rr.resources[candidateID] @@ -3629,6 +3684,9 @@ func (rr *ResourceRegistry) resolveAvailabilityLinkedResource(ref string, incomi if candidateID := eligible(rr.supersededResourceIDLocked(exactID)); candidateID != "" { return candidateID } + if holderID, ambiguous := rr.linkFoldHolderLocked(exactID); holderID != "" || ambiguous { + return eligible(holderID) + } if candidateID := eligible(rr.uniqueSourceResourceIDLocked(ref)); candidateID != "" { return candidateID } @@ -4844,14 +4902,21 @@ func (rr *ResourceRegistry) applyManualLinks(thresholds map[DataSource]time.Dura rr.mergeResourceData(primary, other, thresholds) delete(rr.resources, otherID) - if rr.linkMergedIDs == nil { - rr.linkMergedIDs = make(map[string]struct{}) - } - rr.linkMergedIDs[otherID] = struct{}{} + rr.foldLinkedResourceLocked(primary, otherID, other) rr.updateSourceMappings(otherID, primaryID) } } +// foldLinkedResourceLocked records on the link primary the resource folded +// into it and every ID earlier links folded into that resource, so a chain of +// links resolves to its last primary. The folded row has left the registry, +// so its own index entries lapse on read. +func (rr *ResourceRegistry) foldLinkedResourceLocked(primary *Resource, otherID string, other *Resource) { + folded := append([]string{otherID}, other.linkFoldedIDs...) + primary.linkFoldedIDs = uniqueTrimmed(append(append([]string(nil), primary.linkFoldedIDs...), folded...)...) + rr.indexLinkFoldedIDsLocked(primary, folded) +} + func (rr *ResourceRegistry) mergeResourceData(primary *Resource, other *Resource, thresholds map[DataSource]time.Duration) { if other == nil || primary == nil { return diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index d54c5e831b..e86649a52c 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -58,6 +58,13 @@ type Resource struct { ChildCount int `json:"childCount,omitempty"` parentBySource map[DataSource]string + // linkFoldedIDs lists the canonical IDs operator links folded into this + // resource, including IDs folded into those along a chain. It rides + // in-memory clones, so a registry seeded from another registry's listing + // resolves references to them to the same merged row. It is never a + // superseded ID: a link is operator state, not a retired identity era. + linkFoldedIDs []string + Tags []string `json:"tags,omitempty"` CustomURL string `json:"customUrl,omitempty"` Capabilities []ResourceCapability `json:"capabilities,omitempty"`