Integrate Core resource identity history correction

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-29 19:28:51 +01:00
6 changed files with 92 additions and 2 deletions
@@ -817,6 +817,9 @@ change-journal rows when the PVE inventory is empty. Agent-only disks remain
visible through their Agent source, and an explicit failed-query Agent fallback
still supplies PVE inventory. `TestPhysicalDiskSkippedPollDoesNotPromoteAgentOnlySMARTToPVEInventory`
checks repeated empty-inventory/skipped-poll cycles and journal row counts;
its serial-bearing fixture yields a tags-only row if the source guard is
removed, matching the reported history shape without claiming the reporter's
topology or installed write rate. With the guard, all three cycles add no rows.
`TestPhysicalDiskSkippedPollPreservesSourceIdentity` checks genuine PVE disk
continuity. These fixtures do not establish the reporter's installed cause.
SMART enrichment matches serial, WWN, device path, and controller
@@ -5055,6 +5055,13 @@ for provider-read breadcrumbs such as VMware tasks and events, plus the
change model instead of introducing a second event shape, and `RecordChange`
must stay idempotent by canonical change ID so poller refreshes and replayed
supplemental snapshots do not duplicate resource history.
Resource identity change emission compares scalar identifiers exactly and
hostname, IP-address and MAC-address lists as sets. Reordering a source merge
or changing a list between nil and empty is not an identity change; a changed
serial/machine ID, guest key or actual set member still is. This prevents
no-op `identity` configuration rows without weakening canonical matching or
changing the public resource shape. `TestResourceChangeIdentityIgnoresSetOrderAndEmptySlices`
pins both the quiet and real-change cases.
Change emission over registry rebuilds must diff relationships by edge
identity only: canonical source, canonical target, type, and active state,
order-insensitive (`relationshipsEquivalent` in `change_emission.go`).
@@ -195,7 +195,10 @@ func TestPhysicalDiskSkippedPollDoesNotPromoteAgentOnlySMARTToPVEInventory(t *te
ID: "agent", Hostname: "node", LinkedNodeID: "pve-node",
Status: "online", LastSeen: now,
Sensors: models.HostSensorSummary{SMART: []models.HostDiskSMART{{
Device: "sda", Type: "sata", Health: "PASSED",
// A stable serial keeps the canonical identity unchanged; without
// the source guard, the false PVE observation produces exactly the
// tags-only change reported in #2319 rather than a tags+identity row.
Device: "sda", Serial: "disk-serial", Type: "sata", Health: "PASSED",
}}},
})
store := unifiedresources.NewMemoryStore()
+15 -1
View File
@@ -236,7 +236,7 @@ func resourceChangedFields(before, after Resource) []string {
if before.CustomURL != after.CustomURL {
changed = append(changed, "customUrl")
}
if !reflect.DeepEqual(before.Identity, after.Identity) {
if !resourceIdentityEquivalent(before.Identity, after.Identity) {
changed = append(changed, "identity")
}
if (isAvailabilityOwnedResource(before) || isAvailabilityOwnedResource(after)) &&
@@ -545,6 +545,20 @@ func changeSourceAdapterForDataSource(source DataSource) ChangeSourceAdapter {
}
}
// Identity addresses, hostnames and MACs are sets, not ordered observations.
// Registry merges can reorder them or turn a nil slice into an empty one while
// the actual identity remains unchanged; those differences must not create
// configuration-history rows on every poll.
func resourceIdentityEquivalent(a, b ResourceIdentity) bool {
return a.MachineID == b.MachineID &&
a.DMIUUID == b.DMIUUID &&
a.ClusterName == b.ClusterName &&
a.ProxmoxGuestKey == b.ProxmoxGuestKey &&
sameStringSet(a.Hostnames, b.Hostnames) &&
sameStringSet(a.IPAddresses, b.IPAddresses) &&
sameStringSet(a.MACAddresses, b.MACAddresses)
}
func sameStringSet(a, b []string) bool {
if len(a) != len(b) {
return false
@@ -522,6 +522,36 @@ func TestBuildResourceChange_ClassifiesConfigUpdate(t *testing.T) {
}
}
func TestResourceChangeIdentityIgnoresSetOrderAndEmptySlices(t *testing.T) {
before := Resource{
ID: "disk-1", Type: ResourceTypePhysicalDisk, Status: StatusOnline,
Identity: ResourceIdentity{
MachineID: "serial-1", Hostnames: []string{"node", "node.example"},
},
}
after := before
after.Identity = ResourceIdentity{
MachineID: "serial-1", Hostnames: []string{"node.example", "node"},
IPAddresses: []string{}, MACAddresses: []string{},
}
if change := buildResourceChange(before, true, after, true, time.Now().UTC(), nil, SourcePulseDiff, ""); change != nil {
t.Fatalf("unchanged identity set emitted history: %+v", change)
}
after.Identity.MachineID = "serial-2"
change := buildResourceChange(before, true, after, true, time.Now().UTC(), nil, SourcePulseDiff, "")
if change == nil || !sameStringSet(mustChangedFields(t, change), []string{"identity"}) {
t.Fatalf("real identity change not recorded: %+v", change)
}
after.Identity.MachineID = before.Identity.MachineID
after.Identity.IPAddresses = []string{"192.0.2.10"}
change = buildResourceChange(before, true, after, true, time.Now().UTC(), nil, SourcePulseDiff, "")
if change == nil || !sameStringSet(mustChangedFields(t, change), []string{"identity"}) {
t.Fatalf("new identity set member not recorded: %+v", change)
}
}
func mustChangedFields(t *testing.T, change *ResourceChange) []string {
t.Helper()
raw, ok := change.Metadata["changedFields"]
@@ -58,6 +58,39 @@ func TestRegistryGenerationComparisonIgnoresUnchangedTelemetry(t *testing.T) {
}
}
func TestRegistryGenerationComparisonTreatsIdentityListsAsSets(t *testing.T) {
store := NewMemoryStore()
now := time.Date(2026, 9, 29, 17, 0, 0, 0, time.UTC)
entry := IngestRecord{
SourceID: "disk-1",
Resource: Resource{Type: ResourceTypePhysicalDisk, Name: "disk", Status: StatusOnline, LastSeen: now},
Identity: ResourceIdentity{MachineID: "serial-1", Hostnames: []string{"node", "node.example"}},
}
before := NewRegistry(store)
before.IngestRecords(SourceAgent, []IngestRecord{entry})
reordered := entry
reordered.Identity = ResourceIdentity{
MachineID: "serial-1", Hostnames: []string{"node.example", "node"},
IPAddresses: []string{}, MACAddresses: []string{},
}
after := NewRegistry(store)
after.IngestRecords(SourceAgent, []IngestRecord{reordered})
recordRegistryChangesBetweenGenerations(before, after, now.Add(time.Minute), nil, SourcePulseDiff, "")
if len(store.changes) != 0 {
t.Fatalf("equivalent identity lists emitted %d history rows: %+v", len(store.changes), store.changes)
}
changed := reordered
changed.Identity.IPAddresses = []string{"192.0.2.10"}
later := NewRegistry(store)
later.IngestRecords(SourceAgent, []IngestRecord{changed})
recordRegistryChangesBetweenGenerations(after, later, now.Add(2*time.Minute), nil, SourcePulseDiff, "")
if len(store.changes) != 1 || !sameStringSet(mustChangedFields(t, &store.changes[0]), []string{"identity"}) {
t.Fatalf("real address change not recorded once: %+v", store.changes)
}
}
// TestMemoryStore_RecordActionAuditAppliesRedaction is an integration check
// at the registry-store boundary. The MemoryStore is the backing store the
// registry uses in tests and contract examples, and operator-authored audit