diff --git a/internal/parser/vendors/inspur/parser.go b/internal/parser/vendors/inspur/parser.go index da01358..35ff750 100644 --- a/internal/parser/vendors/inspur/parser.go +++ b/internal/parser/vendors/inspur/parser.go @@ -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) diff --git a/internal/parser/vendors/inspur/sol_smartd.go b/internal/parser/vendors/inspur/sol_smartd.go new file mode 100644 index 0000000..3367c03 --- /dev/null +++ b/internal/parser/vendors/inspur/sol_smartd.go @@ -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" +} diff --git a/internal/parser/vendors/inspur/sol_smartd_test.go b/internal/parser/vendors/inspur/sol_smartd_test.go new file mode 100644 index 0000000..a24e899 --- /dev/null +++ b/internal/parser/vendors/inspur/sol_smartd_test.go @@ -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)) + } +}