Fix physical disk SMART/Proxmox merge identity

Refs #1516

Refs #1483

Refs #1471
This commit is contained in:
rcourtman 2026-07-07 09:46:51 +01:00
parent 8eed26d653
commit 58ece3c1b8
14 changed files with 148 additions and 12 deletions

View file

@ -1113,9 +1113,12 @@ host agent reports SMART health, SMART identity, ZFS pool membership, or NVMe
trustworthy health, wearout, model, serial, WWN, type, size, or pool data, the
merge path in `internal/monitoring/monitor.go` must promote that missing data
into the canonical physical-disk model without overwriting provider truth. The
Proxmox polling runtime in `internal/monitoring/monitor_pve.go` must evaluate
disk alerts only after that merged disk view exists, so controller-backed disks
do not lose health and endurance coverage between collection and alerting.
read-state sensor conversion must preserve SMART `SizeBytes` so subsequent
refreshes keep whole-disk capacity evidence available for Proxmox disk merges.
The Proxmox polling runtime in `internal/monitoring/monitor_pve.go` must
evaluate disk alerts only after that merged disk view exists, so
controller-backed disks do not lose health and endurance coverage between
collection and alerting.
That same host-agent temperature boundary must prefer a recent linked host-agent
payload over legacy SSH collection once the agent provides any usable CPU, NVMe,
GPU, or SMART temperature reading. `internal/monitoring/monitor_polling_node_helpers.go`

View file

@ -1592,7 +1592,11 @@ The shared PVE setup-script SMART wrapper remains a storage/recovery dependency
only for disk-temperature evidence. Storage surfaces may depend on its explicit
`-d sat` and `-d scsi` retries for active direct Linux SATA/SAT-style disks, but
they must not fork a storage-local disk-temperature collector or replace the
API-owned setup-script contract.
API-owned setup-script contract. Storage physical-disk rows also depend on the
unified-resource disk contract preserving Proxmox node/instance metadata and
SMART capacity when Proxmox inventory and host-agent SMART telemetry merge;
storage/recovery consumers must read that canonical merged physical-disk
resource rather than rebuilding a local Proxmox/S.M.A.R.T. join.
Notification webhook management changes on shared `internal/api/` handlers are
likewise adjacent only: the webhook `signingSecret` payload field and its

View file

@ -28,6 +28,10 @@ reported as 0..100 percentages. Unified-resource host metric payloads and
host-derived storage adapters must clamp those reported percentages directly
rather than passing them through ratio-to-percent normalization, which is
reserved for providers that report 0..1 usage ratios.
Physical-disk resources own cross-source disk identity. When Proxmox inventory
and host-agent SMART telemetry describe the same device, the merged resource
must retain Proxmox node/instance source payloads while carrying SMART
temperature, capacity, health, and identity enrichment.
## Canonical Files
@ -2842,6 +2846,14 @@ Canonical physical-disk views now expose the full disk identity and SMART
metadata needed by monitoring refresh paths, so physical-disk temperature and
SMART merges can run from unified `ReadState` instead of from snapshot-owned
disk arrays.
When host-agent SMART and Proxmox physical-disk rows merge, the unified
resource must preserve both the enriched `PhysicalDiskMeta` and the Proxmox
source payload (`ProxmoxData.NodeName`, `Instance`, and source id). A
SMART-enriched disk must not lose Proxmox node membership, or later read-state
refreshes and Proxmox-scoped views can drop the disk even though inventory
still exists. `HostSMARTMeta` must carry `sizeBytes` through adapter, clone,
read-state, and API transport so disk capacity remains available after
registry rebuilds.
That same canonical physical-disk view must also expose source-independent host
context. When a disk is API-backed rather than node-backed, typed views should
fall back to canonical host identity such as `identity.hostnames` instead of

View file

@ -884,6 +884,7 @@ func hostSensorSummaryToFrontend(src HostSensorSummary) *HostSensorSummaryFronte
Serial: disk.Serial,
WWN: disk.WWN,
Type: disk.Type,
SizeBytes: disk.SizeBytes,
Temperature: disk.Temperature,
Health: disk.Health,
Standby: disk.Standby,

View file

