mirror of
https://github.com/henrygd/beszel.git
synced 2026-10-22 06:12:33 +00:00
unsafe_shutdowns was uint16, so drives reporting more than 65535 unsafe shutdowns made json.Unmarshal fail and the entire NVMe SMART record was discarded with only "no valid SMART data found" in the logs. Widen it to uint, matching power_cycles (#2484), and log the unmarshal error at debug level so similar failures are diagnosable. Fixes #2582
1552 lines
45 KiB
Go
1552 lines
45 KiB
Go
//go:build testing
|
|
|
|
package agent
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/henrygd/beszel/internal/entities/smart"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestParseSmartForScsi(t *testing.T) {
|
|
fixturePath := filepath.Join("test-data", "smart", "scsi.json")
|
|
data, err := os.ReadFile(fixturePath)
|
|
if err != nil {
|
|
t.Fatalf("failed reading fixture: %v", err)
|
|
}
|
|
|
|
sm := &SmartManager{
|
|
SmartDataMap: make(map[string]*smart.SmartData),
|
|
}
|
|
|
|
hasData, exitStatus := sm.parseSmartForScsi(data, "")
|
|
if !hasData {
|
|
t.Fatalf("expected SCSI data to parse successfully")
|
|
}
|
|
if exitStatus != 0 {
|
|
t.Fatalf("expected exit status 0, got %d", exitStatus)
|
|
}
|
|
|
|
deviceData, ok := sm.SmartDataMap["9YHSDH9B"]
|
|
if !ok {
|
|
t.Fatalf("expected smart data entry for serial 9YHSDH9B")
|
|
}
|
|
|
|
assert.Equal(t, deviceData.ModelName, "YADRO WUH721414AL4204")
|
|
assert.Equal(t, deviceData.SerialNumber, "9YHSDH9B")
|
|
assert.Equal(t, deviceData.FirmwareVersion, "C240")
|
|
assert.Equal(t, deviceData.DiskName, "/dev/sde")
|
|
assert.Equal(t, deviceData.DiskType, "scsi")
|
|
assert.EqualValues(t, deviceData.Temperature, 34)
|
|
assert.Equal(t, deviceData.SmartStatus, "PASSED")
|
|
assert.EqualValues(t, deviceData.Capacity, 14000519643136)
|
|
|
|
if len(deviceData.Attributes) == 0 {
|
|
t.Fatalf("expected attributes to be populated")
|
|
}
|
|
|
|
assertAttrValue(t, deviceData.Attributes, "PowerOnHours", 458)
|
|
assertAttrValue(t, deviceData.Attributes, "PowerOnMinutes", 25)
|
|
assertAttrValue(t, deviceData.Attributes, "GrownDefectList", 0)
|
|
assertAttrValue(t, deviceData.Attributes, "StartStopCycles", 2)
|
|
assertAttrValue(t, deviceData.Attributes, "LoadUnloadCycles", 418)
|
|
assertAttrValue(t, deviceData.Attributes, "ReadGigabytesProcessed", 3641)
|
|
assertAttrValue(t, deviceData.Attributes, "WriteGigabytesProcessed", 2124590)
|
|
assertAttrValue(t, deviceData.Attributes, "VerifyGigabytesProcessed", 0)
|
|
}
|
|
|
|
func TestParseSmartForSata(t *testing.T) {
|
|
fixturePath := filepath.Join("test-data", "smart", "sda.json")
|
|
data, err := os.ReadFile(fixturePath)
|
|
require.NoError(t, err)
|
|
|
|
sm := &SmartManager{
|
|
SmartDataMap: make(map[string]*smart.SmartData),
|
|
}
|
|
|
|
hasData, exitStatus := sm.parseSmartForSata(data, "")
|
|
require.True(t, hasData)
|
|
assert.Equal(t, 64, exitStatus)
|
|
|
|
deviceData, ok := sm.SmartDataMap["9C40918040082"]
|
|
require.True(t, ok, "expected smart data entry for serial 9C40918040082")
|
|
|
|
assert.Equal(t, "P3-2TB", deviceData.ModelName)
|
|
assert.Equal(t, "X0104A0", deviceData.FirmwareVersion)
|
|
assert.Equal(t, "/dev/sda", deviceData.DiskName)
|
|
assert.Equal(t, "sat", deviceData.DiskType)
|
|
assert.Equal(t, uint8(31), deviceData.Temperature)
|
|
assert.Equal(t, "PASSED", deviceData.SmartStatus)
|
|
assert.Equal(t, uint64(2048408248320), deviceData.Capacity)
|
|
if assert.NotEmpty(t, deviceData.Attributes) {
|
|
assertAttrValue(t, deviceData.Attributes, "Temperature_Celsius", 31)
|
|
}
|
|
}
|
|
|
|
func TestParseSmartForSataPreservesFailedAndUnknownStatus(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
temperature int
|
|
want string
|
|
}{
|
|
{name: "failed", temperature: 30, want: "FAILED"},
|
|
{name: "unknown", want: "UNKNOWN"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
jsonPayload := []byte(fmt.Sprintf(`{
|
|
"device": {"name": "/dev/sda", "type": "sat"},
|
|
"serial_number": "PRESERVE%s",
|
|
"temperature": {"current": %d},
|
|
"ata_smart_attributes": {"table": [{"id": 197, "raw": {"value": 1, "string": "1"}}]}
|
|
}`, test.name, test.temperature))
|
|
|
|
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
hasData, _ := sm.parseSmartForSata(jsonPayload, "")
|
|
require.True(t, hasData)
|
|
assert.Equal(t, test.want, sm.SmartDataMap["PRESERVE"+test.name].SmartStatus)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseSmartForSataDeviceStatisticsTemperature(t *testing.T) {
|
|
jsonPayload := []byte(`{
|
|
"smartctl": {"exit_status": 0},
|
|
"device": {"name": "/dev/sdb", "type": "sat"},
|
|
"model_name": "SanDisk SSD U110 16GB",
|
|
"serial_number": "DEVSTAT123",
|
|
"firmware_version": "U21B001",
|
|
"user_capacity": {"bytes": 16013942784},
|
|
"smart_status": {"passed": true},
|
|
"ata_smart_attributes": {"table": []},
|
|
"ata_device_statistics": {
|
|
"pages": [
|
|
{
|
|
"number": 5,
|
|
"name": "Temperature Statistics",
|
|
"table": [
|
|
{"name": "Current Temperature", "value": 22, "flags": {"valid": true}}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}`)
|
|
|
|
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
|
require.True(t, hasData)
|
|
assert.Equal(t, 0, exitStatus)
|
|
|
|
deviceData, ok := sm.SmartDataMap["DEVSTAT123"]
|
|
require.True(t, ok, "expected smart data entry for serial DEVSTAT123")
|
|
assert.Equal(t, uint8(22), deviceData.Temperature)
|
|
}
|
|
|
|
func TestParseSmartForSataAtaDeviceStatistics(t *testing.T) {
|
|
// tests that ata_device_statistics values are parsed correctly
|
|
jsonPayload := []byte(`{
|
|
"smartctl": {"exit_status": 0},
|
|
"device": {"name": "/dev/sdb", "type": "sat"},
|
|
"model_name": "SanDisk SSD U110 16GB",
|
|
"serial_number": "lksjfh23lhj",
|
|
"firmware_version": "U21B001",
|
|
"user_capacity": {"bytes": 16013942784},
|
|
"smart_status": {"passed": true},
|
|
"ata_smart_attributes": {"table": []},
|
|
"ata_device_statistics": {
|
|
"pages": [
|
|
{
|
|
"number": 5,
|
|
"name": "Temperature Statistics",
|
|
"table": [
|
|
{"name": "Current Temperature", "value": 43, "flags": {"valid": true}},
|
|
{"name": "Specified Minimum Operating Temperature", "value": -20, "flags": {"valid": true}}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}`)
|
|
|
|
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
|
require.True(t, hasData)
|
|
assert.Equal(t, 0, exitStatus)
|
|
|
|
deviceData, ok := sm.SmartDataMap["lksjfh23lhj"]
|
|
require.True(t, ok, "expected smart data entry for serial lksjfh23lhj")
|
|
assert.Equal(t, uint8(43), deviceData.Temperature)
|
|
}
|
|
|
|
func TestParseSmartForSataNegativeDeviceStatistics(t *testing.T) {
|
|
// Tests that negative values in ata_device_statistics (e.g. min operating temp)
|
|
// do not cause the entire SAT parser to fail.
|
|
jsonPayload := []byte(`{
|
|
"smartctl": {"exit_status": 0},
|
|
"device": {"name": "/dev/sdb", "type": "sat"},
|
|
"model_name": "SanDisk SSD U110 16GB",
|
|
"serial_number": "NEGATIVE123",
|
|
"firmware_version": "U21B001",
|
|
"user_capacity": {"bytes": 16013942784},
|
|
"smart_status": {"passed": true},
|
|
"temperature": {"current": 38},
|
|
"ata_smart_attributes": {"table": []},
|
|
"ata_device_statistics": {
|
|
"pages": [
|
|
{
|
|
"number": 5,
|
|
"name": "Temperature Statistics",
|
|
"table": [
|
|
{"name": "Current Temperature", "value": 38, "flags": {"valid": true}},
|
|
{"name": "Specified Minimum Operating Temperature", "value": -20, "flags": {"valid": true}}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}`)
|
|
|
|
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
|
require.True(t, hasData)
|
|
assert.Equal(t, 0, exitStatus)
|
|
|
|
deviceData, ok := sm.SmartDataMap["NEGATIVE123"]
|
|
require.True(t, ok, "expected smart data entry for serial NEGATIVE123")
|
|
assert.Equal(t, uint8(38), deviceData.Temperature)
|
|
}
|
|
|
|
func TestParseSmartForSataParentheticalRawValue(t *testing.T) {
|
|
jsonPayload := []byte(`{
|
|
"smartctl": {"exit_status": 0},
|
|
"device": {"name": "/dev/sdz", "type": "sat"},
|
|
"model_name": "Example",
|
|
"serial_number": "PARENTHESES123",
|
|
"firmware_version": "1.0",
|
|
"user_capacity": {"bytes": 1024},
|
|
"smart_status": {"passed": true},
|
|
"temperature": {"current": 25},
|
|
"ata_smart_attributes": {
|
|
"table": [
|
|
{
|
|
"id": 9,
|
|
"name": "Power_On_Hours",
|
|
"value": 93,
|
|
"worst": 55,
|
|
"thresh": 0,
|
|
"when_failed": "",
|
|
"raw": {
|
|
"value": 57891864217128,
|
|
"string": "39925 (212 206 0)"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}`)
|
|
|
|
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
|
|
hasData, exitStatus := sm.parseSmartForSata(jsonPayload, "")
|
|
require.True(t, hasData)
|
|
assert.Equal(t, 0, exitStatus)
|
|
|
|
data, ok := sm.SmartDataMap["PARENTHESES123"]
|
|
require.True(t, ok)
|
|
require.Len(t, data.Attributes, 1)
|
|
|
|
attr := data.Attributes[0]
|
|
assert.Equal(t, uint64(39925), attr.RawValue)
|
|
assert.Equal(t, "39925 (212 206 0)", attr.RawString)
|
|
}
|
|
|
|
func TestParseSmartForNvme(t *testing.T) {
|
|
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
|
|
data, err := os.ReadFile(fixturePath)
|
|
require.NoError(t, err)
|
|
|
|
sm := &SmartManager{
|
|
SmartDataMap: make(map[string]*smart.SmartData),
|
|
}
|
|
|
|
hasData, exitStatus := sm.parseSmartForNvme(data, "")
|
|
require.True(t, hasData)
|
|
assert.Equal(t, 0, exitStatus)
|
|
|
|
deviceData, ok := sm.SmartDataMap["2024031600129"]
|
|
require.True(t, ok, "expected smart data entry for serial 2024031600129")
|
|
|
|
assert.Equal(t, "PELADN 512GB", deviceData.ModelName)
|
|
assert.Equal(t, "VC2S038E", deviceData.FirmwareVersion)
|
|
assert.Equal(t, "/dev/nvme0", deviceData.DiskName)
|
|
assert.Equal(t, "nvme", deviceData.DiskType)
|
|
assert.Equal(t, uint8(61), deviceData.Temperature)
|
|
assert.Equal(t, "PASSED", deviceData.SmartStatus)
|
|
assert.Equal(t, uint64(512110190592), deviceData.Capacity)
|
|
if assert.NotEmpty(t, deviceData.Attributes) {
|
|
assertAttrValue(t, deviceData.Attributes, "PercentageUsed", 0)
|
|
assertAttrValue(t, deviceData.Attributes, "DataUnitsWritten", 16040567)
|
|
}
|
|
}
|
|
|
|
func TestParseSmartForNvmeLargeUnsafeShutdowns(t *testing.T) {
|
|
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
|
|
data, err := os.ReadFile(fixturePath)
|
|
require.NoError(t, err)
|
|
|
|
// Some drives report counts that overflow uint16, which previously made the
|
|
// whole record fail to unmarshal and get discarded.
|
|
re := regexp.MustCompile(`"unsafe_shutdowns":\s*\d+`)
|
|
require.True(t, re.Match(data), "fixture should contain unsafe_shutdowns")
|
|
data = re.ReplaceAll(data, []byte(`"unsafe_shutdowns": 159754`))
|
|
|
|
sm := &SmartManager{
|
|
SmartDataMap: make(map[string]*smart.SmartData),
|
|
}
|
|
|
|
hasData, _ := sm.parseSmartForNvme(data, "")
|
|
require.True(t, hasData)
|
|
|
|
deviceData, ok := sm.SmartDataMap["2024031600129"]
|
|
require.True(t, ok, "expected smart data entry for serial 2024031600129")
|
|
assertAttrValue(t, deviceData.Attributes, "UnsafeShutdowns", 159754)
|
|
}
|
|
|
|
func TestHasDataForDevice(t *testing.T) {
|
|
sm := &SmartManager{
|
|
SmartDataMap: map[string]*smart.SmartData{
|
|
"serial-1": {DiskName: "/dev/sda", DiskType: "jms56x,0"},
|
|
"serial-2": nil,
|
|
},
|
|
}
|
|
|
|
assert.True(t, sm.hasDataForDevice(&DeviceInfo{Name: "/dev/sda", Type: "jms56x,0"}))
|
|
assert.False(t, sm.hasDataForDevice(&DeviceInfo{Name: "/dev/sda", Type: "jms56x,1"}))
|
|
assert.False(t, sm.hasDataForDevice(&DeviceInfo{Name: "/dev/sdb", Type: "jms56x,0"}))
|
|
assert.False(t, sm.hasDataForDevice(nil))
|
|
}
|
|
|
|
func TestDevicesSnapshotReturnsCopy(t *testing.T) {
|
|
originalDevice := &DeviceInfo{Name: "/dev/sda"}
|
|
sm := &SmartManager{
|
|
SmartDevices: []*DeviceInfo{
|
|
originalDevice,
|
|
{Name: "/dev/sdb"},
|
|
},
|
|
}
|
|
|
|
snapshot := sm.devicesSnapshot()
|
|
require.Len(t, snapshot, 2)
|
|
|
|
sm.SmartDevices[0] = &DeviceInfo{Name: "/dev/sdz"}
|
|
assert.Equal(t, "/dev/sda", snapshot[0].Name)
|
|
|
|
snapshot[1] = &DeviceInfo{Name: "/dev/nvme0"}
|
|
assert.Equal(t, "/dev/sdb", sm.SmartDevices[1].Name)
|
|
|
|
sm.SmartDevices = append(sm.SmartDevices, &DeviceInfo{Name: "/dev/nvme1"})
|
|
assert.Len(t, snapshot, 2)
|
|
}
|
|
|
|
func TestScanDevicesWithEnvOverrideAndSeparator(t *testing.T) {
|
|
t.Setenv("SMART_DEVICES_SEPARATOR", "|")
|
|
t.Setenv("SMART_DEVICES", "/dev/sda:jmb39x-q,0|/dev/nvme0:nvme")
|
|
|
|
sm := &SmartManager{
|
|
SmartDataMap: make(map[string]*smart.SmartData),
|
|
}
|
|
|
|
err := sm.ScanDevices(true)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, sm.SmartDevices, 2)
|
|
assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name)
|
|
assert.Equal(t, "jmb39x-q,0", sm.SmartDevices[0].Type)
|
|
assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name)
|
|
assert.Equal(t, "nvme", sm.SmartDevices[1].Type)
|
|
}
|
|
|
|
func TestScanDevicesWithEnvOverride(t *testing.T) {
|
|
t.Setenv("SMART_DEVICES", "/dev/sda:sat, /dev/nvme0:nvme")
|
|
|
|
sm := &SmartManager{
|
|
SmartDataMap: make(map[string]*smart.SmartData),
|
|
}
|
|
|
|
err := sm.ScanDevices(true)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, sm.SmartDevices, 2)
|
|
assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name)
|
|
assert.Equal(t, "sat", sm.SmartDevices[0].Type)
|
|
assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name)
|
|
assert.Equal(t, "nvme", sm.SmartDevices[1].Type)
|
|
}
|
|
|
|
func TestScanDevicesWithEnvOverrideInvalid(t *testing.T) {
|
|
t.Setenv("SMART_DEVICES", ":sat")
|
|
|
|
sm := &SmartManager{
|
|
SmartDataMap: make(map[string]*smart.SmartData),
|
|
}
|
|
|
|
err := sm.ScanDevices(true)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestScanDevicesWithEnvOverrideEmpty(t *testing.T) {
|
|
t.Setenv("SMART_DEVICES", " ")
|
|
|
|
sm := &SmartManager{
|
|
SmartDataMap: make(map[string]*smart.SmartData),
|
|
}
|
|
|
|
err := sm.ScanDevices(true)
|
|
assert.ErrorIs(t, err, errNoValidSmartData)
|
|
assert.Empty(t, sm.SmartDevices)
|
|
}
|
|
|
|
func TestSmartctlArgsWithoutType(t *testing.T) {
|
|
device := &DeviceInfo{Name: "/dev/sda"}
|
|
|
|
sm := &SmartManager{}
|
|
|
|
args := sm.smartctlArgs(device, true)
|
|
assert.Equal(t, []string{"-a", "--json=c", "-n", "standby", "/dev/sda"}, args)
|
|
}
|
|
|
|
func TestSmartctlArgs(t *testing.T) {
|
|
sm := &SmartManager{}
|
|
|
|
sataDevice := &DeviceInfo{Name: "/dev/sda", Type: "sat"}
|
|
assert.Equal(t,
|
|
[]string{"-d", "sat", "-a", "--json=c", "-l", "devstat", "-n", "standby", "/dev/sda"},
|
|
sm.smartctlArgs(sataDevice, true),
|
|
)
|
|
|
|
assert.Equal(t,
|
|
[]string{"-d", "sat", "-a", "--json=c", "-l", "devstat", "/dev/sda"},
|
|
sm.smartctlArgs(sataDevice, false),
|
|
)
|
|
|
|
nvmeDevice := &DeviceInfo{Name: "/dev/nvme0", Type: "nvme"}
|
|
assert.Equal(t,
|
|
[]string{"-d", "nvme", "-a", "--json=c", "-n", "standby", "/dev/nvme0"},
|
|
sm.smartctlArgs(nvmeDevice, true),
|
|
)
|
|
|
|
assert.Equal(t,
|
|
[]string{"-a", "--json=c", "-n", "standby"},
|
|
sm.smartctlArgs(nil, true),
|
|
)
|
|
}
|
|
|
|
// TestSmartctlArgsExplicitType verifies that an explicit SMART_DEVICES type hint
|
|
// is always passed to smartctl via -d, while a scan-detected scsi/ata type is
|
|
// still left off so smartctl can auto-detect it (see issue #1345).
|
|
func TestSmartctlArgsExplicitType(t *testing.T) {
|
|
sm := &SmartManager{}
|
|
|
|
// Scan-detected scsi: -d is intentionally omitted.
|
|
scanScsi := &DeviceInfo{Name: "/dev/sda", Type: "scsi"}
|
|
assert.Equal(t,
|
|
[]string{"-a", "--json=c", "/dev/sda"},
|
|
sm.smartctlArgs(scanScsi, false),
|
|
)
|
|
|
|
// Explicit scsi from SMART_DEVICES: -d scsi must be passed.
|
|
explicitScsi := &DeviceInfo{Name: "/dev/sda", Type: "scsi", explicitType: true}
|
|
assert.Equal(t,
|
|
[]string{"-d", "scsi", "-a", "--json=c", "/dev/sda"},
|
|
sm.smartctlArgs(explicitScsi, false),
|
|
)
|
|
|
|
// Explicit ata from SMART_DEVICES: -d ata must be passed (devstat still added).
|
|
explicitAta := &DeviceInfo{Name: "/dev/sdb", Type: "ata", explicitType: true}
|
|
assert.Equal(t,
|
|
[]string{"-d", "ata", "-a", "--json=c", "-l", "devstat", "/dev/sdb"},
|
|
sm.smartctlArgs(explicitAta, false),
|
|
)
|
|
}
|
|
|
|
// TestSmartDevicesExplicitTypeFlowsToSmartctlArgs is a regression test for
|
|
// issue #2072: an explicit SMART_DEVICES type (e.g. /dev/sda:scsi) must win over
|
|
// a wrong scan-detected type (sat) and be handed to smartctl as -d scsi.
|
|
func TestSmartDevicesExplicitTypeFlowsToSmartctlArgs(t *testing.T) {
|
|
sm := &SmartManager{}
|
|
|
|
configured, err := sm.parseConfiguredDevices("/dev/sda:scsi")
|
|
require.NoError(t, err)
|
|
require.Len(t, configured, 1)
|
|
assert.True(t, configured[0].explicitType)
|
|
|
|
// smartctl --scan misreports this USB drive as sat, which fails on it.
|
|
scanned := []*DeviceInfo{
|
|
{Name: "/dev/sda", Type: "sat", Protocol: "ATA"},
|
|
}
|
|
|
|
merged := mergeDeviceLists(nil, scanned, configured)
|
|
require.Len(t, merged, 1)
|
|
|
|
device := merged[0]
|
|
assert.Equal(t, "scsi", device.Type, "configured type should win over scan-detected sat")
|
|
assert.True(t, device.explicitType, "explicit hint must survive the merge")
|
|
|
|
assert.Equal(t,
|
|
[]string{"-d", "scsi", "-a", "--json=c", "/dev/sda"},
|
|
sm.smartctlArgs(device, false),
|
|
"explicit scsi type must be passed to smartctl, not dropped",
|
|
)
|
|
}
|
|
|
|
// TestMergeDeviceListsPreservesExplicitTypeAcrossRescan ensures a verified,
|
|
// explicitly-typed device keeps its explicit flag when a later scan re-reports
|
|
// it with a different auto-detected type.
|
|
func TestMergeDeviceListsPreservesExplicitTypeAcrossRescan(t *testing.T) {
|
|
existing := []*DeviceInfo{
|
|
{Name: "/dev/sda", Type: "scsi", parserType: "scsi", typeVerified: true, explicitType: true},
|
|
}
|
|
scanned := []*DeviceInfo{
|
|
{Name: "/dev/sda", Type: "sat"},
|
|
}
|
|
|
|
merged := mergeDeviceLists(existing, scanned, nil)
|
|
require.Len(t, merged, 1)
|
|
assert.Equal(t, "scsi", merged[0].Type)
|
|
assert.True(t, merged[0].explicitType, "explicit type flag should survive a rescan")
|
|
}
|
|
|
|
func TestResolveRefreshError(t *testing.T) {
|
|
scanErr := errors.New("scan failed")
|
|
collectErr := errors.New("collect failed")
|
|
|
|
tests := []struct {
|
|
name string
|
|
devices []*DeviceInfo
|
|
data map[string]*smart.SmartData
|
|
scanErr error
|
|
collectErr error
|
|
expectedErr error
|
|
expectNoErr bool
|
|
}{
|
|
{
|
|
name: "no devices returns scan error",
|
|
devices: nil,
|
|
data: make(map[string]*smart.SmartData),
|
|
scanErr: scanErr,
|
|
expectedErr: scanErr,
|
|
},
|
|
{
|
|
name: "has data ignores errors",
|
|
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
|
data: map[string]*smart.SmartData{"serial": {}},
|
|
scanErr: scanErr,
|
|
collectErr: collectErr,
|
|
expectNoErr: true,
|
|
},
|
|
{
|
|
name: "collect error preferred",
|
|
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
|
data: make(map[string]*smart.SmartData),
|
|
collectErr: collectErr,
|
|
expectedErr: collectErr,
|
|
},
|
|
{
|
|
name: "scan error returned when no data",
|
|
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
|
data: make(map[string]*smart.SmartData),
|
|
scanErr: scanErr,
|
|
expectedErr: scanErr,
|
|
},
|
|
{
|
|
name: "no errors returns sentinel",
|
|
devices: []*DeviceInfo{{Name: "/dev/sda"}},
|
|
data: make(map[string]*smart.SmartData),
|
|
expectedErr: errNoValidSmartData,
|
|
},
|
|
{
|
|
name: "no devices collect error",
|
|
devices: nil,
|
|
data: make(map[string]*smart.SmartData),
|
|
collectErr: collectErr,
|
|
expectedErr: collectErr,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
sm := &SmartManager{
|
|
SmartDevices: tt.devices,
|
|
SmartDataMap: tt.data,
|
|
}
|
|
|
|
err := sm.resolveRefreshError(tt.scanErr, tt.collectErr)
|
|
if tt.expectNoErr {
|
|
assert.NoError(t, err)
|
|
return
|
|
}
|
|
|
|
if tt.expectedErr == nil {
|
|
assert.NoError(t, err)
|
|
} else {
|
|
assert.Equal(t, tt.expectedErr, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseScan(t *testing.T) {
|
|
sm := &SmartManager{
|
|
SmartDataMap: map[string]*smart.SmartData{
|
|
"serial-active": {DiskName: "/dev/sda"},
|
|
"serial-stale": {DiskName: "/dev/sdb"},
|
|
},
|
|
}
|
|
|
|
scanJSON := []byte(`{
|
|
"devices": [
|
|
{"name": "/dev/sda", "type": "sat", "info_name": "/dev/sda [SAT]", "protocol": "ATA"},
|
|
{"name": "/dev/nvme0", "type": "nvme", "info_name": "/dev/nvme0", "protocol": "NVMe"}
|
|
]
|
|
}`)
|
|
|
|
devices, hasData := sm.parseScan(scanJSON)
|
|
assert.True(t, hasData)
|
|
|
|
sm.updateSmartDevices(devices)
|
|
|
|
require.Len(t, sm.SmartDevices, 2)
|
|
assert.Equal(t, "/dev/sda", sm.SmartDevices[0].Name)
|
|
assert.Equal(t, "sat", sm.SmartDevices[0].Type)
|
|
assert.Equal(t, "/dev/nvme0", sm.SmartDevices[1].Name)
|
|
assert.Equal(t, "nvme", sm.SmartDevices[1].Type)
|
|
|
|
_, activeExists := sm.SmartDataMap["serial-active"]
|
|
assert.True(t, activeExists, "active smart data should be preserved when device path remains")
|
|
|
|
_, staleExists := sm.SmartDataMap["serial-stale"]
|
|
assert.False(t, staleExists, "stale smart data entry should be removed when device path disappears")
|
|
}
|
|
|
|
func TestMergeDeviceListsPrefersConfigured(t *testing.T) {
|
|
scanned := []*DeviceInfo{
|
|
{Name: "/dev/sda", Type: "sat", InfoName: "scan-info", Protocol: "ATA"},
|
|
{Name: "/dev/nvme0", Type: "nvme"},
|
|
}
|
|
|
|
configured := []*DeviceInfo{
|
|
{Name: "/dev/sda", Type: "sat-override"},
|
|
{Name: "/dev/sdb", Type: "sat"},
|
|
}
|
|
|
|
merged := mergeDeviceLists(nil, scanned, configured)
|
|
require.Len(t, merged, 3)
|
|
|
|
byName := make(map[string]*DeviceInfo, len(merged))
|
|
for _, dev := range merged {
|
|
byName[dev.Name] = dev
|
|
}
|
|
|
|
require.Contains(t, byName, "/dev/sda")
|
|
assert.Equal(t, "sat-override", byName["/dev/sda"].Type, "configured type should override scanned type")
|
|
assert.Equal(t, "scan-info", byName["/dev/sda"].InfoName, "scan metadata should be preserved when config does not provide it")
|
|
|
|
require.Contains(t, byName, "/dev/nvme0")
|
|
assert.Equal(t, "nvme", byName["/dev/nvme0"].Type)
|
|
|
|
require.Contains(t, byName, "/dev/sdb")
|
|
assert.Equal(t, "sat", byName["/dev/sdb"].Type)
|
|
}
|
|
|
|
func TestMergeDeviceListsExpandsConfiguredDevicesWithSamePath(t *testing.T) {
|
|
scanned := []*DeviceInfo{
|
|
{Name: "/dev/sdb", Type: "sat", InfoName: "scan-info", Protocol: "ATA"},
|
|
}
|
|
configured := []*DeviceInfo{
|
|
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
|
|
{Name: "/dev/sdb", Type: "jms56x,1", explicitType: true},
|
|
}
|
|
|
|
merged := mergeDeviceLists(nil, scanned, configured)
|
|
require.Len(t, merged, 2)
|
|
|
|
byKey := make(map[deviceKey]*DeviceInfo, len(merged))
|
|
for _, device := range merged {
|
|
byKey[makeDeviceKey(device.Name, device.Type)] = device
|
|
}
|
|
|
|
first := byKey[makeDeviceKey("/dev/sdb", "jms56x,0")]
|
|
require.NotNil(t, first)
|
|
assert.Equal(t, "scan-info", first.InfoName)
|
|
assert.Equal(t, "ATA", first.Protocol)
|
|
assert.True(t, first.explicitType)
|
|
|
|
second := byKey[makeDeviceKey("/dev/sdb", "jms56x,1")]
|
|
require.NotNil(t, second)
|
|
assert.True(t, second.explicitType)
|
|
assert.NotContains(t, byKey, makeDeviceKey("/dev/sdb", "sat"))
|
|
}
|
|
|
|
func TestMergeDeviceListsPreservesSamePathVerificationAcrossRescan(t *testing.T) {
|
|
existing := []*DeviceInfo{
|
|
{Name: "/dev/sdb", Type: "jms56x,0", parserType: "sat", typeVerified: true, explicitType: true},
|
|
{Name: "/dev/sdb", Type: "jms56x,1", parserType: "sat", typeVerified: true, explicitType: true},
|
|
}
|
|
scanned := []*DeviceInfo{
|
|
{Name: "/dev/sdb", Type: "sat", Protocol: "ATA"},
|
|
}
|
|
configured := []*DeviceInfo{
|
|
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
|
|
{Name: "/dev/sdb", Type: "jms56x,1", explicitType: true},
|
|
}
|
|
|
|
merged := mergeDeviceLists(existing, scanned, configured)
|
|
require.Len(t, merged, 2)
|
|
byKey := make(map[deviceKey]*DeviceInfo, len(merged))
|
|
for _, device := range merged {
|
|
byKey[makeDeviceKey(device.Name, device.Type)] = device
|
|
assert.True(t, device.typeVerified, device.Type)
|
|
assert.Equal(t, "sat", device.parserType, device.Type)
|
|
assert.True(t, device.explicitType, device.Type)
|
|
}
|
|
assert.Contains(t, byKey, makeDeviceKey("/dev/sdb", "jms56x,0"))
|
|
assert.Contains(t, byKey, makeDeviceKey("/dev/sdb", "jms56x,1"))
|
|
}
|
|
|
|
func TestMergeDeviceListsDeduplicatesConfiguredIdentityAfterRekey(t *testing.T) {
|
|
scanned := []*DeviceInfo{{Name: "/dev/sdb", Type: "sat"}}
|
|
configured := []*DeviceInfo{
|
|
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
|
|
{Name: "/dev/sdb", Type: "jms56x,0", explicitType: true},
|
|
}
|
|
|
|
merged := mergeDeviceLists(nil, scanned, configured)
|
|
require.Len(t, merged, 1)
|
|
assert.Equal(t, "/dev/sdb", merged[0].Name)
|
|
assert.Equal(t, "jms56x,0", merged[0].Type)
|
|
}
|
|
|
|
func TestMergeDeviceListsPreservesVerification(t *testing.T) {
|
|
existing := []*DeviceInfo{
|
|
{Name: "/dev/sda", Type: "sat+megaraid", parserType: "sat", typeVerified: true},
|
|
}
|
|
|
|
scanned := []*DeviceInfo{
|
|
{Name: "/dev/sda", Type: "nvme"},
|
|
}
|
|
|
|
merged := mergeDeviceLists(existing, scanned, nil)
|
|
require.Len(t, merged, 1)
|
|
|
|
device := merged[0]
|
|
assert.True(t, device.typeVerified)
|
|
assert.Equal(t, "sat", device.parserType)
|
|
assert.Equal(t, "sat+megaraid", device.Type)
|
|
}
|
|
|
|
func TestMergeDeviceListsUpdatesTypeWhenUnverified(t *testing.T) {
|
|
existing := []*DeviceInfo{
|
|
{Name: "/dev/sda", Type: "sat", parserType: "sat", typeVerified: false},
|
|
}
|
|
|
|
scanned := []*DeviceInfo{
|
|
{Name: "/dev/sda", Type: "nvme"},
|
|
}
|
|
|
|
merged := mergeDeviceLists(existing, scanned, nil)
|
|
require.Len(t, merged, 1)
|
|
|
|
device := merged[0]
|
|
assert.False(t, device.typeVerified)
|
|
assert.Equal(t, "nvme", device.Type)
|
|
assert.Equal(t, "", device.parserType)
|
|
}
|
|
|
|
func TestMergeDeviceListsHandlesDevicesWithSameNameAndDifferentTypes(t *testing.T) {
|
|
// There are use cases where the same device name is re-used,
|
|
// for example, a RAID controller with multiple drives.
|
|
scanned := []*DeviceInfo{
|
|
{Name: "/dev/sda", Type: "megaraid,0"},
|
|
{Name: "/dev/sda", Type: "megaraid,1"},
|
|
{Name: "/dev/sda", Type: "megaraid,2"},
|
|
}
|
|
|
|
merged := mergeDeviceLists(nil, scanned, nil)
|
|
require.Len(t, merged, 3, "should have 3 separate devices for RAID controller")
|
|
|
|
byKey := make(map[string]*DeviceInfo, len(merged))
|
|
for _, dev := range merged {
|
|
key := dev.Name + "|" + dev.Type
|
|
byKey[key] = dev
|
|
}
|
|
|
|
assert.Contains(t, byKey, "/dev/sda|megaraid,0")
|
|
assert.Contains(t, byKey, "/dev/sda|megaraid,1")
|
|
assert.Contains(t, byKey, "/dev/sda|megaraid,2")
|
|
}
|
|
|
|
func TestMergeDeviceListsHandlesMixedRAIDAndRegular(t *testing.T) {
|
|
// Test mixing RAID drives with regular devices
|
|
scanned := []*DeviceInfo{
|
|
{Name: "/dev/sda", Type: "megaraid,0"},
|
|
{Name: "/dev/sda", Type: "megaraid,1"},
|
|
{Name: "/dev/sdb", Type: "sat"},
|
|
{Name: "/dev/nvme0", Type: "nvme"},
|
|
}
|
|
|
|
merged := mergeDeviceLists(nil, scanned, nil)
|
|
require.Len(t, merged, 4, "should have 4 separate devices")
|
|
|
|
byKey := make(map[string]*DeviceInfo, len(merged))
|
|
for _, dev := range merged {
|
|
key := dev.Name + "|" + dev.Type
|
|
byKey[key] = dev
|
|
}
|
|
|
|
assert.Contains(t, byKey, "/dev/sda|megaraid,0")
|
|
assert.Contains(t, byKey, "/dev/sda|megaraid,1")
|
|
assert.Contains(t, byKey, "/dev/sdb|sat")
|
|
assert.Contains(t, byKey, "/dev/nvme0|nvme")
|
|
}
|
|
|
|
func TestUpdateSmartDevicesPreservesRAIDDrives(t *testing.T) {
|
|
// Test that updateSmartDevices correctly validates RAID drives using composite keys
|
|
sm := &SmartManager{
|
|
SmartDevices: []*DeviceInfo{
|
|
{Name: "/dev/sda", Type: "megaraid,0"},
|
|
{Name: "/dev/sda", Type: "megaraid,1"},
|
|
},
|
|
SmartDataMap: map[string]*smart.SmartData{
|
|
"serial-0": {
|
|
DiskName: "/dev/sda",
|
|
DiskType: "megaraid,0",
|
|
SerialNumber: "serial-0",
|
|
},
|
|
"serial-1": {
|
|
DiskName: "/dev/sda",
|
|
DiskType: "megaraid,1",
|
|
SerialNumber: "serial-1",
|
|
},
|
|
"serial-stale": {
|
|
DiskName: "/dev/sda",
|
|
DiskType: "megaraid,2",
|
|
SerialNumber: "serial-stale",
|
|
},
|
|
},
|
|
}
|
|
|
|
sm.updateSmartDevices(sm.SmartDevices)
|
|
|
|
// serial-0 and serial-1 should be preserved (matching devices exist)
|
|
assert.Contains(t, sm.SmartDataMap, "serial-0")
|
|
assert.Contains(t, sm.SmartDataMap, "serial-1")
|
|
// serial-stale should be removed (no matching device)
|
|
assert.NotContains(t, sm.SmartDataMap, "serial-stale")
|
|
}
|
|
|
|
func TestParseSmartOutputMarksVerified(t *testing.T) {
|
|
fixturePath := filepath.Join("test-data", "smart", "nvme0.json")
|
|
data, err := os.ReadFile(fixturePath)
|
|
require.NoError(t, err)
|
|
|
|
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
device := &DeviceInfo{Name: "/dev/nvme0"}
|
|
|
|
require.True(t, sm.parseSmartOutput(device, data))
|
|
assert.Equal(t, "nvme", device.Type)
|
|
assert.Equal(t, "nvme", device.parserType)
|
|
assert.True(t, device.typeVerified)
|
|
}
|
|
|
|
func TestParseSmartForNvmeRejectsIdentityOnlyResponse(t *testing.T) {
|
|
jsonPayload := []byte(`{
|
|
"smartctl": {"exit_status": 2},
|
|
"device": {"name": "/dev/nvme0", "type": "nvme"},
|
|
"model_name": "Netac NVMe SSD 500GB",
|
|
"serial_number": "IDENTITY-ONLY",
|
|
"user_capacity": {"bytes": 500107862016}
|
|
}`)
|
|
|
|
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
hasData, exitStatus := sm.parseSmartForNvme(jsonPayload, "")
|
|
|
|
assert.False(t, hasData)
|
|
assert.Equal(t, 2, exitStatus)
|
|
assert.NotContains(t, sm.SmartDataMap, "IDENTITY-ONLY")
|
|
|
|
device := &DeviceInfo{Name: "/dev/nvme0", Type: "nvme"}
|
|
assert.False(t, sm.parseSmartOutput(device, jsonPayload))
|
|
assert.NotContains(t, sm.SmartDataMap, "IDENTITY-ONLY")
|
|
}
|
|
|
|
func TestParseSmartOutputKeepsCustomType(t *testing.T) {
|
|
fixturePath := filepath.Join("test-data", "smart", "sda.json")
|
|
data, err := os.ReadFile(fixturePath)
|
|
require.NoError(t, err)
|
|
|
|
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
device := &DeviceInfo{Name: "/dev/sda", Type: "sat+megaraid"}
|
|
|
|
require.True(t, sm.parseSmartOutput(device, data))
|
|
assert.Equal(t, "sat+megaraid", device.Type)
|
|
assert.Equal(t, "sat", device.parserType)
|
|
assert.True(t, device.typeVerified)
|
|
assert.Equal(t, "sat+megaraid", sm.SmartDataMap["9C40918040082"].DiskType)
|
|
}
|
|
|
|
func TestParseSmartOutputDoesNotNormalizeDeviceIdentity(t *testing.T) {
|
|
fixturePath := filepath.Join("test-data", "smart", "sda.json")
|
|
data, err := os.ReadFile(fixturePath)
|
|
require.NoError(t, err)
|
|
|
|
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
device := &DeviceInfo{Name: "/dev/sda", Type: "ata", explicitType: true}
|
|
|
|
require.True(t, sm.parseSmartOutput(device, data))
|
|
assert.Equal(t, "sat", device.parserType)
|
|
assert.Equal(t, "ata", sm.SmartDataMap["9C40918040082"].DiskType)
|
|
}
|
|
|
|
func TestParseSmartOutputResetsVerificationOnFailure(t *testing.T) {
|
|
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
|
|
device := &DeviceInfo{Name: "/dev/sda", Type: "sat", parserType: "sat", typeVerified: true}
|
|
|
|
assert.False(t, sm.parseSmartOutput(device, []byte("not json")))
|
|
assert.False(t, device.typeVerified)
|
|
assert.Equal(t, "sat", device.parserType)
|
|
}
|
|
|
|
func assertAttrValue(t *testing.T, attributes []*smart.SmartAttribute, name string, expected uint64) {
|
|
t.Helper()
|
|
attr := findAttr(attributes, name)
|
|
if attr == nil {
|
|
t.Fatalf("expected attribute %s to be present", name)
|
|
}
|
|
if attr.RawValue != expected {
|
|
t.Fatalf("unexpected attribute %s value: got %d, want %d", name, attr.RawValue, expected)
|
|
}
|
|
}
|
|
|
|
func findAttr(attributes []*smart.SmartAttribute, name string) *smart.SmartAttribute {
|
|
for _, attr := range attributes {
|
|
if attr != nil && attr.Name == name {
|
|
return attr
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestIsVirtualDevice(t *testing.T) {
|
|
sm := &SmartManager{}
|
|
|
|
tests := []struct {
|
|
name string
|
|
vendor string
|
|
product string
|
|
model string
|
|
expected bool
|
|
}{
|
|
{"regular drive", "SEAGATE", "ST1000DM003", "ST1000DM003-1CH162", false},
|
|
{"qemu virtual", "QEMU", "QEMU HARDDISK", "QEMU HARDDISK", true},
|
|
{"virtualbox virtual", "VBOX", "HARDDISK", "VBOX HARDDISK", true},
|
|
{"vmware virtual", "VMWARE", "Virtual disk", "VMWARE Virtual disk", true},
|
|
{"virtual in model", "ATA", "VIRTUAL", "VIRTUAL DISK", true},
|
|
{"iet virtual", "IET", "VIRTUAL-DISK", "VIRTUAL-DISK", true},
|
|
{"hyper-v virtual", "MSFT", "VIRTUAL HD", "VIRTUAL HD", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
data := &smart.SmartInfoForSata{
|
|
ScsiVendor: tt.vendor,
|
|
ScsiProduct: tt.product,
|
|
ModelName: tt.model,
|
|
}
|
|
result := sm.isVirtualDevice(data)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsVirtualDeviceNvme(t *testing.T) {
|
|
sm := &SmartManager{}
|
|
|
|
tests := []struct {
|
|
name string
|
|
model string
|
|
expected bool
|
|
}{
|
|
{"regular nvme", "Samsung SSD 970 EVO Plus 1TB", false},
|
|
{"qemu virtual", "QEMU NVMe Ctrl", true},
|
|
{"virtualbox virtual", "VBOX NVMe", true},
|
|
{"vmware virtual", "VMWARE NVMe", true},
|
|
{"virtual in model", "Virtual NVMe Device", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
data := &smart.SmartInfoForNvme{
|
|
ModelName: tt.model,
|
|
}
|
|
result := sm.isVirtualDeviceNvme(data)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsVirtualDeviceScsi(t *testing.T) {
|
|
sm := &SmartManager{}
|
|
|
|
tests := []struct {
|
|
name string
|
|
vendor string
|
|
product string
|
|
model string
|
|
expected bool
|
|
}{
|
|
{"regular scsi", "SEAGATE", "ST1000DM003", "ST1000DM003-1CH162", false},
|
|
{"qemu virtual", "QEMU", "QEMU HARDDISK", "QEMU HARDDISK", true},
|
|
{"virtualbox virtual", "VBOX", "HARDDISK", "VBOX HARDDISK", true},
|
|
{"vmware virtual", "VMWARE", "Virtual disk", "VMWARE Virtual disk", true},
|
|
{"virtual in model", "ATA", "VIRTUAL", "VIRTUAL DISK", true},
|
|
{"iet virtual", "IET", "VIRTUAL-DISK", "VIRTUAL-DISK", true},
|
|
{"hyper-v virtual", "MSFT", "VIRTUAL HD", "VIRTUAL HD", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
data := &smart.SmartInfoForScsi{
|
|
ScsiVendor: tt.vendor,
|
|
ScsiProduct: tt.product,
|
|
ScsiModelName: tt.model,
|
|
}
|
|
result := sm.isVirtualDeviceScsi(data)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFindAtaDeviceStatisticsValue(t *testing.T) {
|
|
val42 := int64(42)
|
|
val100 := int64(100)
|
|
valMinus20 := int64(-20)
|
|
|
|
tests := []struct {
|
|
name string
|
|
data smart.SmartInfoForSata
|
|
ataDeviceStats smart.AtaDeviceStatistics
|
|
entryNumber uint8
|
|
entryName string
|
|
minValue int64
|
|
maxValue int64
|
|
expectedValue *int64
|
|
}{
|
|
{
|
|
name: "value in ataDeviceStats",
|
|
ataDeviceStats: smart.AtaDeviceStatistics{
|
|
Pages: []smart.AtaDeviceStatisticsPage{
|
|
{
|
|
Number: 5,
|
|
Table: []smart.AtaDeviceStatisticsEntry{
|
|
{Name: "Current Temperature", Value: &val42},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
entryNumber: 5,
|
|
entryName: "Current Temperature",
|
|
minValue: 0,
|
|
maxValue: 100,
|
|
expectedValue: &val42,
|
|
},
|
|
{
|
|
name: "value unmarshaled from data",
|
|
data: smart.SmartInfoForSata{
|
|
AtaDeviceStatistics: []byte(`{"pages":[{"number":5,"table":[{"name":"Current Temperature","value":100}]}]}`),
|
|
},
|
|
entryNumber: 5,
|
|
entryName: "Current Temperature",
|
|
minValue: 0,
|
|
maxValue: 255,
|
|
expectedValue: &val100,
|
|
},
|
|
{
|
|
name: "value out of range (too high)",
|
|
ataDeviceStats: smart.AtaDeviceStatistics{
|
|
Pages: []smart.AtaDeviceStatisticsPage{
|
|
{
|
|
Number: 5,
|
|
Table: []smart.AtaDeviceStatisticsEntry{
|
|
{Name: "Current Temperature", Value: &val100},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
entryNumber: 5,
|
|
entryName: "Current Temperature",
|
|
minValue: 0,
|
|
maxValue: 50,
|
|
expectedValue: nil,
|
|
},
|
|
{
|
|
name: "value out of range (too low)",
|
|
ataDeviceStats: smart.AtaDeviceStatistics{
|
|
Pages: []smart.AtaDeviceStatisticsPage{
|
|
{
|
|
Number: 5,
|
|
Table: []smart.AtaDeviceStatisticsEntry{
|
|
{Name: "Min Temp", Value: &valMinus20},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
entryNumber: 5,
|
|
entryName: "Min Temp",
|
|
minValue: 0,
|
|
maxValue: 100,
|
|
expectedValue: nil,
|
|
},
|
|
{
|
|
name: "no statistics available",
|
|
data: smart.SmartInfoForSata{},
|
|
entryNumber: 5,
|
|
entryName: "Current Temperature",
|
|
minValue: 0,
|
|
maxValue: 255,
|
|
expectedValue: nil,
|
|
},
|
|
{
|
|
name: "wrong page number",
|
|
ataDeviceStats: smart.AtaDeviceStatistics{
|
|
Pages: []smart.AtaDeviceStatisticsPage{
|
|
{
|
|
Number: 1,
|
|
Table: []smart.AtaDeviceStatisticsEntry{
|
|
{Name: "Current Temperature", Value: &val42},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
entryNumber: 5,
|
|
entryName: "Current Temperature",
|
|
minValue: 0,
|
|
maxValue: 100,
|
|
expectedValue: nil,
|
|
},
|
|
{
|
|
name: "wrong entry name",
|
|
ataDeviceStats: smart.AtaDeviceStatistics{
|
|
Pages: []smart.AtaDeviceStatisticsPage{
|
|
{
|
|
Number: 5,
|
|
Table: []smart.AtaDeviceStatisticsEntry{
|
|
{Name: "Other Stat", Value: &val42},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
entryNumber: 5,
|
|
entryName: "Current Temperature",
|
|
minValue: 0,
|
|
maxValue: 100,
|
|
expectedValue: nil,
|
|
},
|
|
{
|
|
name: "case insensitive name match",
|
|
ataDeviceStats: smart.AtaDeviceStatistics{
|
|
Pages: []smart.AtaDeviceStatisticsPage{
|
|
{
|
|
Number: 5,
|
|
Table: []smart.AtaDeviceStatisticsEntry{
|
|
{Name: "CURRENT TEMPERATURE", Value: &val42},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
entryNumber: 5,
|
|
entryName: "Current Temperature",
|
|
minValue: 0,
|
|
maxValue: 100,
|
|
expectedValue: &val42,
|
|
},
|
|
{
|
|
name: "entry value is nil",
|
|
ataDeviceStats: smart.AtaDeviceStatistics{
|
|
Pages: []smart.AtaDeviceStatisticsPage{
|
|
{
|
|
Number: 5,
|
|
Table: []smart.AtaDeviceStatisticsEntry{
|
|
{Name: "Current Temperature", Value: nil},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
entryNumber: 5,
|
|
entryName: "Current Temperature",
|
|
minValue: 0,
|
|
maxValue: 100,
|
|
expectedValue: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := findAtaDeviceStatisticsValue(&tt.data, &tt.ataDeviceStats, tt.entryNumber, tt.entryName, tt.minValue, tt.maxValue)
|
|
if tt.expectedValue == nil {
|
|
assert.Nil(t, result)
|
|
} else {
|
|
require.NotNil(t, result)
|
|
assert.Equal(t, *tt.expectedValue, *result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRefreshExcludedDevices(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
envValue string
|
|
expectedDevs map[string]struct{}
|
|
}{
|
|
{
|
|
name: "empty env",
|
|
envValue: "",
|
|
expectedDevs: map[string]struct{}{},
|
|
},
|
|
{
|
|
name: "single device",
|
|
envValue: "/dev/sda",
|
|
expectedDevs: map[string]struct{}{
|
|
"/dev/sda": {},
|
|
},
|
|
},
|
|
{
|
|
name: "multiple devices",
|
|
envValue: "/dev/sda,/dev/sdb,/dev/nvme0",
|
|
expectedDevs: map[string]struct{}{
|
|
"/dev/sda": {},
|
|
"/dev/sdb": {},
|
|
"/dev/nvme0": {},
|
|
},
|
|
},
|
|
{
|
|
name: "devices with whitespace",
|
|
envValue: " /dev/sda , /dev/sdb , /dev/nvme0 ",
|
|
expectedDevs: map[string]struct{}{
|
|
"/dev/sda": {},
|
|
"/dev/sdb": {},
|
|
"/dev/nvme0": {},
|
|
},
|
|
},
|
|
{
|
|
name: "duplicate devices",
|
|
envValue: "/dev/sda,/dev/sdb,/dev/sda",
|
|
expectedDevs: map[string]struct{}{
|
|
"/dev/sda": {},
|
|
"/dev/sdb": {},
|
|
},
|
|
},
|
|
{
|
|
name: "empty entries and whitespace",
|
|
envValue: "/dev/sda,, /dev/sdb , , ",
|
|
expectedDevs: map[string]struct{}{
|
|
"/dev/sda": {},
|
|
"/dev/sdb": {},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if tt.envValue != "" {
|
|
t.Setenv("EXCLUDE_SMART", tt.envValue)
|
|
} else {
|
|
// Ensure env var is not set for empty test
|
|
t.Setenv("EXCLUDE_SMART", "")
|
|
}
|
|
|
|
sm := &SmartManager{}
|
|
sm.refreshExcludedDevices()
|
|
|
|
assert.Equal(t, tt.expectedDevs, sm.excludedDevices)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsExcludedDevice(t *testing.T) {
|
|
sm := &SmartManager{
|
|
excludedDevices: map[string]struct{}{
|
|
"/dev/sda": {},
|
|
"/dev/nvme0": {},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
deviceName string
|
|
expectedBool bool
|
|
}{
|
|
{"excluded device sda", "/dev/sda", true},
|
|
{"excluded device nvme0", "/dev/nvme0", true},
|
|
{"non-excluded device sdb", "/dev/sdb", false},
|
|
{"non-excluded device nvme1", "/dev/nvme1", false},
|
|
{"empty device name", "", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := sm.isExcludedDevice(tt.deviceName)
|
|
assert.Equal(t, tt.expectedBool, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFilterExcludedDevices(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
excludedDevs map[string]struct{}
|
|
inputDevices []*DeviceInfo
|
|
expectedDevs []*DeviceInfo
|
|
expectedLength int
|
|
}{
|
|
{
|
|
name: "no exclusions",
|
|
excludedDevs: map[string]struct{}{},
|
|
inputDevices: []*DeviceInfo{
|
|
{Name: "/dev/sda"},
|
|
{Name: "/dev/sdb"},
|
|
{Name: "/dev/nvme0"},
|
|
},
|
|
expectedDevs: []*DeviceInfo{
|
|
{Name: "/dev/sda"},
|
|
{Name: "/dev/sdb"},
|
|
{Name: "/dev/nvme0"},
|
|
},
|
|
expectedLength: 3,
|
|
},
|
|
{
|
|
name: "some devices excluded",
|
|
excludedDevs: map[string]struct{}{
|
|
"/dev/sda": {},
|
|
"/dev/nvme0": {},
|
|
},
|
|
inputDevices: []*DeviceInfo{
|
|
{Name: "/dev/sda"},
|
|
{Name: "/dev/sdb"},
|
|
{Name: "/dev/nvme0"},
|
|
{Name: "/dev/nvme1"},
|
|
},
|
|
expectedDevs: []*DeviceInfo{
|
|
{Name: "/dev/sdb"},
|
|
{Name: "/dev/nvme1"},
|
|
},
|
|
expectedLength: 2,
|
|
},
|
|
{
|
|
name: "all devices excluded",
|
|
excludedDevs: map[string]struct{}{
|
|
"/dev/sda": {},
|
|
"/dev/sdb": {},
|
|
},
|
|
inputDevices: []*DeviceInfo{
|
|
{Name: "/dev/sda"},
|
|
{Name: "/dev/sdb"},
|
|
},
|
|
expectedDevs: []*DeviceInfo{},
|
|
expectedLength: 0,
|
|
},
|
|
{
|
|
name: "nil devices",
|
|
excludedDevs: map[string]struct{}{},
|
|
inputDevices: nil,
|
|
expectedDevs: []*DeviceInfo{},
|
|
expectedLength: 0,
|
|
},
|
|
{
|
|
name: "filter nil and empty name devices",
|
|
excludedDevs: map[string]struct{}{
|
|
"/dev/sda": {},
|
|
},
|
|
inputDevices: []*DeviceInfo{
|
|
{Name: "/dev/sda"},
|
|
nil,
|
|
{Name: ""},
|
|
{Name: "/dev/sdb"},
|
|
},
|
|
expectedDevs: []*DeviceInfo{
|
|
{Name: "/dev/sdb"},
|
|
},
|
|
expectedLength: 1,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
sm := &SmartManager{
|
|
excludedDevices: tt.excludedDevs,
|
|
}
|
|
|
|
result := sm.filterExcludedDevices(tt.inputDevices)
|
|
|
|
assert.Len(t, result, tt.expectedLength)
|
|
assert.Equal(t, tt.expectedDevs, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsNvmeControllerPath(t *testing.T) {
|
|
tests := []struct {
|
|
path string
|
|
expected bool
|
|
}{
|
|
// Controller paths (should return true)
|
|
{"/dev/nvme0", true},
|
|
{"/dev/nvme1", true},
|
|
{"/dev/nvme10", true},
|
|
{"nvme0", true},
|
|
|
|
// Namespace paths (should return false)
|
|
{"/dev/nvme0n1", false},
|
|
{"/dev/nvme1n1", false},
|
|
{"/dev/nvme0n1p1", false},
|
|
{"nvme0n1", false},
|
|
|
|
// Non-NVMe paths (should return false)
|
|
{"/dev/sda", false},
|
|
{"/dev/sda1", false},
|
|
{"/dev/hda", false},
|
|
{"", false},
|
|
{"/dev/nvme", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.path, func(t *testing.T) {
|
|
result := isNvmeControllerPath(tt.path)
|
|
assert.Equal(t, tt.expected, result, "path: %s", tt.path)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseSmartForNvmeAppleSSD(t *testing.T) {
|
|
// Apple SSDs don't report user_capacity via smartctl; capacity should be fetched
|
|
// from system_profiler via the darwinNvmeProvider fallback.
|
|
fixturePath := filepath.Join("test-data", "smart", "apple_nvme.json")
|
|
data, err := os.ReadFile(fixturePath)
|
|
require.NoError(t, err)
|
|
|
|
providerCalls := 0
|
|
fakeProvider := func() ([]byte, error) {
|
|
providerCalls++
|
|
return []byte(`{
|
|
"SPNVMeDataType": [{
|
|
"_items": [{
|
|
"device_serial": "0ba0147940253c15",
|
|
"size_in_bytes": 251000193024
|
|
}]
|
|
}]
|
|
}`), nil
|
|
}
|
|
|
|
sm := &SmartManager{
|
|
SmartDataMap: make(map[string]*smart.SmartData),
|
|
darwinNvmeProvider: fakeProvider,
|
|
}
|
|
|
|
hasData, _ := sm.parseSmartForNvme(data, "")
|
|
require.True(t, hasData)
|
|
|
|
deviceData, ok := sm.SmartDataMap["0ba0147940253c15"]
|
|
require.True(t, ok)
|
|
assert.Equal(t, "APPLE SSD AP0256Q", deviceData.ModelName)
|
|
assert.Equal(t, uint64(251000193024), deviceData.Capacity)
|
|
assert.Equal(t, uint8(42), deviceData.Temperature)
|
|
assert.Equal(t, "PASSED", deviceData.SmartStatus)
|
|
assert.Equal(t, 1, providerCalls, "system_profiler should be called once")
|
|
|
|
// Second parse: provider should NOT be called again (cache hit)
|
|
_, _ = sm.parseSmartForNvme(data, "")
|
|
assert.Equal(t, 1, providerCalls, "system_profiler should not be called again after caching")
|
|
}
|
|
|
|
func TestLookupDarwinNvmeCapacityMultipleDisks(t *testing.T) {
|
|
fakeProvider := func() ([]byte, error) {
|
|
return []byte(`{
|
|
"SPNVMeDataType": [
|
|
{
|
|
"_items": [
|
|
{"device_serial": "serial-disk0", "size_in_bytes": 251000193024},
|
|
{"device_serial": "serial-disk1", "size_in_bytes": 1000204886016}
|
|
]
|
|
},
|
|
{
|
|
"_items": [
|
|
{"device_serial": "serial-disk2", "size_in_bytes": 512110190592}
|
|
]
|
|
}
|
|
]
|
|
}`), nil
|
|
}
|
|
|
|
sm := &SmartManager{darwinNvmeProvider: fakeProvider}
|
|
assert.Equal(t, uint64(251000193024), sm.lookupDarwinNvmeCapacity("serial-disk0"))
|
|
assert.Equal(t, uint64(1000204886016), sm.lookupDarwinNvmeCapacity("serial-disk1"))
|
|
assert.Equal(t, uint64(512110190592), sm.lookupDarwinNvmeCapacity("serial-disk2"))
|
|
assert.Equal(t, uint64(0), sm.lookupDarwinNvmeCapacity("unknown-serial"))
|
|
}
|
|
|
|
func TestLookupDarwinNvmeCapacityProviderError(t *testing.T) {
|
|
fakeProvider := func() ([]byte, error) {
|
|
return nil, errors.New("system_profiler not found")
|
|
}
|
|
|
|
sm := &SmartManager{darwinNvmeProvider: fakeProvider}
|
|
assert.Equal(t, uint64(0), sm.lookupDarwinNvmeCapacity("any-serial"))
|
|
// Cache should be initialized even on error so we don't retry (Once already fired)
|
|
assert.NotNil(t, sm.darwinNvmeCapacity)
|
|
}
|
|
|
|
// A child test process acts as smartctl: the controller is inaccessible,
|
|
// while its namespace returns valid NVMe data.
|
|
func init() {
|
|
fixture := os.Getenv("BESZEL_SMART_TEST_FIXTURE")
|
|
if fixture == "" {
|
|
return
|
|
}
|
|
if isNvmeControllerPath(os.Args[len(os.Args)-1]) {
|
|
fmt.Print(`{"smartctl":{"exit_status":2,"messages":[{"string":"Permission denied","severity":"error"}]}}`)
|
|
os.Exit(2)
|
|
}
|
|
data, err := os.ReadFile(fixture)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Print(strings.ReplaceAll(string(data), "/dev/nvme0", os.Args[len(os.Args)-1]))
|
|
os.Exit(0)
|
|
}
|
|
|
|
func TestCollectSmartNvmeNamespaceFallback(t *testing.T) {
|
|
fixture, err := filepath.Abs(filepath.Join("test-data", "smart", "nvme0.json"))
|
|
require.NoError(t, err)
|
|
t.Setenv("BESZEL_SMART_TEST_FIXTURE", fixture)
|
|
executable, err := os.Executable()
|
|
require.NoError(t, err)
|
|
for _, cached := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("cached=%t", cached), func(t *testing.T) {
|
|
device := &DeviceInfo{Name: "/dev/nvme0", Type: "nvme"}
|
|
sm := &SmartManager{smartctlPath: executable, SmartDataMap: make(map[string]*smart.SmartData)}
|
|
if cached {
|
|
sm.SmartDataMap["2024031600129"] = &smart.SmartData{DiskName: device.Name, DiskType: device.Type}
|
|
}
|
|
require.NoError(t, sm.CollectSmart(device))
|
|
assert.Equal(t, "/dev/nvme0n1", device.Name)
|
|
assert.Equal(t, "/dev/nvme0n1", sm.SmartDataMap["2024031600129"].DiskName)
|
|
assert.True(t, sm.isExcludedDevice("/dev/nvme0"))
|
|
})
|
|
}
|
|
}
|