// Package inspur provides parser for Inspur/Kaytus BMC diagnostic archives // Tested with: Inspur NF5468M7 / Kaytus KR4268X2 (onekeylog format) // // IMPORTANT: Increment parserVersion when modifying parser logic! // This helps track which version was used to parse specific logs. package inspur import ( "fmt" "strings" "time" "git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/parser" ) // parserVersion - version of this parser module // IMPORTANT: Increment this version when making changes to parser logic! const parserVersion = "3.0" func init() { parser.Register(&Parser{}) } // Parser implements VendorParser for Inspur/Kaytus servers type Parser struct{} // Name returns human-readable parser name func (p *Parser) Name() string { return "Inspur/Kaytus BMC Parser" } // Vendor returns vendor identifier func (p *Parser) Vendor() string { return "inspur" } // Version returns parser version // IMPORTANT: Update parserVersion constant when modifying parser logic! func (p *Parser) Version() string { return parserVersion } // Detect checks if archive matches Inspur/Kaytus format // Returns confidence 0-100 func (p *Parser) Detect(files []parser.ExtractedFile) int { confidence := 0 for _, f := range files { path := strings.ToLower(f.Path) // Strong indicators for Inspur/Kaytus onekeylog format if strings.Contains(path, "onekeylog/") { confidence += 30 } if strings.Contains(path, "devicefrusdr.log") { confidence += 25 } if strings.Contains(path, "component/component.log") { confidence += 15 } // Per-file component/ variant (no combined component.log), e.g. dumps // named dump__/ rather than onekeylog/. if strings.HasSuffix(path, "onekeylog_dreport.log") { confidence += 25 } if strings.Contains(path, "component/powersupplyinfo.txt") { confidence += 15 } // Check for asset.json with Inspur-specific structure if strings.HasSuffix(path, "asset.json") { if containsInspurMarkers(f.Content) { confidence += 20 } } // Cap at 100 if confidence >= 100 { return 100 } } return confidence } // containsInspurMarkers checks if content has Inspur-specific markers func containsInspurMarkers(content []byte) bool { s := string(content) // Check for typical Inspur asset.json structure return strings.Contains(s, "VersionInfo") && strings.Contains(s, "CpuInfo") && strings.Contains(s, "MemInfo") } // Parse parses Inspur/Kaytus archive func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) { selLocation := inferInspurArchiveLocation(files) selTimezone := inferInspurTimezoneResolver(files, selLocation) result := &models.AnalysisResult{ Events: make([]models.Event, 0), FRU: make([]models.FRUInfo, 0), Sensors: make([]models.SensorReading, 0), } // Pre-parse enrichment maps from devicefrusdr.log for use inside ParseAssetJSON. // BMC does not populate HddInfo.ModelName or SerialNumber for NVMe drives. var pcieSlotDeviceNames map[int]string var nvmeLocToSlot map[int]int if f := parser.FindFileByName(files, "devicefrusdr.log"); f != nil { pcieSlotDeviceNames = ParsePCIeSlotDeviceNames(f.Content) nvmeLocToSlot = ParsePCIeNVMeLocToSlot(f.Content) } // Parse NVMe serial numbers from audit.log: every disk SN change is logged there. // Combine with the NVMe loc→slot mapping to build pcieSlot→serial map. // Also parse RAID disk serials by backplane slot key (e.g. "BP0:0"). var pcieSlotSerials map[int]string var raidSlotSerials map[string]string if f := parser.FindFileByName(files, "audit.log"); f != nil { if len(nvmeLocToSlot) > 0 { nvmeDiskSerials := ParseAuditLogNVMeSerials(f.Content) if len(nvmeDiskSerials) > 0 { pcieSlotSerials = make(map[int]string, len(nvmeDiskSerials)) for diskNum, serial := range nvmeDiskSerials { if slot, ok := nvmeLocToSlot[diskNum]; ok { pcieSlotSerials[slot] = serial } } if len(pcieSlotSerials) == 0 { pcieSlotSerials = nil } } } raidSlotSerials = ParseAuditLogRAIDSerials(f.Content) } // Parse asset.json first (base hardware info) if f := parser.FindFileByName(files, "asset.json"); f != nil { if hw, err := ParseAssetJSON(f.Content, pcieSlotDeviceNames, pcieSlotSerials); err == nil { result.Hardware = hw } } // Extract BoardInfo from FRU data if result.Hardware == nil { result.Hardware = &models.HardwareConfig{} } // Parse devicefrusdr.log (contains SDR, FRU, PCIe and additional data) if f := parser.FindFileByName(files, "devicefrusdr.log"); f != nil { p.parseDeviceFruSDR(f.Content, result) } extractBoardInfo(result.FRU, result.Hardware) // Extract PlatformId (server model) from ThermalConfig if f := parser.FindFileByName(files, "ThermalConfig_Cur.conf"); f != nil { extractPlatformId(f.Content, result.Hardware) } // Parse component.log for additional data (PSU, etc.) if f := parser.FindFileByName(files, "component.log"); f != nil { ParseComponentLog(f.Content, result.Hardware) // Some combined component.log dumps have no devicefrusdr.log / asset.json; // board manufacturer/product/part/UUID live only in "RESTful FRU info:". if len(result.FRU) == 0 { if fru := ParseComponentLogFRU(f.Content, result.Hardware); len(fru) > 0 { result.FRU = fru extractBoardInfo(result.FRU, result.Hardware) } } // Extract events from component.log (memory errors, etc.) componentEvents := ParseComponentLogEvents(f.Content) result.Events = append(result.Events, componentEvents...) // Extract additional telemetry sensors from component.log sections // (fan RPM, backplane temperature, PSU summary power, etc.). componentSensors := ParseComponentLogSensors(f.Content) result.Sensors = mergeSensorReadings(result.Sensors, componentSensors) // Record sections where BMC itself returned an error instead of data, // and mirror each one into the Events stream so they appear in the log viewer. // Source is set to "BMC/
" so the viewer can show the specific module. for _, ce := range ParseComponentLogCollectionErrors(f.Content) { result.CollectionErrors = append(result.CollectionErrors, ce) desc := ce.Message if ce.Code != 0 { desc = fmt.Sprintf("%s (code %d)", ce.Message, ce.Code) } result.Events = append(result.Events, models.Event{ ID: fmt.Sprintf("bmc_collection_error_%s", strings.ToLower(strings.ReplaceAll(ce.Section, " ", "_"))), Timestamp: time.Time{}, // no timestamp available Source: fmt.Sprintf("BMC/%s", ce.Section), SensorType: "bmc_collection_error", EventType: "Collection Error", Severity: models.SeverityWarning, Description: desc, }) } } else { // Some onekeylog BMC variants split component.log into per-file // D-Bus transcripts under component/ instead of one combined log. if f := parser.FindFileByName(files, "PowerSupplyInfo.txt"); f != nil { ParseComponentDirPowerSupply(f.Content, result.Hardware) } if f := parser.FindFileByName(files, "FanInfo.txt"); f != nil { fanSensors := ParseComponentDirFan(f.Content) result.Sensors = mergeSensorReadings(result.Sensors, fanSensors) } } // New dump__/ layout has no devicefrusdr.log: FRU and // sensors live in separate ipmitool-output text files under component/. if parser.FindFileByName(files, "devicefrusdr.log") == nil { if f := parser.FindFileByName(files, "fru.txt"); f != nil && len(result.FRU) == 0 { result.FRU = ParseFRU(f.Content) extractBoardInfo(result.FRU, result.Hardware) } if f := parser.FindFileByName(files, "sensor.txt"); f != nil && len(result.Sensors) == 0 { result.Sensors = mergeSensorReadings(result.Sensors, ParseSensorList(f.Content)) } else if f := parser.FindFileByName(files, "sdr.txt"); f != nil && len(result.Sensors) == 0 { result.Sensors = mergeSensorReadings(result.Sensors, ParseSDRElist(f.Content)) } // Structural PCIe snapshot (GPU presence/link state) from the HGX diagnose dump. if f := parser.FindFileByName(files, "OtrdDiagnoseComponent.json"); f != nil && result.Hardware != nil { diagPCIe := ParseOtrdDiagnosePCIe(f.Content) result.Hardware.PCIeDevices = MergePCIeDevices(result.Hardware.PCIeDevices, diagPCIe) result.Events = append(result.Events, BuildPCIeLinkDegradationEvents(diagPCIe)...) } } // Enrich runtime component data from Redis snapshot (serials, FW, telemetry), // when text logs miss these fields. if f := parser.FindFileByName(files, "redis-dump.rdb"); f != nil && result.Hardware != nil { enrichFromRedisDump(f.Content, result.Hardware) } // Parse IDL-like logs (plain and structured JSON logs with embedded IDL messages) idlFiles := parser.FindFileByPattern(files, "/idl.log", "idl_json.log", "run_json.log") for _, f := range idlFiles { idlEvents := ParseIDLLog(f.Content) result.Events = append(result.Events, idlEvents...) } // Parse SEL list (selelist.csv), falling back to log/sel.csv on the // dump__/ layout that has no selelist.csv. if f := parser.FindFileByName(files, "selelist.csv"); f != nil { selEvents := parseSELListWithResolver(f.Content, selTimezone) result.Events = append(result.Events, selEvents...) } else if f := parser.FindFileByName(files, "sel.csv"); f != nil { selEvents := parseSELListWithResolver(f.Content, selTimezone) result.Events = append(result.Events, selEvents...) } // Parse syslog files syslogFiles := parser.FindFileByPattern(files, "syslog/emerg", "syslog/alert", "syslog/crit", "syslog/error", "syslog/warning", "syslog/notice", "syslog/info", ) for _, f := range syslogFiles { events := ParseSyslog(f.Content, f.Path) result.Events = append(result.Events, events...) } // Parse BMC component failure logs (log/bmc/commer-comp/*): HGX management // controller, switch VR/power, switch CPLD and slot presence failures. result.Events = append(result.Events, ParseCommerCompEvents(files, selLocation)...) // Same event can be reported by both IDL and SEL. After timezone // normalization, retain the richer offset-bearing IDL event and remove the // equivalent SEL copy, then provide a deterministic chronological stream. result.Events = dedupSELEvents(result.Events) sortInspurEvents(result.Events) // Fallback for archives where board serial is missing in parsed FRU/asset data: // recover it from log content, never from archive filename. if strings.TrimSpace(result.Hardware.BoardInfo.SerialNumber) == "" { if serial := inferBoardSerialFromFallbackLogs(files); serial != "" { result.Hardware.BoardInfo.SerialNumber = serial } } if strings.TrimSpace(result.Hardware.BoardInfo.ProductName) == "" { if model := inferBoardModelFromFallbackLogs(files); model != "" { result.Hardware.BoardInfo.ProductName = model } } // Enrich GPU inventory from HGX Redfish snapshot (serial/model/part mapping). if f := parser.FindFileByName(files, "HGX_HWInfo_FWVersion.log"); f != nil && result.Hardware != nil { enrichGPUsFromHGXHWInfo(f.Content, result.Hardware) appendHGXFirmwareFromHWInfo(f.Content, result.Hardware) result.Hardware.HGX = parseHGXIdentity(f.Content) } // Mark problematic GPUs from IDL errors like "BIOS miss F_GPU6". if result.Hardware != nil { applyGPUStatusFromEvents(result.Hardware, result.Events) var currentDeviceEvents []models.Event currentDeviceSnapshotAvailable := false if f := parser.FindFileByName(files, "dev_status.log"); f != nil { currentDeviceSnapshotAvailable = true currentDeviceEvents = ParseIDLLog(f.Content) } applyStorageStatusFromEvents(result.Hardware, result.Events, currentDeviceEvents, currentDeviceSnapshotAvailable) 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) parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware) } if len(result.FRU) == 0 || len(result.Sensors) == 0 { var missing []string if len(result.FRU) == 0 { missing = append(missing, "FRU") } if len(result.Sensors) == 0 { missing = append(missing, "sensors") } result.CollectionErrors = append(result.CollectionErrors, models.CollectionError{ Section: "inventory", Message: fmt.Sprintf("inventory sources not found: devicefrusdr.log absent, fell back to component/fru.txt+sensor.txt/sdr.txt, still missing: %s", strings.Join(missing, ", ")), }) } return result, nil } func inferInspurArchiveLocation(files []parser.ExtractedFile) *time.Location { fallback := parser.DefaultArchiveLocation() f := parser.FindFileByName(files, "timezone.conf") if f == nil { return fallback } locName := parseTimezoneConfigLocation(f.Content) if strings.TrimSpace(locName) == "" { return fallback } loc, err := time.LoadLocation(locName) if err != nil { return fallback } return loc } func parseTimezoneConfigLocation(content []byte) string { lines := strings.Split(string(content), "\n") for _, line := range lines { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "[") || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { continue } parts := strings.SplitN(line, "=", 2) if len(parts) != 2 { continue } key := strings.ToLower(strings.TrimSpace(parts[0])) val := strings.TrimSpace(parts[1]) if key == "timezone" && val != "" { return val } } return "" } func (p *Parser) parseDeviceFruSDR(content []byte, result *models.AnalysisResult) { lines := string(content) // Find SDR section sdrStart := strings.Index(lines, "BMC sdr Info:") fruStart := strings.Index(lines, "BMC fru Info:") if sdrStart != -1 { var sdrContent string if fruStart != -1 && fruStart > sdrStart { sdrContent = lines[sdrStart:fruStart] } else { sdrContent = lines[sdrStart:] } result.Sensors = ParseSDR([]byte(sdrContent)) } // Find FRU section if fruStart != -1 { fruContent := lines[fruStart:] result.FRU = ParseFRU([]byte(fruContent)) } // Parse PCIe devices from RESTful PCIE Device info // This supplements data from asset.json with serial numbers, firmware, etc. pcieDevicesFromREST := ParsePCIeDevices(content) // Merge PCIe data: asset.json is the base inventory, RESTful data enriches names/links/serials. if result.Hardware != nil { result.Hardware.PCIeDevices = MergePCIeDevices(result.Hardware.PCIeDevices, pcieDevicesFromREST) } // Parse GPU devices and add temperature data from sensors if len(result.Sensors) > 0 && result.Hardware != nil { // Use existing GPU data from asset.json and enrich with sensor data for i := range result.Hardware.GPUs { gpu := &result.Hardware.GPUs[i] // Extract GPU number from slot name slotNum := extractSlotNumberFromGPU(gpu.Slot) // Find temperature sensors for this GPU for _, sensor := range result.Sensors { sensorName := strings.ToUpper(sensor.Name) // Match GPU temperature sensor if strings.Contains(sensorName, fmt.Sprintf("GPU%d_TEMP", slotNum)) && !strings.Contains(sensorName, "MEM") { if sensor.RawValue != "" { fmt.Sscanf(sensor.RawValue, "%d", &gpu.Temperature) } } // Match GPU memory temperature if strings.Contains(sensorName, fmt.Sprintf("GPU%d_MEM_TEMP", slotNum)) { if sensor.RawValue != "" { fmt.Sscanf(sensor.RawValue, "%d", &gpu.MemTemperature) } } // Match PCIe slot temperature as fallback if strings.Contains(sensorName, fmt.Sprintf("PCIE%d_GPU_TLM_T", slotNum)) && gpu.Temperature == 0 { if sensor.RawValue != "" { fmt.Sscanf(sensor.RawValue, "%d", &gpu.Temperature) } } } } } } // extractSlotNumberFromGPU extracts slot number from GPU slot string func extractSlotNumberFromGPU(slot string) int { parts := strings.Split(slot, "_") for _, part := range parts { if strings.HasPrefix(part, "PCIE") { var num int fmt.Sscanf(part, "PCIE%d", &num) if num > 0 { return num } } } return 0 } func mergeSensorReadings(base, extra []models.SensorReading) []models.SensorReading { if len(extra) == 0 { return base } out := append([]models.SensorReading{}, base...) seen := make(map[string]struct{}, len(out)) for _, s := range out { if key := sensorMergeKey(s); key != "" { seen[key] = struct{}{} } } for _, s := range extra { key := sensorMergeKey(s) if key != "" { if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} } out = append(out, s) } return out } func sensorMergeKey(s models.SensorReading) string { name := strings.ToLower(strings.TrimSpace(s.Name)) if name == "" { return "" } return name }