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 (#2721)
Stacked on #2715 (fix/manual-links-record-ingest), whose commit this branch carries until it lands. ## Problem When an operator link folds one resource into another, such as a Pulse 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 (`LinkedResourceID` = the agent's `agent-…` ID) lost its target: the VM showed no check and the endpoint row kept no relationship. The resources API seeds a fresh registry from the monitor's already-folded listing and then replays the checks, so it lost the check too. With #2715 joining links on record ingest, the monitor broadcast does as well. `ResolveReferenceID` / `GetByReference` returned nothing for the folded ID either. A registry-local fold set is not enough: the resources API never sees the folded resource, so the monitor and the API would disagree. ## Change - The merged resource carries the IDs links folded into it (`Resource.linkFoldedIDs`, unexported, in-memory only), including IDs folded along a chain of links. Clones of the listing carry it, and resource seeding indexes it, so the monitor rebuild, the resources API and read-state overlays resolve a folded ID to the same merged row. - The index stores the holding rows and checks them on read. A holder that a later link merges away, or a disk re-keyed in place, stays consistent. An ID two rows hold resolves to nothing and does not fall through to weaker matches such as a hostname alias. - New fold arms in `resolveAvailabilityLinkedResource` and `resolveReferenceIDLocked`. An exact live ID still wins. - Deliberately unchanged: - **History resolution.** History bindings persist, and `expandHistoryAliases` walks them in both directions, so binding a folded ID would join two journals permanently. - **`SupersededCanonicalIDs`.** Alert-configuration and availability-link migrations consume it and would rewrite the operator's stored config. - **Patrol's finding operator-state projection** (`internal/api/router.go`) still reads an exact row first. That is a separate follow-up; the contract states the exception. ## Testing - `availability_link_test.go`: - `TestAvailabilityLinkToALinkFoldedResourceFollowsItsPrimary`: both link directions, two rebuilds, monitor + resources-API seeding + overlay; history and superseded IDs untouched. - `TestAvailabilityLinkFollowsChainedLinkFolds`. - `TestLinkFoldIndexFollowsItsHolders`: ambiguity with a hostname-alias decoy, holders merged by a link, a re-keyed holder. - The first two fail on #2715's head. The holder cases fail against the first version of this change (string-keyed index). - `go test ./internal/unifiedresources/` passes locally. `./internal/unifiedresources/... ./internal/monitoring/... ./internal/api/...` on pulse-dev: everything passes except the known `TestServerInfoEndpointReportsDevelopment`, which fails because the tree was rsynced without `.git`.
This commit is contained in:
@@ -6670,11 +6670,43 @@ separate.
|
||||
still take it from each other on alternate rebuilds, as before. Evidence
|
||||
a later record pass of the same rebuild merges into an already-joined row
|
||||
reaches neither side's pin.
|
||||
- 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
|
||||
registry indexes the folded IDs named by the merged resource's link fold
|
||||
record (`linkFolds`, `recordManualLinkFold`), which covers IDs folded along
|
||||
a chain of links, both when a link applies and when resource seeding
|
||||
copies the record in. 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 resource lookups by the folded ID
|
||||
(`GET /api/resources/{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`).
|
||||
`TestResourceUnlinkSplitsManuallyLinkedPair` pins the API lookup: the
|
||||
folded agent's ID answers with the VM while linked and with the agent's own
|
||||
row once unlinked.
|
||||
|
||||
`registry_merge_policy_test.go` pins the record links in both directions
|
||||
through two rebuilds and the live supplemental refresh, and checks that a
|
||||
|
||||
@@ -99,10 +99,16 @@ func TestResourceUnlinkSplitsManuallyLinkedPair(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("%s: VM %s missing from REST", step, vmID)
|
||||
}
|
||||
_, agentListed := get(agentID)
|
||||
// A lookup by the folded agent's ID answers with the row holding it,
|
||||
// so the agent is listed only when its own row answers.
|
||||
byAgentID, found := get(agentID)
|
||||
agentListed := found && byAgentID.ID == agentID
|
||||
if wantLinked && (vm.Agent == nil || agentListed) {
|
||||
t.Fatalf("%s: want agent folded into the VM, VM agent facet=%v agent row listed=%v", step, vm.Agent != nil, agentListed)
|
||||
}
|
||||
if wantLinked && byAgentID.ID != vmID {
|
||||
t.Fatalf("%s: lookup by folded agent %s answered %q (found %v), want the VM %s", step, agentID, byAgentID.ID, found, vmID)
|
||||
}
|
||||
if !wantLinked && (vm.Agent != nil || !agentListed) {
|
||||
t.Fatalf("%s: want VM and agent apart, VM agent facet=%v agent row listed=%v", step, vm.Agent != nil, agentListed)
|
||||
}
|
||||
|
||||
@@ -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,294 @@ 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 {
|
||||
folds := make([]ManualLinkFold, 0, len(folded))
|
||||
for _, foldedID := range folded {
|
||||
folds = append(folds, ManualLinkFold{HolderID: id, FoldedID: foldedID, Sources: []DataSource{SourceAgent}})
|
||||
}
|
||||
return Resource{
|
||||
ID: id, Type: ResourceTypeVM, Name: id, Status: StatusOnline, LastSeen: now,
|
||||
Sources: []DataSource{SourceVMware}, linkFolds: folds,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.linkFolds). 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
|
||||
// linkOwnPins holds the identity pin each side of a manual link derives
|
||||
// from its own sources, captured before the link merged them. A nil
|
||||
// value records a side with no pinnable identity. PersistIdentityPins
|
||||
@@ -800,8 +806,59 @@ 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
|
||||
}
|
||||
|
||||
// indexLinkFoldsLocked records that holder took in the canonical IDs its
|
||||
// link folds name (Resource.linkFolds, recordManualLinkFold).
|
||||
func (rr *ResourceRegistry) indexLinkFoldsLocked(holder *Resource) {
|
||||
if holder == nil || len(holder.linkFolds) == 0 {
|
||||
return
|
||||
}
|
||||
if rr.linkFoldIndex == nil {
|
||||
rr.linkFoldIndex = make(map[string][]*Resource)
|
||||
}
|
||||
for _, fold := range holder.linkFolds {
|
||||
if !slices.Contains(rr.linkFoldIndex[fold.FoldedID], holder) {
|
||||
rr.linkFoldIndex[fold.FoldedID] = append(rr.linkFoldIndex[fold.FoldedID], holder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// holdsLinkFold reports whether a resource's link folds name foldedID.
|
||||
func holdsLinkFold(resource *Resource, foldedID string) bool {
|
||||
for _, fold := range resource.linkFolds {
|
||||
if fold.FoldedID == foldedID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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 || !holdsLinkFold(holder, 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
|
||||
@@ -901,6 +958,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.indexLinkFoldsLocked(resource)
|
||||
rr.indexPhysicalDiskHardwareLocked(resource)
|
||||
}
|
||||
}
|
||||
@@ -1537,6 +1595,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
|
||||
}
|
||||
@@ -3613,10 +3676,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]
|
||||
@@ -3628,6 +3693,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
|
||||
}
|
||||
@@ -4851,10 +4919,10 @@ func (rr *ResourceRegistry) applyManualLinks(thresholds map[DataSource]time.Dura
|
||||
rr.mergeResourceData(primary, other, thresholds)
|
||||
recordManualLinkFold(primary, primaryID, other, otherID)
|
||||
delete(rr.resources, otherID)
|
||||
if rr.linkMergedIDs == nil {
|
||||
rr.linkMergedIDs = make(map[string]struct{})
|
||||
}
|
||||
rr.linkMergedIDs[otherID] = struct{}{}
|
||||
// The fold record names every ID along a chain of links, so the chain
|
||||
// resolves to its last primary; the folded row's own index entries
|
||||
// lapse on read now that it has left the registry.
|
||||
rr.indexLinkFoldsLocked(primary)
|
||||
rr.updateSourceMappings(otherID, primaryID)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user