fix(monitoring): stop host-agent endurance raising disk life

The physical-disk SMART merge let a linked host agent raise a disk's
remaining life above the Proxmox inventory value. An intermittent NVMe
PercentageUsed 0 reading therefore resolved a genuine low-life disk-wearout
alert, which re-fired on the next physical-disk poll and emitted a fresh
resolved notification every poll interval (#2112).

The merge now takes the agent value only when the Proxmox value is unreported
or more pessimistic, never raising a reported remaining-life value, while
still recording the agent SMART attributes. This does not change agent
admission, token binding or removal-block behaviour.

Regression: TestMergeHostAgentSMARTIntoDisks_AgentWearoutDoesNotHideLowPVELife
and TestMergeHostAgentSMARTIntoDisks_AgentWearoutFillsUnreportedPVELife in
internal/monitoring/monitor_integration_test.go. The monitoring contract is
updated in this commit.

Change-source: pulse-maintainer
Contract-Neutral: Behavioral bug fix in the physical-disk SMART merge; no public API or external contract delta. The monitoring contract is updated in this commit and the dependent agent-lifecycle contract and accepted proof file follow in the next commit.
This commit is contained in:
pulse-triage[bot]
2026-09-19 17:23:14 +01:00
parent 50f744fe6b
commit 0910542f0a
3 changed files with 97 additions and 2 deletions
@@ -4101,3 +4101,22 @@ consults the base identity as well when the presenting token had no prior
binding: a fresh install token clears the block and heals to the base identity,
and a token already bound to its own derived identity is left alone (#1753).
Focused proof lives in `internal/monitoring/monitor_host_agents_test.go`.
### Host-agent endurance evidence cannot raise a disk's remaining life
The physical-disk merge lets the linked host agent's SMART endurance counter
correct the Proxmox inventory, because the agent's `PercentageUsed` is more
precise than the provider's coarse `wearout`. It must not, however, make a disk
look healthier than Proxmox already reports. An intermittent agent reading
(for example an NVMe endurance log that briefly reports `PercentageUsed` 0)
previously raised the merged remaining life to 100, which resolved a genuine
low-life alert and let it re-fire on the next physical-disk poll with a fresh
resolved notification, repeating roughly every poll interval (#2112). The merge
now takes the agent value when the Proxmox value is unreported (`-1`) or when
the agent value is lower (more pessimistic); it never raises a reported
remaining-life value. `PercentageUsed` and the other merged SMART attributes are
still recorded. Focused proof lives in
`internal/monitoring/monitor_integration_test.go`
(`TestMergeHostAgentSMARTIntoDisks_AgentWearoutDoesNotHideLowPVELife` and
`TestMergeHostAgentSMARTIntoDisks_AgentWearoutFillsUnreportedPVELife`). This is
synthetic merge evidence, not reporter acceptance or hardware confirmation.
+11 -2
View File
@@ -627,11 +627,20 @@ func mergeHostAgentSMARTIntoDisks(disks []models.PhysicalDisk, nodes []models.No
Msg("Matched host agent SMART temperature")
}
// Always merge SMART attributes from host agent
// Always merge SMART attributes from host agent. The agent's endurance
// counter is more precise than the Proxmox inventory, so it may correct
// a coarse value downward or fill an unreported one. It must not,
// however, make a disk look healthier than the Proxmox inventory already
// reports: an intermittent agent reading (for example an NVMe log page
// that briefly reports PercentageUsed 0) used to raise the remaining
// life, resolve a genuine low-life alert, and let it re-fire on the next
// poll with a fresh resolved notification (#2112).
if matched.Attributes != nil {
updated[i].SmartAttributes = smartAttributesCopy(matched.Attributes)
if derivedWearout := deriveWearoutFromSMARTAttributes(matched.Attributes); derivedWearout >= 0 {
updated[i].Wearout = derivedWearout
if updated[i].Wearout < 0 || derivedWearout < updated[i].Wearout {
updated[i].Wearout = derivedWearout
}
}
}
if matched.IO != nil {
@@ -217,6 +217,73 @@ func TestMergeHostAgentSMARTIntoDisks_SMARTFailureAndWearoutOverrideCoarsePVEVal
}
}
func TestMergeHostAgentSMARTIntoDisks_AgentWearoutDoesNotHideLowPVELife(t *testing.T) {
// A worn drive whose NVMe endurance log intermittently reads
// PercentageUsed 0 must not have its Proxmox-reported remaining life
// raised to 100. That resolved the low-life alert and let it re-fire on the
// next poll, producing repeated notifications (#2112).
disks := []models.PhysicalDisk{{
ID: "d1",
Node: "pve1",
Serial: "SER1",
Type: "nvme",
Health: "PASSED",
Wearout: 0,
}}
nodes := []models.Node{{Name: "pve1", LinkedAgentID: "host-1"}}
used := 0
hosts := []models.Host{{
ID: "host-1",
Sensors: models.HostSensorSummary{
SMART: []models.HostDiskSMART{{
Device: "/dev/nvme0n1",
Serial: "SER1",
Health: "PASSED",
Attributes: &models.SMARTAttributes{
PercentageUsed: &used,
},
}},
},
}}
result := mergeHostAgentSMARTIntoDisks(disks, nodes, hosts)
if result[0].Wearout != 0 {
t.Fatalf("agent endurance reading raised the remaining life of a worn disk: wearout=%d", result[0].Wearout)
}
if result[0].SmartAttributes == nil || result[0].SmartAttributes.PercentageUsed == nil || *result[0].SmartAttributes.PercentageUsed != 0 {
t.Fatalf("agent SMART attributes were not merged: %+v", result[0].SmartAttributes)
}
}
func TestMergeHostAgentSMARTIntoDisks_AgentWearoutFillsUnreportedPVELife(t *testing.T) {
disks := []models.PhysicalDisk{{
ID: "d1",
Node: "pve1",
Serial: "SER1",
Type: "nvme",
Wearout: -1,
}}
nodes := []models.Node{{Name: "pve1", LinkedAgentID: "host-1"}}
used := 40
hosts := []models.Host{{
ID: "host-1",
Sensors: models.HostSensorSummary{
SMART: []models.HostDiskSMART{{
Device: "/dev/nvme0n1",
Serial: "SER1",
Attributes: &models.SMARTAttributes{
PercentageUsed: &used,
},
}},
},
}}
result := mergeHostAgentSMARTIntoDisks(disks, nodes, hosts)
if result[0].Wearout != 60 {
t.Fatalf("agent endurance did not fill unreported PVE life: wearout=%d", result[0].Wearout)
}
}
func TestMergeHostAgentSMARTIntoDisks_DoesNotReplaceFailureWithPassed(t *testing.T) {
disks := []models.PhysicalDisk{{ID: "d1", Node: "pve1", Serial: "SER1", Health: "FAILED"}}
nodes := []models.Node{{Name: "pve1", LinkedAgentID: "host-1"}}