feat(inspur): enrich storage serials from SOLHostCapture.log smartd output

When the BMC HDD API returns an empty array (RAID controller attached via
PCIe, e.g. PM8204-2GB), disk serial numbers are now recovered from smartd
startup messages in SOLHostCapture.log.

Enrichment runs in three passes: model-match on existing slots, positional
fill of empty backplane placeholders, then new entries for any remainder.
Both log/ and runningdata/var/ copies are merged with serial deduplication.

Parser version bumped to 2.1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-06-15 17:32:41 +03:00
parent 6ab0f4eb20
commit a18d8fe648
3 changed files with 442 additions and 1 deletions

View File

@@ -16,7 +16,7 @@ import (
// parserVersion - version of this parser module
// IMPORTANT: Increment this version when making changes to parser logic!
const parserVersion = "2.0"
const parserVersion = "2.1"
func init() {
parser.Register(&Parser{})
@@ -234,6 +234,9 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
if result.Hardware != nil {
applyGPUStatusFromEvents(result.Hardware, result.Events)
enrichStorageFromSerialFallbackFiles(files, result.Hardware)
// Enrich storage serial numbers from smartd output in SOLHostCapture.log.
// Fills in serial, model, firmware for backplane slots that the BMC HDD API left empty.
enrichStorageFromSOLSmartd(files, result.Hardware)
// Apply RAID disk serials from audit.log (authoritative: last non-NULL SN change).
// These override redis/component.log serials which may be stale after disk replacement.
applyRAIDSlotSerials(result.Hardware, raidSlotSerials)

View File

@@ -0,0 +1,247 @@
package inspur
import (
"regexp"
"sort"
"strconv"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
// solSmartdDeviceRe matches smartd device info lines from SOLHostCapture.log.
// Example:
//
// Device: /dev/sda [SAT], Micron_5400_MTFDDAK480TGA, S/N:2310400DC7E3, WWN:..., FW:D4CM003, 480 GB
var solSmartdDeviceRe = regexp.MustCompile(
`Device: /dev/\S+ \[SAT\], (.+?), S/N:(\S+),.*?FW:(\S+), ([\d.]+) (GB|TB)`,
)
type solSmartdDevice struct {
Model string
Serial string
Firmware string
SizeGB int
}
// 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") {
m := solSmartdDeviceRe.FindStringSubmatch(line)
if m == nil {
continue
}
serial := strings.TrimSpace(m[2])
if serial == "" {
continue
}
key := strings.ToLower(serial)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
sizeGB := parseSolSizeGB(m[4], m[5])
out = append(out, solSmartdDevice{
Model: strings.TrimSpace(m[1]),
Serial: serial,
Firmware: strings.TrimSpace(m[3]),
SizeGB: sizeGB,
})
}
return out
}
// 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 = "SATA"
}
}
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: "SATA",
Present: true,
}
}
// 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"
}

View File

