Resolve references to resources a manual link folded

When an operator link folds one resource into another, such as an agent
into the vSphere or Proxmox VM it runs in, the folded resource's
canonical ID stopped resolving. An availability check configured against
the agent lost its target: the VM showed no check and the endpoint row
kept no relationship. The resources API seeds from the monitor's
already-folded listing and then replays the checks, so it lost the check
too, and so does the monitor broadcast now that record ingest joins
links. Reference reads by the folded ID (alert intent, operator state,
API lookups) found nothing either.

The merged resource now carries the IDs links folded into it, including
IDs folded along a chain, in an in-memory field that rides clones of the
listing. Resource seeding indexes them, so the monitor rebuild, the
resources API and read-state overlays resolve a folded ID to the same
merged row. The index keeps the holding rows and checks them on read:
a holder that a later link merges away or that is re-keyed in place
stays consistent, and an ID two rows hold resolves to nothing instead
of to a weaker alias match.

History does not follow folds: history bindings persist and join two
journals in both directions, so they would outlive the link. Folded IDs
never become superseded IDs either, because alert and availability
migrations would rewrite those into the operator's stored config.
This commit is contained in:
rcourtman
2026-10-07 23:23:39 +01:00
parent b0590c0ccf
commit 8237cb997a
7 changed files with 439 additions and 19 deletions
@@ -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
@@ -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:<host ID>`) 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
@@ -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)
}
})
}
+1
View File
@@ -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)
+18
View File
@@ -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)
+79 -14
View File
@@ -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:<host ID>") 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
+7
View File
@@ -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"`