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" }