@ -792,11 +792,12 @@ func (s HostSensorSummaryFrontend) NormalizeCollections() HostSensorSummaryFront
// HostDiskSMARTFrontend represents S.M.A.R.T. data for a disk from a host agent.
type HostDiskSMARTFrontend struct {
Device string `json:"device"` // Device name (e.g., sda)
Model string `json:"model,omitempty"` // Disk model
Serial string `json:"serial,omitempty"` // Serial number
WWN string `json:"wwn,omitempty"` // World Wide Name
Type string `json:"type,omitempty"` // Transport type: sata, sas, nvme
Device string `json:"device"` // Device name (e.g., sda)
Model string `json:"model,omitempty"` // Disk model
Serial string `json:"serial,omitempty"` // Serial number
WWN string `json:"wwn,omitempty"` // World Wide Name
Type string `json:"type,omitempty"` // Transport type: sata, sas, nvme
SizeBytes int64 `json:"sizeBytes,omitempty"`
Temperature int `json:"temperature"` // Temperature in Celsius
Health string `json:"health,omitempty"` // PASSED, FAILED, UNKNOWN
Standby bool `json:"standby,omitempty"` // True if disk was in standby

View file

@ -188,6 +188,7 @@ func convertUnifiedHostSMART(smart []unifiedresources.HostSMARTMeta) []models.Ho
Serial: disk.Serial,
WWN: disk.WWN,
Type: disk.Type,
SizeBytes: disk.SizeBytes,
Temperature: disk.Temperature,
Health: disk.Health,
Standby: disk.Standby,

View file

@ -3430,6 +3430,7 @@ func hostSensorsFromReadStateView(sensors *unifiedresources.HostSensorMeta) mode
Serial: smart.Serial,
WWN: smart.WWN,
Type: smart.Type,
SizeBytes: smart.SizeBytes,
Temperature: smart.Temperature,
Health: smart.Health,
Standby: smart.Standby,

View file

@ -211,6 +211,33 @@ func TestFindLinkedProxmoxEntityWithHints_UsesExactEndpointHostnameBeforeNameFal
}
}
func TestHostSensorsFromReadStateViewPreservesSMARTSizeBytes(t *testing.T) {
sensors := &unifiedresources.HostSensorMeta{
SMART: []unifiedresources.HostSMARTMeta{
{
Device: "/dev/sda",
Model: "CT240BX500SSD1",
Serial: "SATA-SERIAL-1",
Type: "sata",
SizeBytes: 240_057_409_536,
Temperature: 32,
Health: "PASSED",
},
},
}
got := hostSensorsFromReadStateView(sensors)
if len(got.SMART) != 1 {
t.Fatalf("SMART row count = %d, want 1", len(got.SMART))
}
if got.SMART[0].SizeBytes != 240_057_409_536 {
t.Fatalf("SMART SizeBytes = %d, want 240057409536", got.SMART[0].SizeBytes)
}
if got.SMART[0].Temperature != 32 {
t.Fatalf("SMART temperature = %d, want 32", got.SMART[0].Temperature)
}
}
func TestApplyDockerReportNormalizesContainerCPUCapacityAcceptedIngestProof(t *testing.T) {
monitor := newTestMonitor(t)
token := &config.APITokenRecord{ID: "token-docker-cpu", Name: "Docker Token"}

View file

@ -219,6 +219,7 @@ func resourceFromHost(host models.Host) (Resource, ResourceIdentity) {
Serial: s.Serial,
WWN: s.WWN,
Type: s.Type,
SizeBytes: s.SizeBytes,
Temperature: s.Temperature,
Health: s.Health,
Standby: s.Standby,

View file

@ -22,6 +22,32 @@ func TestCanonicalResourceTypeDoesNotAliasHost(t *testing.T) {
}
}
func TestHostSMARTMetaCarriesSizeBytesJSONContract(t *testing.T) {
payload := HostSMARTMeta{
Device: "/dev/sda",
Model: "CT240BX500SSD1",
Serial: "SATA-SERIAL-1",
Type: "sata",
SizeBytes: 240_057_409_536,
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal HostSMARTMeta: %v", err)
}
if !strings.Contains(string(data), `"sizeBytes":240057409536`) {
t.Fatalf("HostSMARTMeta JSON did not carry sizeBytes: %s", data)
}
var decoded HostSMARTMeta
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("unmarshal HostSMARTMeta: %v", err)
}
if decoded.SizeBytes != payload.SizeBytes {
t.Fatalf("decoded sizeBytes = %d, want %d", decoded.SizeBytes, payload.SizeBytes)
}
}
func TestContractResourceType(t *testing.T) {
tests := []struct {
name string

View file

@ -99,6 +99,64 @@ func TestMonitorAdapterReadStateForwardsToRegistry(t *testing.T) {
}
}
func TestMonitorAdapterPhysicalDiskReadStateRetainsProxmoxIdentityAfterSMARTMerge(t *testing.T) {
adapter := NewMonitorAdapter(NewRegistry(nil))
now := time.Date(2026, 7, 7, 10, 0, 0, 0, time.UTC)
adapter.PopulateFromSnapshot(models.StateSnapshot{
Hosts: []models.Host{
{
ID: "host-pve",
Hostname: "pve",
Status: "online",
LastSeen: now,
Sensors: models.HostSensorSummary{
SMART: []models.HostDiskSMART{
{
Device: "/dev/nvme0n1",
Model: "KINGSTON SNV3S2000G",
Serial: "SERIAL-NVME-0",
Type: "nvme",
SizeBytes: 2_000_398_934_016,
Temperature: 37,
Health: "PASSED",
},
},
},
},
},
PhysicalDisks: []models.PhysicalDisk{
{
ID: "homelab-pve--dev-nvme0n1",
Node: "pve",
Instance: "homelab",
DevPath: "/dev/nvme0n1",
Model: "KINGSTON SNV3S2000G",
Serial: "SERIAL-NVME-0",
Type: "nvme",
Size: 2_000_398_934_016,
Health: "PASSED",
LastChecked: now,
},
},
})
disks := adapter.PhysicalDisks()
if len(disks) != 1 {
t.Fatalf("physical disk count = %d, want 1", len(disks))
}
disk := disks[0]
if disk.Node() != "pve" || disk.Instance() != "homelab" {
t.Fatalf("merged disk lost Proxmox identity: node=%q instance=%q", disk.Node(), disk.Instance())
}
if disk.Temperature() != 37 {
t.Fatalf("merged disk temperature = %d, want 37", disk.Temperature())
}
if disk.SizeBytes() != 2_000_398_934_016 {
t.Fatalf("merged disk sizeBytes = %d, want 2000398934016", disk.SizeBytes())
}
}
func TestMonitorAdapterReadStateReturnsClonedIncidents(t *testing.T) {
adapter := NewMonitorAdapter(NewRegistry(nil))
now := time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC)

View file

@ -2649,9 +2649,6 @@ func (rr *ResourceRegistry) mergeInto(existing *Resource, incoming Resource, sou
// Update source payload
switch source {
case SourceProxmox:
if mergedPhysicalDisk {
break
}
existing.Proxmox = mergeProxmoxData(existing.Proxmox, incoming.Proxmox)
case SourceAgent:
if mergedPhysicalDisk {

View file

@ -2206,6 +2206,9 @@ func TestResourceRegistry_IngestSnapshotMergesAgentAndProxmoxPhysicalDisksByIden
if disk.PhysicalDisk == nil || disk.PhysicalDisk.Temperature != 37 {
t.Fatalf("expected merged SMART temperature from agent disk, got %+v", disk.PhysicalDisk)
}
if disk.Proxmox == nil || disk.Proxmox.NodeName != "tower" || disk.Proxmox.Instance != "pve-tower" {
t.Fatalf("expected merged disk to retain Proxmox node identity, got %+v", disk.Proxmox)
}
}
// Regression for issue #1483: a legacy host agent reports an NVMe disk keyed by

View file

@ -568,6 +568,7 @@ type HostSMARTMeta struct {
Serial string `json:"serial,omitempty"`
WWN string `json:"wwn,omitempty"`
Type string `json:"type,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
Temperature int `json:"temperature"`
Health string `json:"health"`
Standby bool `json:"standby,omitempty"`