fix(agent): handle partial NVMe SMART data (#2340)

This commit is contained in:
spatiumstas
2026-09-29 19:09:32 +03:00
committed by GitHub
parent 1997984325
commit 67c5905b36
3 changed files with 51 additions and 14 deletions

View File

@@ -311,11 +311,13 @@ func (sm *SmartManager) filterExcludedDevices(devices []*DeviceInfo) []*DeviceIn
return filtered return filtered
} }
// detectSmartOutputType inspects sections that are unique to each smartctl // detectSmartOutputType inspects protocol-specific sections and the reported
// JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used // device type to choose a parser, including when the NVMe health log is missing.
// when the reported device type is ambiguous or missing.
func detectSmartOutputType(output []byte) string { func detectSmartOutputType(output []byte) string {
var hints struct { var hints struct {
Device struct {
Type string `json:"type"`
} `json:"device"`
AtaSmartAttributes json.RawMessage `json:"ata_smart_attributes"` AtaSmartAttributes json.RawMessage `json:"ata_smart_attributes"`
NVMeSmartHealthInformationLog json.RawMessage `json:"nvme_smart_health_information_log"` NVMeSmartHealthInformationLog json.RawMessage `json:"nvme_smart_health_information_log"`
ScsiErrorCounterLog json.RawMessage `json:"scsi_error_counter_log"` ScsiErrorCounterLog json.RawMessage `json:"scsi_error_counter_log"`
@@ -326,7 +328,7 @@ func detectSmartOutputType(output []byte) string {
} }
switch { switch {
case hasJSONValue(hints.NVMeSmartHealthInformationLog): case hasJSONValue(hints.NVMeSmartHealthInformationLog), normalizeParserType(hints.Device.Type) == "nvme":
return "nvme" return "nvme"
case hasJSONValue(hints.AtaSmartAttributes): case hasJSONValue(hints.AtaSmartAttributes):
return "sat" return "sat"
@@ -397,11 +399,11 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
} }
} }
// Only run the type detection when we do not yet know which parser works // Inspect every response so a failed NVMe query cannot reach other parsers.
// or the previous attempt failed. structureType := detectSmartOutputType(output)
// Update the stored parser only when it is not yet verified.
needsDetection := deviceType == "" || !deviceInfo.typeVerified needsDetection := deviceType == "" || !deviceInfo.typeVerified
if needsDetection { if needsDetection {
structureType := detectSmartOutputType(output)
if deviceType != structureType { if deviceType != structureType {
deviceType = structureType deviceType = structureType
deviceInfo.parserType = structureType deviceInfo.parserType = structureType
@@ -442,6 +444,11 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
// Try the selected parsers in order until we find one that succeeds. // Try the selected parsers in order until we find one that succeeds.
for _, parser := range selectedParsers { for _, parser := range selectedParsers {
// A failed NVMe response may still contain a serial number, which is
// enough for the SATA and SCSI parsers to accept incorrect zero values.
if structureType == "nvme" && parser.Type != "nvme" {
continue
}
hasData, _ := parser.Parse(output) hasData, _ := parser.Parse(output)
if hasData { if hasData {
deviceInfo.parserType = parser.Type deviceInfo.parserType = parser.Type
@@ -1145,6 +1152,16 @@ func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (boo
return false, data.Smartctl.ExitStatus return false, data.Smartctl.ExitStatus
} }
// smartctl may return device identity fields before failing to read the NVMe
// health log (for example, on an unsupported controller path or with insufficient
// permissions). Do not accept that partial response as valid SMART data: doing
// so stores incorrect zero values and prevents the namespace-path fallback.
log := data.NVMeSmartHealthInformationLog
if log == nil {
slog.Debug("no NVMe SMART health information", "device", data.Device.Name)
return false, data.Smartctl.ExitStatus
}
sm.Lock() sm.Lock()
defer sm.Unlock() defer sm.Unlock()
@@ -1167,7 +1184,7 @@ func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (boo
if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) { if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber) smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
} }
smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature smartData.Temperature = log.Temperature
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed) smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type smartData.DiskType = data.Device.Type
@@ -1177,7 +1194,6 @@ func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (boo
// nvme attributes does not follow the same format as ata attributes, // nvme attributes does not follow the same format as ata attributes,
// so we manually map each field to SmartAttributes // so we manually map each field to SmartAttributes
log := data.NVMeSmartHealthInformationLog
smartData.Attributes = []*smart.SmartAttribute{ smartData.Attributes = []*smart.SmartAttribute{
{Name: "CriticalWarning", RawValue: uint64(log.CriticalWarning)}, {Name: "CriticalWarning", RawValue: uint64(log.CriticalWarning)},
{Name: "Temperature", RawValue: uint64(log.Temperature)}, {Name: "Temperature", RawValue: uint64(log.Temperature)},

View File

@@ -837,6 +837,27 @@ func TestParseSmartOutputMarksVerified(t *testing.T) {
assert.True(t, device.typeVerified) 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) { func TestParseSmartOutputKeepsCustomType(t *testing.T) {
fixturePath := filepath.Join("test-data", "smart", "sda.json") fixturePath := filepath.Join("test-data", "smart", "sda.json")
data, err := os.ReadFile(fixturePath) data, err := os.ReadFile(fixturePath)

View File

@@ -503,11 +503,11 @@ type SmartInfoForNvme struct {
UserCapacity UserCapacity `json:"user_capacity"` UserCapacity UserCapacity `json:"user_capacity"`
// LogicalBlockSize int `json:"logical_block_size"` // LogicalBlockSize int `json:"logical_block_size"`
// LocalTime LocalTime `json:"local_time"` // LocalTime LocalTime `json:"local_time"`
SmartStatus SmartStatusInfoNvme `json:"smart_status"` SmartStatus SmartStatusInfoNvme `json:"smart_status"`
NVMeSmartHealthInformationLog NVMeSmartHealthInformationLog `json:"nvme_smart_health_information_log"` NVMeSmartHealthInformationLog *NVMeSmartHealthInformationLog `json:"nvme_smart_health_information_log"`
Temperature TemperatureInfoNvme `json:"temperature"` Temperature TemperatureInfoNvme `json:"temperature"`
PowerCycleCount uint16 `json:"power_cycle_count"` PowerCycleCount uint16 `json:"power_cycle_count"`
PowerOnTime PowerOnTimeInfoNvme `json:"power_on_time"` PowerOnTime PowerOnTimeInfoNvme `json:"power_on_time"`
} }
type TemperatureInfoNvme struct { type TemperatureInfoNvme struct {