Keep Agent-only SMART disks out of PVE poll readback

Linked Agent SMART disks inherit a PVE instance for presentation. Restrict skipped-poll readback to actual PVE observations so an empty disks/list cannot alternate fabricated inventory, tags and history rows. Pin repeated cycles and the source boundary in the monitoring contracts.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-09-29 18:16:44 +01:00
parent 41bee3d475
commit 0204fe000f
4 changed files with 80 additions and 1 deletions

View file

@ -136,6 +136,13 @@ monitoring metadata and does not change Agent registration, execution authority,
or the Agent report wire contract. `internal/models/metrics_types_test.go` covers
snapshot retention and isolation, and the monitoring collection-trust roundtrip
covers merged Agent/Proxmox disks without losing the Proxmox source schedule.
An Agent-only SMART disk linked to a PVE node may carry that node's instance
for presentation, but the instance is not an Agent report of PVE disk
inventory. Skipped PVE polls must not round-trip that disk into PVE-owned
state; the monitoring readback requires an actual Proxmox source observation.
This leaves Agent source identity, report admission and command authority
unchanged. The repeated-cycle regression is
`TestPhysicalDiskSkippedPollDoesNotPromoteAgentOnlySMARTToPVEInventory`.
Assistant historical metric wiring uses the current monitor's retained store
and registry metrics coordinates. Historical reads do not alter enrollment,

View file

@ -809,7 +809,17 @@ Proxmox physical-disk polling is also a continuity boundary. A failed or
permission-denied `disks/list` call must remain an error so the monitor can use
linked host-agent inventory or retain same-instance, same-node prior evidence;
it must never become a successful empty inventory that removes valid boot or
data disks. SMART enrichment matches serial, WWN, device path, and controller
data disks. Between PVE disk polls, readback into `State.PhysicalDisks` accepts
only a disk with an actual Proxmox source observation. A linked Agent-only
SMART disk may inherit the PVE instance for presentation, but must not be
written back as PVE inventory; doing so adds and removes a false source and
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;
`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
member topology uniquely and fail-closed. Serial and WWN are interchangeable
hardware-identity carriers across reporters: comparison may case-fold and
remove only `naa.`, `eui.`, `wwn-`, and `0x` framing, but must reject

View file

@ -854,6 +854,13 @@ func physicalDisksForInstanceFromReadState(readState unifiedresources.ReadState,
if disk == nil || disk.Instance() != instance {
continue
}
// A linked host Agent's SMART disk inherits the PVE instance for
// presentation, but that does not make it a PVE inventory record.
// Feeding it back into State.PhysicalDisks invents a PVE source and
// alternates tags/history whenever the real disks/list is empty.
if _, observedByPVE := disk.SourceStatus(unifiedresources.SourceProxmox); !observedByPVE {
continue
}
out = append(out, physicalDiskFromReadStateView(disk))
}
return out

View file

@ -179,6 +179,61 @@ func TestPhysicalDiskSkippedPollPreservesSourceIdentity(t *testing.T) {
}
}
// An agent disk may inherit its linked PVE node's instance for presentation,
// even when the PVE disks/list endpoint did not report that disk. The skipped
// poll must not turn that presentation scope into a PVE inventory observation:
// an empty PVE inventory on the next full poll would then remove the invented
// observation and record spurious configuration changes on every cycle (#2319).
func TestPhysicalDiskSkippedPollDoesNotPromoteAgentOnlySMARTToPVEInventory(t *testing.T) {
state := models.NewState()
now := time.Now().UTC()
state.UpdateNodesForInstance("pve", []models.Node{{
ID: "pve-node", Name: "node", Instance: "pve", LinkedAgentID: "agent",
Status: "online", LastSeen: now,
}})
state.UpsertHost(models.Host{
ID: "agent", Hostname: "node", LinkedNodeID: "pve-node",
Status: "online", LastSeen: now,
Sensors: models.HostSensorSummary{SMART: []models.HostDiskSMART{{
Device: "sda", Type: "sata", Health: "PASSED",
}}},
})
store := unifiedresources.NewMemoryStore()
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(store))
monitor := &Monitor{
state: state, resourceStore: adapter,
lastPhysicalDiskPoll: map[string]time.Time{"pve": now},
}
for cycle := 0; cycle < 3; cycle++ {
// A successful full PVE inventory read found no disks on this node.
state.UpdatePhysicalDisks("pve", nil)
adapter.PopulateFromSnapshot(state.GetSnapshot())
disks := adapter.PhysicalDisks()
if len(disks) != 1 || disks[0].Instance() != "pve" {
t.Fatalf("cycle %d: linked Agent SMART disk missing from presentation: %+v", cycle, disks)
}
if _, hasPVE := disks[0].SourceStatus(unifiedresources.SourceProxmox); hasPVE {
t.Fatalf("cycle %d: Agent-only disk unexpectedly has PVE source", cycle)
}
before, err := store.GetRecentChanges(disks[0].ID(), time.Time{}, 100)
if err != nil {
t.Fatal(err)
}
monitor.maybePollPhysicalDisksAsync(context.Background(), "pve", &config.PVEInstance{}, nil, nil, nil, nil)
adapter.PopulateFromSnapshot(state.GetSnapshot())
after, err := store.GetRecentChanges(disks[0].ID(), time.Time{}, 100)
if err != nil {
t.Fatal(err)
}
if len(after) != len(before) {
t.Fatalf("cycle %d: unchanged SMART disk emitted %d new history rows: %+v", cycle, len(after)-len(before), after)
}
if got := state.GetSnapshot().PhysicalDisks; len(got) != 0 {
t.Fatalf("cycle %d: Agent-only disk was written into PVE inventory: %+v", cycle, got)
}
}
}
func TestPhysicalDiskReadbackSourceIDFallback(t *testing.T) {
for _, resource := range []unifiedresources.Resource{
{ID: "canonical"},