fix(collector): recover storage model for NVMe drives exposed as SCSI

An NVMe SSD passed through a RAID/HBA as /dev/sdX (not /dev/nvmeX) gets
smartctl'd over the SCSI protocol, which reports its model in
scsi_model_name/scsi_vendor/scsi_product instead of the ATA/NVMe
model_name field. smartctlInfo only mapped model_name, so the drive's
model was silently dropped from reanimator.json even though smartctl
ran successfully and the serial number came through fine — caught via
a support bundle where lsblk showed "SSSTC CA6-8D1024" for a drive but
reanimator.json's storage entry had no model at all.

Add scsi_model_name/scsi_vendor/scsi_product to smartctlInfo and fall
back to them, in that order, when model_name is empty.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-08-24 17:41:23 +03:00
co-authored by Claude Sonnet 5
parent 9add5616dd
commit a09201c7ab
2 changed files with 74 additions and 4 deletions
+26 -4
View File
@@ -270,9 +270,16 @@ func mergeStorageDevice(existing, incoming lsblkDevice) lsblkDevice {
// smartctlInfo is the subset of smartctl -j -a output we care about.
type smartctlInfo struct {
ModelFamily string `json:"model_family"`
ModelName string `json:"model_name"`
SerialNumber string `json:"serial_number"`
ModelFamily string `json:"model_family"`
ModelName string `json:"model_name"`
// ScsiModelName is set instead of ModelName when smartctl reports over the
// SCSI protocol (device.protocol == "SCSI") — this includes NVMe drives
// exposed through a RAID/HBA passthrough as /dev/sdX rather than
// /dev/nvmeX, which report no ModelName at all.
ScsiModelName string `json:"scsi_model_name"`
ScsiVendor string `json:"scsi_vendor"`
ScsiProduct string `json:"scsi_product"`
SerialNumber string `json:"serial_number"`
FirmwareVer string `json:"firmware_version"`
RotationRate int `json:"rotation_rate"`
Temperature struct {
@@ -299,6 +306,21 @@ type smartctlInfo struct {
PowerCycleCount int `json:"power_cycle_count"`
}
// smartctlModelName picks the drive model out of a parsed smartctl -j -a
// document. ATA/NVMe devices report it as model_name; devices smartctl talks
// to over the SCSI protocol (including NVMe drives exposed through a
// RAID/HBA passthrough as /dev/sdX) leave model_name empty and report
// scsi_model_name, or failing that scsi_vendor/scsi_product, instead.
func smartctlModelName(info smartctlInfo) string {
if v := cleanDMIValue(info.ModelName); v != "" {
return v
}
if v := cleanDMIValue(info.ScsiModelName); v != "" {
return v
}
return cleanDMIValue(strings.TrimSpace(info.ScsiVendor + " " + info.ScsiProduct))
}
func enrichWithSmartctl(dev lsblkDevice) schema.HardwareStorage {
present := true
s := schema.HardwareStorage{Present: &present}
@@ -349,7 +371,7 @@ func enrichWithSmartctl(dev lsblkDevice) schema.HardwareStorage {
var raw map[string]any
_ = json.Unmarshal(out, &raw)
if err := json.Unmarshal(out, &info); err == nil {
if v := cleanDMIValue(info.ModelName); v != "" {
if v := smartctlModelName(info); v != "" {
s.Model = &v
}
if v := cleanDMIValue(info.SerialNumber); v != "" {
@@ -99,3 +99,51 @@ func TestApplySCSISmartctlTelemetryDoesNotOverwriteExistingValues(t *testing.T)
t.Fatalf("life_used_pct=%v want 50", disk.LifeUsedPct)
}
}
func TestSmartctlModelNamePrefersATAModelName(t *testing.T) {
t.Parallel()
info := smartctlInfo{
ModelName: "Samsung SSD 970 EVO",
ScsiModelName: "should not be used",
}
if got := smartctlModelName(info); got != "Samsung SSD 970 EVO" {
t.Fatalf("model=%q want %q", got, "Samsung SSD 970 EVO")
}
}
func TestSmartctlModelNameFallsBackToScsiModelName(t *testing.T) {
t.Parallel()
// NVMe drive exposed through a RAID/HBA passthrough as /dev/sdX:
// smartctl reports it over the SCSI protocol, so model_name is empty
// and the model only shows up as scsi_model_name.
info := smartctlInfo{
ScsiModelName: "NVMe SSSTC CA6-8D1024",
ScsiVendor: "NVMe",
ScsiProduct: "SSSTC CA6-8D1024",
}
if got := smartctlModelName(info); got != "NVMe SSSTC CA6-8D1024" {
t.Fatalf("model=%q want %q", got, "NVMe SSSTC CA6-8D1024")
}
}
func TestSmartctlModelNameFallsBackToScsiVendorProduct(t *testing.T) {
t.Parallel()
info := smartctlInfo{
ScsiVendor: "NVMe",
ScsiProduct: "SSSTC CA6-8D1024",
}
if got := smartctlModelName(info); got != "NVMe SSSTC CA6-8D1024" {
t.Fatalf("model=%q want %q", got, "NVMe SSSTC CA6-8D1024")
}
}
func TestSmartctlModelNameEmptyWhenNoFieldsSet(t *testing.T) {
t.Parallel()
if got := smartctlModelName(smartctlInfo{}); got != "" {
t.Fatalf("model=%q want empty", got)
}
}