mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Fix physical disk SMART/Proxmox merge identity
Refs #1516 Refs #1483 Refs #1471
This commit is contained in:
parent
8eed26d653
commit
58ece3c1b8
14 changed files with 148 additions and 12 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue