mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 21:47:20 +00:00
ec50758974
Splitting a merged physical disk (the drawer's "Split merged resource",
POST /api/resources/{id}/report-merge) recorded its exclusions and the
registry kept two rows, but the PVE disk poller had already paired the
linked agent's SMART row with the Proxmox disk by WWN, serial or path
before the registry saw either, and read no exclusions. The split Proxmox
row still carried the agent's attributes, I/O, health, any serial, WWN,
type or temperature it filled, and AgentSMARTReported, which hands the
disk's temperature alert to the agent's CheckHost. #2748 listed this as
the part of the split it did not reach.
mergeHostAgentSMARTIntoDisks now asks the resource store
(MonitorAdapter.ProxmoxDiskAgentSMARTSplit) once per matched row. The
answer is physicalDiskSplitLocked's, applied to the agent's disk as the
registry generation holds it and to the Proxmox observation under its own
ID, so the exclusions that keep the registry's rows apart keep the
poller's pair apart: an exclusion naming only the agent's candidate
splits nothing, a replacement in the slot inherits no split recorded
against the disk it replaced, and a manual link that leaves the
generation holding both observations as one disk pairs them again.
A split disk takes nothing from the row. That includes the node-sensor
temperature the agent supplied: the node poll takes its SMART list from a
reporting agent whose report carries a usable reading, so that reading is
the agent's row of the other disk; an SSH-supplied reading stays and the
PVE disk check judges it. The record is marked AgentSMARTSplit, so the
poll's evidence retention restores nothing into it, and the
Proxmox-query-failure fallback gives it no stand-in row.
The split applies on the first full disk poll that collects the node from
Proxmox and matches the agent's row; the unified-resources contract names
that lag and the other limits.
478 lines
14 KiB
Go
478 lines
14 KiB
Go
package monitoring
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
)
|
|
|
|
// --- mergeHostAgentSMARTIntoDisks ---
|
|
|
|
func TestMergeHostAgentSMARTIntoDisks_EmptyInputs(t *testing.T) {
|
|
// All combinations of empty inputs should return disks unchanged
|
|
disks := []models.PhysicalDisk{{ID: "d1"}}
|
|
if got := mergeHostAgentSMARTIntoDisks(nil, nil, nil, nil); got != nil {
|
|
t.Error("nil disks should return nil")
|
|
}
|
|
if got := mergeHostAgentSMARTIntoDisks(disks, nil, nil, nil); len(got) != 1 {
|
|
t.Error("nil nodes/hosts should return disks unchanged")
|
|
}
|
|
if got := mergeHostAgentSMARTIntoDisks(disks, []models.Node{}, nil, nil); len(got) != 1 {
|
|
t.Error("empty nodes should return disks unchanged")
|
|
}
|
|
if got := mergeHostAgentSMARTIntoDisks(disks, []models.Node{{Name: "n1"}}, nil, nil); len(got) != 1 {
|
|
t.Error("nil hosts should return disks unchanged")
|
|
}
|
|
}
|
|
|
|
func TestMergeHostAgentSMARTIntoDisks_MatchByWWN(t *testing.T) {
|
|
disks := []models.PhysicalDisk{
|
|
{ID: "d1", Node: "pve1", DevPath: "/dev/sda", WWN: "0x5001234", Temperature: 0},
|
|
}
|
|
nodes := []models.Node{
|
|
{Name: "pve1", LinkedAgentID: "host-1"},
|
|
}
|
|
hosts := []models.Host{
|
|
{
|
|
ID: "host-1",
|
|
Sensors: models.HostSensorSummary{
|
|
SMART: []models.HostDiskSMART{
|
|
{Device: "/dev/sda", WWN: "0x5001234", Temperature: 42},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
result := mergeHostAgentSMARTIntoDisks(disks, nodes, hosts, nil)
|
|
if result[0].Temperature != 42 {
|
|
t.Errorf("expected temp 42 via WWN match, got %d", result[0].Temperature)
|
|
}
|
|
}
|
|
|
|
func TestMergeHostAgentSMARTIntoDisks_MatchBySerial(t *testing.T) {
|
|
disks := []models.PhysicalDisk{
|
|
{ID: "d1", Node: "pve1", Serial: "SERIAL123", Temperature: 0},
|
|
}
|
|
nodes := []models.Node{
|
|
{Name: "pve1", LinkedAgentID: "host-1"},
|
|
}
|
|
hosts := []models.Host{
|
|
{
|
|
ID: "host-1",
|
|
Sensors: models.HostSensorSummary{
|
|
SMART: []models.HostDiskSMART{
|
|
{Device: "/dev/sdb", Serial: "serial123", Temperature: 38},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
result := mergeHostAgentSMARTIntoDisks(disks, nodes, hosts, nil)
|
|
if result[0].Temperature != 38 {
|
|
t.Errorf("expected temp 38 via serial match (case-insensitive), got %d", result[0].Temperature)
|
|
}
|
|
}
|
|
|
|
func TestMergeHostAgentSMARTIntoDisks_MatchByDevPath(t *testing.T) {
|
|
disks := []models.PhysicalDisk{
|
|
{ID: "d1", Node: "pve1", DevPath: "/dev/nvme0n1", Temperature: 0},
|
|
}
|
|
nodes := []models.Node{
|
|
{Name: "pve1", LinkedAgentID: "host-1"},
|
|
}
|
|
hosts := []models.Host{
|
|
{
|
|
ID: "host-1",
|
|
Sensors: models.HostSensorSummary{
|
|
SMART: []models.HostDiskSMART{
|
|
{Device: "nvme0n1", Temperature: 35},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
result := mergeHostAgentSMARTIntoDisks(disks, nodes, hosts, nil)
|
|
if result[0].Temperature != 35 {
|
|
t.Errorf("expected temp 35 via device path match (strip /dev/), got %d", result[0].Temperature)
|
|
}
|
|
}
|
|
|
|
func TestMergeHostAgentSMARTIntoDisks_SkipsStandby(t *testing.T) {
|
|
disks := []models.PhysicalDisk{
|
|
{ID: "d1", Node: "pve1", Serial: "SER1", Temperature: 0},
|
|
}
|
|
nodes := []models.Node{
|
|
{Name: "pve1", LinkedAgentID: "host-1"},
|
|
}
|
|
hosts := []models.Host{
|
|
{
|
|
ID: "host-1",
|
|
Sensors: models.HostSensorSummary{
|
|
SMART: []models.HostDiskSMART{
|
|
{Device: "/dev/sda", Serial: "SER1", Temperature: 40, Standby: true},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
result := mergeHostAgentSMARTIntoDisks(disks, nodes, hosts, nil)
|
|
if result[0].Temperature != 0 {
|
|
t.Error("standby disk should not have its temperature merged")
|
|
}
|
|
}
|
|
|
|
func TestMergeHostAgentSMARTIntoDisks_MergesSMARTAttributes(t *testing.T) {
|
|
disks := []models.PhysicalDisk{
|
|
{ID: "d1", Node: "pve1", Serial: "SER1", Temperature: 0},
|
|
}
|
|
nodes := []models.Node{
|
|
{Name: "pve1", LinkedAgentID: "host-1"},
|
|
}
|
|
attrs := &models.SMARTAttributes{}
|
|
poh := int64(5000)
|
|
attrs.PowerOnHours = &poh
|
|
hosts := []models.Host{
|
|
{
|
|
ID: "host-1",
|
|
Sensors: models.HostSensorSummary{
|
|
SMART: []models.HostDiskSMART{
|
|
{Device: "/dev/sda", Serial: "SER1", Temperature: 41, Attributes: attrs},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
result := mergeHostAgentSMARTIntoDisks(disks, nodes, hosts, nil)
|
|
if result[0].SmartAttributes == nil {
|
|
t.Fatal("SMART attributes should be merged")
|
|
}
|
|
if *result[0].SmartAttributes.PowerOnHours != 5000 {
|
|
t.Error("PowerOnHours should be 5000")
|
|
}
|
|
}
|
|
|
|
func TestMergeHostAgentSMARTIntoDisks_DerivesWearoutFromSMARTAttributes(t *testing.T) {
|
|
disks := []models.PhysicalDisk{
|
|
{ID: "d1", Node: "pve1", Serial: "SER1", Wearout: -1},
|
|
}
|
|
nodes := []models.Node{
|
|
{Name: "pve1", LinkedAgentID: "host-1"},
|
|
}
|
|
used := 6
|
|
hosts := []models.Host{
|
|
{
|
|
ID: "host-1",
|
|
Sensors: models.HostSensorSummary{
|
|
SMART: []models.HostDiskSMART{
|
|
{
|
|
Device: "/dev/sda [megaraid,7]",
|
|
Serial: "SER1",
|
|
Health: "PASSED",
|
|
Attributes: &models.SMARTAttributes{
|
|
PercentageUsed: &used,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result := mergeHostAgentSMARTIntoDisks(disks, nodes, hosts, nil)
|
|
if result[0].Wearout != 94 {
|
|
t.Fatalf("expected wearout derived from PercentageUsed, got %d", result[0].Wearout)
|
|
}
|
|
if result[0].SmartAttributes == nil || result[0].SmartAttributes.PercentageUsed == nil || *result[0].SmartAttributes.PercentageUsed != 6 {
|
|
t.Fatalf("expected merged PercentageUsed, got %+v", result[0].SmartAttributes)
|
|
}
|
|
}
|
|
|
|
func TestMergeHostAgentSMARTIntoDisks_SMARTFailureAndWearoutOverrideCoarsePVEValues(t *testing.T) {
|
|
disks := []models.PhysicalDisk{{
|
|
ID: "d1",
|
|
Node: "pve1",
|
|
Serial: "SER1",
|
|
Type: "ssd",
|
|
Health: "PASSED",
|
|
Wearout: 100,
|
|
}}
|
|
nodes := []models.Node{{Name: "pve1", LinkedAgentID: "host-1"}}
|
|
used := 95
|
|
hosts := []models.Host{{
|
|
ID: "host-1",
|
|
Sensors: models.HostSensorSummary{
|
|
SMART: []models.HostDiskSMART{{
|
|
Device: "/dev/sda",
|
|
Serial: "SER1",
|
|
Health: "FAILED",
|
|
Attributes: &models.SMARTAttributes{
|
|
PercentageUsed: &used,
|
|
},
|
|
}},
|
|
},
|
|
}}
|
|
|
|
result := mergeHostAgentSMARTIntoDisks(disks, nodes, hosts, nil)
|
|
if result[0].Health != "FAILED" {
|
|
t.Fatalf("SMART failure was suppressed by PVE health: %+v", result[0])
|
|
}
|
|
if result[0].Wearout != 5 {
|
|
t.Fatalf("SMART wearout did not override contradictory PVE value: %+v", result[0])
|
|
}
|
|
}
|
|
|
|
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"}}
|
|
hosts := []models.Host{{
|
|
ID: "host-1",
|
|
Sensors: models.HostSensorSummary{
|
|
SMART: []models.HostDiskSMART{{Serial: "SER1", Health: "PASSED"}},
|
|
},
|
|
}}
|
|
|
|
if got := mergeHostAgentSMARTIntoDisks(disks, nodes, hosts, nil)[0].Health; got != "FAILED" {
|
|
t.Fatalf("PASSED replaced an existing failure: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestMergeHostAgentSMARTIntoDisks_DuplicateIdentityRequiresUniqueTopology(t *testing.T) {
|
|
disks := []models.PhysicalDisk{{
|
|
ID: "d1",
|
|
Node: "pve1",
|
|
DevPath: "/dev/sg2",
|
|
Serial: "DUPLICATE-SERIAL",
|
|
Controller: "sssraid0",
|
|
Target: "sssraid,0,2",
|
|
Health: "UNKNOWN",
|
|
}}
|
|
nodes := []models.Node{{Name: "pve1", LinkedAgentID: "host-1"}}
|
|
hosts := []models.Host{{
|
|
ID: "host-1",
|
|
Sensors: models.HostSensorSummary{
|
|
SMART: []models.HostDiskSMART{
|
|
{
|
|
Device: "/dev/sg2",
|
|
Serial: "DUPLICATE-SERIAL",
|
|
Controller: "sssraid0",
|
|
Target: "sssraid,0,1",
|
|
Temperature: 31,
|
|
},
|
|
{
|
|
Device: "/dev/sg2",
|
|
Serial: "DUPLICATE-SERIAL",
|
|
Controller: "sssraid0",
|
|
Target: "sssraid,0,2",
|
|
Temperature: 47,
|
|
Health: "PASSED",
|
|
},
|
|
},
|
|
},
|
|
}}
|
|
|
|
result := mergeHostAgentSMARTIntoDisks(disks, nodes, hosts, nil)
|
|
if result[0].Temperature != 47 || result[0].Health != "PASSED" {
|
|
t.Fatalf("topology-specific SMART evidence was not selected: %+v", result[0])
|
|
}
|
|
|
|
disks[0].Controller = ""
|
|
disks[0].Target = ""
|
|
disks[0].DevPath = "/dev/unknown"
|
|
result = mergeHostAgentSMARTIntoDisks(disks, nodes, hosts, nil)
|
|
if result[0].Temperature != 0 || result[0].Health != "UNKNOWN" {
|
|
t.Fatalf("ambiguous SMART identity was guessed: %+v", result[0])
|
|
}
|
|
}
|
|
|
|
func TestMergeHostAgentSMARTIntoDisks_FillsMissingHealthFromHostSMART(t *testing.T) {
|
|
disks := []models.PhysicalDisk{
|
|
{ID: "d1", Node: "pve1", Serial: "SER1", Health: "UNKNOWN"},
|
|
}
|
|
nodes := []models.Node{
|
|
{Name: "pve1", LinkedAgentID: "host-1"},
|
|
}
|
|
hosts := []models.Host{
|
|
{
|
|
ID: "host-1",
|
|
Sensors: models.HostSensorSummary{
|
|
SMART: []models.HostDiskSMART{
|
|
{Device: "/dev/sda", Serial: "SER1", Health: "FAILED"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result := mergeHostAgentSMARTIntoDisks(disks, nodes, hosts, nil)
|
|
if result[0].Health != "FAILED" {
|
|
t.Fatalf("expected host SMART health to fill unknown disk health, got %q", result[0].Health)
|
|
}
|
|
}
|
|
|
|
func TestMergeHostAgentSMARTIntoDisks_NoAgentLink(t *testing.T) {
|
|
disks := []models.PhysicalDisk{
|
|
{ID: "d1", Node: "pve1", Serial: "SER1", Temperature: 0},
|
|
}
|
|
nodes := []models.Node{
|
|
{Name: "pve1"}, // No LinkedAgentID
|
|
}
|
|
hosts := []models.Host{
|
|
{
|
|
ID: "host-1",
|
|
Sensors: models.HostSensorSummary{
|
|
SMART: []models.HostDiskSMART{
|
|
{Serial: "SER1", Temperature: 40},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
result := mergeHostAgentSMARTIntoDisks(disks, nodes, hosts, nil)
|
|
if result[0].Temperature != 0 {
|
|
t.Error("unlinked nodes should not get host agent temperatures")
|
|
}
|
|
}
|
|
|
|
func TestMergeHostAgentSMARTIntoDisks_PreservesExistingTemp(t *testing.T) {
|
|
disks := []models.PhysicalDisk{
|
|
{ID: "d1", Node: "pve1", Serial: "SER1", Temperature: 50}, // Already has temp
|
|
}
|
|
nodes := []models.Node{
|
|
{Name: "pve1", LinkedAgentID: "host-1"},
|
|
}
|
|
hosts := []models.Host{
|
|
{
|
|
ID: "host-1",
|
|
Sensors: models.HostSensorSummary{
|
|
SMART: []models.HostDiskSMART{
|
|
{Serial: "SER1", Temperature: 40},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
result := mergeHostAgentSMARTIntoDisks(disks, nodes, hosts, nil)
|
|
if result[0].Temperature != 50 {
|
|
t.Errorf("existing temperature should be preserved, got %d", result[0].Temperature)
|
|
}
|
|
}
|
|
|
|
func TestMergeHostAgentSMARTIntoDisks_OriginalUnchanged(t *testing.T) {
|
|
disks := []models.PhysicalDisk{
|
|
{ID: "d1", Node: "pve1", Serial: "SER1", Temperature: 0},
|
|
}
|
|
nodes := []models.Node{{Name: "pve1", LinkedAgentID: "host-1"}}
|
|
hosts := []models.Host{
|
|
{ID: "host-1", Sensors: models.HostSensorSummary{
|
|
SMART: []models.HostDiskSMART{{Serial: "SER1", Temperature: 42}},
|
|
}},
|
|
}
|
|
result := mergeHostAgentSMARTIntoDisks(disks, nodes, hosts, nil)
|
|
if disks[0].Temperature != 0 {
|
|
t.Error("original disk slice should not be mutated")
|
|
}
|
|
if result[0].Temperature != 42 {
|
|
t.Error("returned slice should have the merged temp")
|
|
}
|
|
}
|
|
|
|
// --- smartAttributesFromUnifiedMeta ---
|
|
|
|
func TestSmartAttributesFromUnifiedMeta_Nil(t *testing.T) {
|
|
if smartAttributesFromUnifiedMeta(nil) != nil {
|
|
t.Error("nil input should return nil")
|
|
}
|
|
}
|
|
|
|
func TestSmartAttributesFromUnifiedMeta_AllZero(t *testing.T) {
|
|
// No reported fields should return nil.
|
|
if smartAttributesFromUnifiedMeta(&unifiedresources.SMARTMeta{}) != nil {
|
|
t.Error("SMARTMeta with no reported fields should return nil")
|
|
}
|
|
}
|
|
|
|
func TestSmartAttributesFromUnifiedMeta_PopulatesFields(t *testing.T) {
|
|
meta := &unifiedresources.SMARTMeta{
|
|
PowerOnHours: smartInt64Pointer(12345),
|
|
PowerCycles: smartInt64Pointer(500),
|
|
ReallocatedSectors: smartInt64Pointer(0),
|
|
PendingSectors: smartInt64Pointer(2),
|
|
OfflineUncorrectable: smartInt64Pointer(1),
|
|
UDMACRCErrors: smartInt64Pointer(3),
|
|
PercentageUsed: smartIntPointer(15),
|
|
AvailableSpare: smartIntPointer(85),
|
|
MediaErrors: smartInt64Pointer(0),
|
|
UnsafeShutdowns: smartInt64Pointer(10),
|
|
}
|
|
result := smartAttributesFromUnifiedMeta(meta)
|
|
if result == nil {
|
|
t.Fatal("non-zero meta should produce non-nil result")
|
|
}
|
|
if result.PowerOnHours == nil || *result.PowerOnHours != 12345 {
|
|
t.Error("PowerOnHours should be 12345")
|
|
}
|
|
if result.PowerCycles == nil || *result.PowerCycles != 500 {
|
|
t.Error("PowerCycles should be 500")
|
|
}
|
|
if result.ReallocatedSectors == nil || *result.ReallocatedSectors != 0 {
|
|
t.Error("reported zero ReallocatedSectors should survive projection")
|
|
}
|
|
if result.PendingSectors == nil || *result.PendingSectors != 2 {
|
|
t.Error("PendingSectors should be 2")
|
|
}
|
|
if result.PercentageUsed == nil || *result.PercentageUsed != 15 {
|
|
t.Error("PercentageUsed should be 15")
|
|
}
|
|
if result.MediaErrors == nil || *result.MediaErrors != 0 {
|
|
t.Error("reported zero MediaErrors should survive projection")
|
|
}
|
|
if result.UnsafeShutdowns == nil || *result.UnsafeShutdowns != 10 {
|
|
t.Error("UnsafeShutdowns should be 10")
|
|
}
|
|
}
|
|
|
|
func smartIntPointer(value int) *int { return &value }
|
|
func smartInt64Pointer(value int64) *int64 { return &value }
|
|
|
|
// --- getNodeDisplayName ---
|
|
|
|
func TestGetNodeDisplayName_NilInstance(t *testing.T) {
|
|
got := getNodeDisplayName(nil, "pve1")
|
|
if got != "pve1" {
|
|
t.Errorf("expected 'pve1', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestGetNodeDisplayName_EmptyNodeName(t *testing.T) {
|
|
got := getNodeDisplayName(nil, "")
|
|
if got != "unknown-node" {
|
|
t.Errorf("expected 'unknown-node', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestGetNodeDisplayName_FriendlyName(t *testing.T) {
|
|
inst := &config.PVEInstance{Name: "My Server"}
|
|
got := getNodeDisplayName(inst, "pve1")
|
|
if got != "My Server" {
|
|
t.Errorf("expected friendly name 'My Server', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestGetNodeDisplayName_ClusterUsesNodeName(t *testing.T) {
|
|
inst := &config.PVEInstance{Name: "cluster1", IsCluster: true}
|
|
got := getNodeDisplayName(inst, "node1")
|
|
if got != "node1" {
|
|
t.Errorf("cluster mode should fall back to the native node name, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestGetNodeDisplayName_ClusterWithEndpointLabel(t *testing.T) {
|
|
inst := &config.PVEInstance{
|
|
Name: "cluster1",
|
|
IsCluster: true,
|
|
ClusterEndpoints: []config.ClusterEndpoint{
|
|
{NodeName: "node1", NodeID: "node/node1", NodeIdentity: "cluster1-node1"},
|
|
},
|
|
ClusterNodeIdentities: []config.PVEClusterNodeIdentity{{
|
|
ID: "cluster1-node1", NativeName: "node1", DisplayName: "Compute one",
|
|
}},
|
|
}
|
|
got := getNodeDisplayName(inst, "node1")
|
|
if got != "Compute one" {
|
|
t.Errorf("expected persisted cluster-node display name, got %q", got)
|
|
}
|
|
}
|