@@ -0,0 +1,191 @@
package inspur
import (
"strings"
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
const solSmartdSample = `
[ 17.219818] smartd[3321]: Device: /dev/sda [SAT], Micron_5400_MTFDDAK480TGA, S/N:2310400DC7E3, WWN:5-00a075-1400dc7e3, FW:D4CM003, 480 GB
[ 17.553024] smartd[3321]: Device: /dev/sdc [SAT], MTFDDAK3T8TGA-1BC1ZABDA, S/N:25134F172DB3, WWN:5-00a075-14f172db3, FW:D4DK403, 3.84 TB
[ 17.553331] smartd[3321]: Device: /dev/sde [SAT], Micron_5400_MTFDDAK480TGA, S/N:2310400DC80F, WWN:5-00a075-1400dc80f, FW:D4CM003, 480 GB
[ 17.553709] smartd[3321]: Device: /dev/sdh [SAT], MTFDDAK3T8TGA-1BC1ZABDA, S/N:25134F57DAB8, WWN:5-00a075-14f57dab8, FW:D4DK403, 3.84 TB
[ 17.886180] smartd[3321]: Device: /dev/sda [SAT], state written to /var/lib/smartmontools/smartd.Micron-2310400DC7E3.ata.state
`
func TestParseSOLSmartdDevices_Dedup(t *testing.T) {
devices := parseSOLSmartdDevices([]byte(solSmartdSample))
if len(devices) != 4 {
t.Fatalf("expected 4 unique devices, got %d: %v", len(devices), devices)
}
// order matches first-seen
if devices[0].Serial != "2310400DC7E3" {
t.Errorf("first device serial: got %q, want 2310400DC7E3", devices[0].Serial)
}
if devices[0].SizeGB != 480 {
t.Errorf("first device size: got %d, want 480", devices[0].SizeGB)
}
if devices[1].SizeGB != 3840 {
t.Errorf("TB device size: got %d, want 3840", devices[1].SizeGB)
}
if devices[1].Firmware != "D4DK403" {
t.Errorf("firmware: got %q, want D4DK403", devices[1].Firmware)
}
}
func TestParseSOLSmartdDevices_SkipsNonInfoLines(t *testing.T) {
content := `
[ 17.886177] smartd[3321]: Device: /dev/sda [SAT], state written to /var/lib/smartmontools/smartd.foo.ata.state
[ 17.040843] smartd[3321]: Device: /dev/sda [SAT], not found in smartd database 7.3/5319.
[ 17.040865] smartd[3321]: Device: /dev/sda [SAT], is SMART capable. Adding to "monitor" list.
`
devices := parseSOLSmartdDevices([]byte(content))
if len(devices) != 0 {
t.Errorf("expected 0 devices, got %d", len(devices))
}
}
func TestParseSolSizeGB(t *testing.T) {
cases := []struct {
value, unit string
want int
}{
{"480", "GB", 480},
{"1.92", "TB", 1920},
{"3.84", "TB", 3840},
{"1", "TB", 1000},
{"0", "GB", 0},
}
for _, c := range cases {
got := parseSolSizeGB(c.value, c.unit)
if got != c.want {
t.Errorf("parseSolSizeGB(%q, %q) = %d, want %d", c.value, c.unit, got, c.want)
}
}
}
func TestSolStorageType(t *testing.T) {
cases := []struct {
model string
want string
}{
{"MTFDDAK3T8TGA-1BC1ZABDA", "SSD"},
{"Micron_5400_MTFDDAK480TGA", "SSD"},
{"INTEL SSDSC2KB019TZ", "SSD"},
{"SEAGATE ST4000NM0115", "HDD"},
}
for _, c := range cases {
got := solStorageType(c.model)
if got != c.want {
t.Errorf("solStorageType(%q) = %q, want %q", c.model, got, c.want)
}
}
}
func TestEnrichStorageFromSOLSmartd_ModelMatch(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "onekeylog/log/sollog/SOLHostCapture.log",
Content: []byte(solSmartdSample),
},
}
hw := &models.HardwareConfig{
Storage: []models.Storage{
{Slot: "BP0:0", Model: "MTFDDAK3T8TGA-1BC1ZABDA", SizeGB: 3576, Present: true},
{Slot: "BP0:1", Model: "MTFDDAK3T8TGA-1BC1ZABDA", SizeGB: 3576, Present: true},
},
}
enrichStorageFromSOLSmartd(files, hw)
// The two existing slots must have received serials via model match.
for _, s := range hw.Storage[:2] {
if s.SerialNumber == "" {
t.Errorf("slot %q: expected serial to be assigned via model match", s.Slot)
}
if s.SizeGB != 3576 {
t.Errorf("slot %q: size should be preserved, got %d", s.Slot, s.SizeGB)
}
}
// The two unmatched Micron entries should be added as new storage entries.
if len(hw.Storage) != 4 {
t.Errorf("expected 4 total storage entries (2 existing + 2 new Micron), got %d", len(hw.Storage))
}
}
func TestEnrichStorageFromSOLSmartd_PlaceholderSlots(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "onekeylog/log/sollog/SOLHostCapture.log",
Content: []byte(solSmartdSample),
},
}
hw := &models.HardwareConfig{
Storage: []models.Storage{
{Slot: "BP0:0", Present: true},
{Slot: "BP0:1", Present: true},
},
}
enrichStorageFromSOLSmartd(files, hw)
for _, s := range hw.Storage {
if s.SerialNumber == "" {
t.Errorf("slot %q: expected serial to be assigned", s.Slot)
}
if s.Model == "" {
t.Errorf("slot %q: expected model to be assigned", s.Slot)
}
}
}
func TestEnrichStorageFromSOLSmartd_SkipsExistingSerial(t *testing.T) {
files := []parser.ExtractedFile{
{
Path: "onekeylog/log/sollog/SOLHostCapture.log",
Content: []byte(solSmartdSample),
},
}
hw := &models.HardwareConfig{
Storage: []models.Storage{
{Slot: "BP0:0", SerialNumber: "2310400DC7E3", Present: true},
},
}
before := len(hw.Storage)
enrichStorageFromSOLSmartd(files, hw)
// BP0:0 should still have original serial unchanged
if hw.Storage[0].SerialNumber != "2310400DC7E3" {
t.Errorf("existing serial was changed: got %q", hw.Storage[0].SerialNumber)
}
// Remaining 3 devices should be added as new entries
if len(hw.Storage) <= before {
t.Errorf("expected new entries to be added, got %d (same as before)", len(hw.Storage))
}
}
func TestEnrichStorageFromSOLSmartd_MergesTwoFiles(t *testing.T) {
// Two SOL files with partial overlap; combined unique serials = 3
file1 := `[ 17.0] smartd[1]: Device: /dev/sda [SAT], ModelA, S/N:SN001, WWN:w, FW:fw1, 480 GB`
file2 := strings.Join([]string{
`[ 17.0] smartd[2]: Device: /dev/sda [SAT], ModelA, S/N:SN001, WWN:w, FW:fw1, 480 GB`,
`[ 17.1] smartd[2]: Device: /dev/sdb [SAT], ModelB, S/N:SN002, WWN:w, FW:fw2, 480 GB`,
`[ 17.2] smartd[2]: Device: /dev/sdc [SAT], ModelC, S/N:SN003, WWN:w, FW:fw3, 480 GB`,
}, "\n")
files := []parser.ExtractedFile{
{Path: "log/sollog/SOLHostCapture.log", Content: []byte(file1)},
{Path: "runningdata/var/sollog/SOLHostCapture.log", Content: []byte(file2)},
}
hw := &models.HardwareConfig{}
enrichStorageFromSOLSmartd(files, hw)
if len(hw.Storage) != 3 {
t.Fatalf("expected 3 unique storage entries, got %d", len(hw.Storage))
}
}