Files
logpile/internal/parser/vendors/inspur/sol_smartd.go
T
Mikhail ChusavitinandClaude Sonnet 5 2fa0f78f94 fix(inspur): read RESTful FRU info and float fans_power from component.log
Diffing an NF5280M6 BMC dump against its BEE-SP live-CD bundle found two
blind spots in the combined-component.log onekeylog layout (no
devicefrusdr.log / asset.json):

- board manufacturer/product/part/uuid empty and stats.fru 0: the
  "RESTful FRU info:" JSON block was never parsed. New component_fru.go
  (ParseComponentLogFRU) flattens it to []models.FRUInfo, prefers the
  product-area system serial over the board PCB serial, and sets
  BoardInfo.UUID from system_uuid. Wired as a fallback only when
  result.FRU is still empty.
- zero fan sensors: FanRESTInfo.FansPower was int but this firmware
  writes "fans_power": 12.000000, so json.Unmarshal of the whole fan
  block failed. Changed to float64.

Also included: SOL smartd SCSI/SAS device-line parsing and diagnose.go
gofmt from concurrent work on the same live-CD-diff task. See ADL-064.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LffAvostt3uMkiUbVUiyM
2026-09-01 11:58:55 +03:00

305 lines
8.6 KiB
Go

package inspur
import (
"regexp"
"sort"
"strconv"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
// smartd prints one device-info line per drive during startup. Two shapes occur,
// depending on how the drive is attached:
//
// SAT (SATA drive, ATA pass-through):
// Device: /dev/sda [SAT], Micron_5400_MTFDDAK480TGA, S/N:2310400DC7E3, WWN:5-00a075-1400dc7e3, FW:D4CM003, 480 GB
// SCSI (SAS drive, or SATA drive behind a SAS HBA in SCSI mode):
// Device: /dev/sdb, [SEAGATE ST6000NM005B K0A1], lu id: 0x5000c500ee6e11f7, S/N: WX004FCC0000E22967PY, 6.00 TB
//
// The SCSI line has no firmware field and reports the model as a padded SCSI
// INQUIRY triple "VENDOR PRODUCT REV".
var (
solSmartdSATDeviceRe = regexp.MustCompile(
`Device: /dev/\S+ \[SAT\], (.+?), S/N:\s*(\S+),.*?FW:(\S+), ([\d.]+) (GB|TB)`,
)
solSmartdSCSIDeviceRe = regexp.MustCompile(
`Device: /dev/\S+?, \[(.+?)\],.*?S/N:\s*(\S+), ([\d.]+) (GB|TB)`,
)
multiSpaceRe = regexp.MustCompile(`\s{2,}`)
)
type solSmartdDevice struct {
Model string
Serial string
Firmware string
SizeGB int
Interface string // "SATA" or "SAS"
}
// parseSOLSmartdDevices extracts unique disk entries from SOLHostCapture.log content.
// Deduplicates by serial number (case-insensitive); preserves first-seen order.
func parseSOLSmartdDevices(content []byte) []solSmartdDevice {
seen := make(map[string]struct{})
var out []solSmartdDevice
for _, line := range strings.Split(string(content), "\n") {
dev, ok := parseSOLSmartdLine(line)
if !ok {
continue
}
key := strings.ToLower(dev.Serial)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, dev)
}
return out
}
func parseSOLSmartdLine(line string) (solSmartdDevice, bool) {
if m := solSmartdSATDeviceRe.FindStringSubmatch(line); m != nil {
serial := strings.TrimSpace(m[2])
if serial == "" {
return solSmartdDevice{}, false
}
return solSmartdDevice{
Model: strings.TrimSpace(m[1]),
Serial: serial,
Firmware: strings.TrimSpace(m[3]),
SizeGB: parseSolSizeGB(m[4], m[5]),
Interface: "SATA",
}, true
}
if m := solSmartdSCSIDeviceRe.FindStringSubmatch(line); m != nil {
serial := strings.TrimSpace(m[2])
if serial == "" {
return solSmartdDevice{}, false
}
return solSmartdDevice{
Model: scsiInquiryModel(m[1]),
Serial: serial,
SizeGB: parseSolSizeGB(m[3], m[4]),
Interface: "SAS",
}, true
}
return solSmartdDevice{}, false
}
// scsiInquiryModel turns a padded SCSI INQUIRY string ("SEAGATE ST6000NM005B
// K0A1") into "VENDOR PRODUCT", dropping the trailing revision. An "ATA" vendor
// is a SATA drive bridged into SCSI mode — its product field already holds the
// real model, so the vendor token is dropped.
func scsiInquiryModel(raw string) string {
fields := multiSpaceRe.Split(strings.TrimSpace(raw), -1)
switch len(fields) {
case 0:
return strings.TrimSpace(raw)
case 1:
return fields[0]
}
vendor, product := fields[0], fields[1]
if strings.EqualFold(vendor, "ATA") {
return product
}
return vendor + " " + product
}
// parseSolSizeGB converts smartd size string ("480", "3.84") + unit ("GB", "TB") to integer GB.
// Uses decimal TB (1 TB = 1000 GB) matching disk manufacturer conventions.
func parseSolSizeGB(value, unit string) int {
f, err := strconv.ParseFloat(value, 64)
if err != nil || f <= 0 {
return 0
}
if strings.EqualFold(unit, "TB") {
f *= 1000
}
return int(f + 0.5)
}
// enrichStorageFromSOLSmartd enriches the storage inventory using disk info from
// SOLHostCapture.log (smartd startup messages). Both the log/ and runningdata/ copies
// are processed; serials are deduplicated across both files.
//
// Enrichment priority:
// 1. Exact model match to existing entries that are missing a serial.
// 2. Positional assignment to present placeholder slots (no model, no serial).
// 3. New entries added for any remaining devices.
func enrichStorageFromSOLSmartd(files []parser.ExtractedFile, hw *models.HardwareConfig) {
if hw == nil {
return
}
solFiles := parser.FindFileByPattern(files, "SOLHostCapture.log")
if len(solFiles) == 0 {
return
}
// Collect unique devices from all SOL log copies.
seenSerial := make(map[string]struct{})
var devices []solSmartdDevice
for _, f := range solFiles {
for _, d := range parseSOLSmartdDevices(f.Content) {
key := strings.ToLower(d.Serial)
if _, ok := seenSerial[key]; ok {
continue
}
seenSerial[key] = struct{}{}
devices = append(devices, d)
}
}
if len(devices) == 0 {
return
}
// Skip devices whose serial already appears in the storage inventory.
existingSerials := make(map[string]struct{}, len(hw.Storage))
for _, dev := range hw.Storage {
sn := strings.ToLower(strings.TrimSpace(dev.SerialNumber))
if sn != "" {
existingSerials[sn] = struct{}{}
}
}
var newDevices []solSmartdDevice
for _, d := range devices {
if _, ok := existingSerials[strings.ToLower(d.Serial)]; !ok {
newDevices = append(newDevices, d)
}
}
if len(newDevices) == 0 {
return
}
// Pass 1: enrich existing entries that match by model (first-match wins per device).
remaining := solEnrichByModel(hw, newDevices)
if len(remaining) == 0 {
return
}
// Pass 2: assign to present placeholder slots (present=true, no model, no serial).
remaining = solEnrichByPlaceholder(hw, remaining)
if len(remaining) == 0 {
return
}
// Pass 3: add as new storage entries without a slot assignment.
for _, d := range remaining {
hw.Storage = append(hw.Storage, solMakeStorage(d))
}
}
// solEnrichByModel fills SerialNumber (and optionally Firmware/SizeGB) on existing storage
// entries whose model matches the smartd model exactly. Returns unmatched devices.
func solEnrichByModel(hw *models.HardwareConfig, devices []solSmartdDevice) []solSmartdDevice {
var unmatched []solSmartdDevice
for _, d := range devices {
matched := false
for i := range hw.Storage {
if strings.TrimSpace(hw.Storage[i].SerialNumber) != "" {
continue
}
if !strings.EqualFold(strings.TrimSpace(hw.Storage[i].Model), d.Model) {
continue
}
hw.Storage[i].SerialNumber = d.Serial
if strings.TrimSpace(hw.Storage[i].Firmware) == "" {
hw.Storage[i].Firmware = d.Firmware
}
if hw.Storage[i].SizeGB == 0 {
hw.Storage[i].SizeGB = d.SizeGB
}
matched = true
break
}
if !matched {
unmatched = append(unmatched, d)
}
}
return unmatched
}
// solEnrichByPlaceholder assigns smartd devices to present storage entries that have
// neither a model nor a serial number, sorted by slot name. Returns unmatched devices.
func solEnrichByPlaceholder(hw *models.HardwareConfig, devices []solSmartdDevice) []solSmartdDevice {
type slot struct {
index int
name string
}
var placeholders []slot
for i := range hw.Storage {
if !hw.Storage[i].Present {
continue
}
if strings.TrimSpace(hw.Storage[i].SerialNumber) != "" {
continue
}
if strings.TrimSpace(hw.Storage[i].Model) != "" {
continue
}
placeholders = append(placeholders, slot{index: i, name: hw.Storage[i].Slot})
}
sort.Slice(placeholders, func(i, j int) bool {
return placeholders[i].name < placeholders[j].name
})
pi := 0
var unmatched []solSmartdDevice
for _, d := range devices {
if pi >= len(placeholders) {
unmatched = append(unmatched, d)
continue
}
idx := placeholders[pi].index
pi++
hw.Storage[idx].SerialNumber = d.Serial
hw.Storage[idx].Model = d.Model
hw.Storage[idx].Firmware = d.Firmware
if hw.Storage[idx].SizeGB == 0 {
hw.Storage[idx].SizeGB = d.SizeGB
}
hw.Storage[idx].Type = solStorageType(d.Model)
if hw.Storage[idx].Manufacturer == "" {
hw.Storage[idx].Manufacturer = extractStorageManufacturer(d.Model)
}
if hw.Storage[idx].Interface == "" {
hw.Storage[idx].Interface = solDeviceInterface(d)
}
}
return unmatched
}
func solMakeStorage(d solSmartdDevice) models.Storage {
return models.Storage{
Model: d.Model,
SerialNumber: d.Serial,
Firmware: d.Firmware,
SizeGB: d.SizeGB,
Type: solStorageType(d.Model),
Manufacturer: extractStorageManufacturer(d.Model),
Interface: solDeviceInterface(d),
Present: true,
}
}
func solDeviceInterface(d solSmartdDevice) string {
if d.Interface != "" {
return d.Interface
}
return "SATA"
}
// solStorageType infers SSD vs HDD from the model string.
// Micron SSD models start with "MTFDD"; Intel SSDs contain "SSD".
func solStorageType(model string) string {
upper := strings.ToUpper(model)
if strings.Contains(upper, "SSD") ||
strings.HasPrefix(upper, "MTFDD") ||
strings.HasPrefix(upper, "MICRON_5") {
return "SSD"
}
return "HDD"
}