mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user