diff --git a/internal/parser/vendors/vendors.go b/internal/parser/vendors/vendors.go index f485abc..cc4cfd6 100644 --- a/internal/parser/vendors/vendors.go +++ b/internal/parser/vendors/vendors.go @@ -10,6 +10,7 @@ import ( _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia_bug_report" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/unraid" + _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xfusion" _ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xigmanas" // Generic fallback parser (must be last for lowest priority) diff --git a/internal/parser/vendors/xfusion/hardware.go b/internal/parser/vendors/xfusion/hardware.go new file mode 100644 index 0000000..bd28ebf --- /dev/null +++ b/internal/parser/vendors/xfusion/hardware.go @@ -0,0 +1,669 @@ +package xfusion + +import ( + "fmt" + "strconv" + "strings" + "time" + + "git.mchus.pro/mchus/logpile/internal/models" + "git.mchus.pro/mchus/logpile/internal/parser" +) + +// ── FRU ────────────────────────────────────────────────────────────────────── + +// parseFRUInfo parses fruinfo.txt and populates result.FRU and result.Hardware.BoardInfo. +// The file contains IPMI FRU blocks separated by "FRU Device Description" header lines. +func parseFRUInfo(content []byte, result *models.AnalysisResult) { + type fruBlock struct { + header string + fields map[string]string + } + var blocks []fruBlock + var current *fruBlock + + for _, line := range strings.Split(string(content), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "FRU Device Description") { + if current != nil { + blocks = append(blocks, *current) + } + current = &fruBlock{header: trimmed, fields: make(map[string]string)} + continue + } + if current == nil { + continue + } + idx := strings.Index(trimmed, " : ") + if idx < 0 { + continue + } + key := strings.TrimSpace(trimmed[:idx]) + val := strings.TrimSpace(trimmed[idx+3:]) + if key != "" && current.fields[key] == "" { + current.fields[key] = val + } + } + if current != nil { + blocks = append(blocks, *current) + } + + for _, b := range blocks { + f := b.fields + fru := models.FRUInfo{ + Description: extractFRUHeaderDesc(b.header), + Manufacturer: firstNonEmpty(f["Board Manufacturer"], f["Product Manufacturer"]), + ProductName: f["Product Name"], + SerialNumber: firstNonEmpty(f["Product Serial Number"], f["Board Serial Number"]), + PartNumber: firstNonEmpty(f["Product Part Number"], f["Board Part Number"]), + MfgDate: f["Board Mfg. Date"], + } + if fru.Description != "" || fru.ProductName != "" || fru.SerialNumber != "" { + result.FRU = append(result.FRU, fru) + } + } + + // Set BoardInfo from the mainboard block (ID 0). + for _, b := range blocks { + hdr := strings.ToLower(b.header) + if strings.Contains(hdr, "id 0") || strings.Contains(hdr, "mainboard") { + f := b.fields + result.Hardware.BoardInfo = models.BoardInfo{ + Manufacturer: firstNonEmpty(f["Product Manufacturer"], f["Board Manufacturer"]), + ProductName: firstNonEmpty(f["Product Name"], f["Board Product Name"]), + SerialNumber: firstNonEmpty(f["Product Serial Number"], f["Board Serial Number"]), + PartNumber: firstNonEmpty(f["Product Part Number"], f["Board Part Number"]), + } + break + } + } +} + +func extractFRUHeaderDesc(header string) string { + // "FRU Device Description : Builtin FRU Device (ID 0, Mainboard)" + idx := strings.Index(header, " : ") + if idx >= 0 { + return strings.TrimSpace(header[idx+3:]) + } + return header +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + v = strings.TrimSpace(v) + if v != "" { + return v + } + } + return "" +} + +// ── Sensors ─────────────────────────────────────────────────────────────────── + +// parseSensorInfo parses the pipe-delimited IPMI sensor table from sensor_info.txt. +// Columns: sensor id | sensor name | value | unit | status | thresholds... +func parseSensorInfo(content []byte) []models.SensorReading { + var sensors []models.SensorReading + inTable := false + + for _, line := range strings.Split(string(content), "\n") { + if strings.Contains(line, "sensor id") && strings.Contains(line, "sensor name") { + inTable = true + continue + } + if inTable && strings.HasPrefix(strings.TrimSpace(line), "**") { + // "*** Detailed Voltage Object Information ***" signals end of main table + inTable = false + continue + } + if !inTable || !strings.Contains(line, "|") { + continue + } + parts := strings.Split(line, "|") + if len(parts) < 5 { + continue + } + name := strings.TrimSpace(parts[1]) + valueStr := strings.TrimSpace(parts[2]) + unit := strings.TrimSpace(parts[3]) + status := strings.TrimSpace(parts[4]) + + if name == "" || valueStr == "na" || unit == "discrete" || unit == "unspecified" { + continue + } + value, err := strconv.ParseFloat(valueStr, 64) + if err != nil { + continue + } + sensors = append(sensors, models.SensorReading{ + Name: name, + Type: sensorType(name, unit), + Value: value, + Unit: mapSensorUnit(unit), + RawValue: valueStr, + Status: status, + }) + } + return sensors +} + +func mapSensorUnit(u string) string { + switch strings.ToLower(strings.TrimSpace(u)) { + case "degrees c": + return "C" + case "volts": + return "V" + case "watts": + return "W" + case "rpm": + return "RPM" + case "amps": + return "A" + default: + return u + } +} + +func sensorType(name, unit string) string { + u := strings.ToLower(unit) + n := strings.ToLower(name) + switch { + case strings.Contains(u, "degrees"): + return "temperature" + case strings.Contains(u, "volts"): + return "voltage" + case strings.Contains(u, "watts"): + return "power" + case strings.Contains(u, "rpm") || strings.Contains(n, "fan") && strings.Contains(n, "speed"): + return "fan" + case strings.Contains(u, "amps"): + return "current" + default: + return "" + } +} + +// ── CPU ─────────────────────────────────────────────────────────────────────── + +// parseCPUInfo parses the comma-separated cpu_info file. +// Columns: slot, presence, model, processorID, cores, threads, flags, L1, L2, L3, partNum, devName, location, SN +func parseCPUInfo(content []byte) []models.CPU { + var cpus []models.CPU + lines := strings.Split(string(content), "\n") + for i, line := range lines { + if i == 0 { // skip header + continue + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Split(line, ",") + if len(parts) < 6 { + continue + } + slot := strings.TrimSpace(parts[0]) + if !strings.HasPrefix(strings.ToLower(slot), "cpu") { + continue + } + if strings.ToLower(strings.TrimSpace(parts[1])) != "present" { + continue + } + + socketNum := 0 + fmt.Sscanf(strings.ToLower(slot), "cpu%d", &socketNum) + + model := strings.TrimSpace(parts[2]) + cores := 0 + fmt.Sscanf(strings.TrimSpace(parts[4]), "%d", &cores) + threads := 0 + fmt.Sscanf(strings.TrimSpace(parts[5]), "%d", &threads) + + l1, l2, l3 := 0, 0, 0 + if len(parts) >= 10 { + l1 = parseCacheSizeKB(parts[7]) + l2 = parseCacheSizeKB(parts[8]) + l3 = parseCacheSizeKB(parts[9]) + } + + sn := "" + if len(parts) >= 14 { + sn = strings.TrimSpace(parts[13]) + } + + cpus = append(cpus, models.CPU{ + Socket: socketNum, + Model: model, + Cores: cores, + Threads: threads, + L1CacheKB: l1, + L2CacheKB: l2, + L3CacheKB: l3, + SerialNumber: sn, + Status: "ok", + }) + } + return cpus +} + +func parseCacheSizeKB(s string) int { + var n int + fmt.Sscanf(strings.TrimSpace(s), "%d", &n) + return n +} + +// ── Memory ──────────────────────────────────────────────────────────────────── + +// parseMemInfo parses the comma-separated mem_info file. +// Columns: slot, location, dimmName, manufacturer, size, maxSpeed, curSpeed, type, SN, voltage, rank, bitWidth, tech, bom, partNum, ..., health +func parseMemInfo(content []byte) []models.MemoryDIMM { + var dimms []models.MemoryDIMM + lines := strings.Split(string(content), "\n") + for i, line := range lines { + if i == 0 { + continue + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.Split(line, ",") + if len(parts) < 9 { + continue + } + + sn := strings.TrimSpace(parts[8]) + if strings.ToLower(sn) == "no dimm" || sn == "" { + continue + } + + slot := strings.TrimSpace(parts[0]) + location := strings.TrimSpace(parts[1]) + manufacturer := strings.TrimSpace(parts[3]) + if strings.ToLower(manufacturer) == "unknown" { + manufacturer = "" + } + + sizeMB := 0 + fmt.Sscanf(strings.TrimSpace(parts[4]), "%d MB", &sizeMB) + + maxSpeedMHz := 0 + fmt.Sscanf(strings.TrimSpace(parts[5]), "%d MT/s", &maxSpeedMHz) + + curSpeedMHz := 0 + fmt.Sscanf(strings.TrimSpace(parts[6]), "%d MT/s", &curSpeedMHz) + + memType := strings.TrimSpace(parts[7]) + if strings.ToLower(memType) == "unknown" { + memType = "" + } + + ranks := 0 + if len(parts) >= 11 { + fmt.Sscanf(strings.TrimSpace(parts[10]), "%d rank", &ranks) + } + + partNum := "" + if len(parts) >= 15 { + v := strings.TrimSpace(parts[14]) + if strings.ToLower(v) != "no dimm" && strings.ToLower(v) != "unknown" { + partNum = v + } + } + + status := "ok" + if len(parts) >= 22 { + if s := strings.TrimSpace(parts[21]); strings.ToLower(s) != "ok" && s != "" { + status = strings.ToLower(s) + } + } + + dimms = append(dimms, models.MemoryDIMM{ + Slot: slot, + Location: location, + Present: true, + SizeMB: sizeMB, + Type: memType, + MaxSpeedMHz: maxSpeedMHz, + CurrentSpeedMHz: curSpeedMHz, + Manufacturer: manufacturer, + SerialNumber: sn, + PartNumber: partNum, + Ranks: ranks, + Status: status, + }) + } + return dimms +} + +// ── Card Info (GPU + NIC) ───────────────────────────────────────────────────── + +// parseCardInfo parses card_info file, extracting GPU and NIC entries. +// The file has named sections ("GPU Card Info", "OCP Card Info", etc.) each with a pipe-table. +func parseCardInfo(content []byte) (gpus []models.GPU, nics []models.NIC) { + sections := splitPipeSections(content) + + // Build BDF and VendorID/DeviceID map from PCIe Card Info: slot → info + type pcieEntry struct { + bdf string + vendorID int + deviceID int + desc string + } + slotPCIe := make(map[string]pcieEntry) + for _, row := range sections["pcie card info"] { + slot := strings.TrimSpace(row["slot"]) + seg := parseHexInt(row["segment number"]) + bus := parseHexInt(row["bus number"]) + dev := parseHexInt(row["device number"]) + fn := parseHexInt(row["function number"]) + slotPCIe[slot] = pcieEntry{ + bdf: fmt.Sprintf("%04x:%02x:%02x.%d", seg, bus, dev, fn), + vendorID: parseHexInt(row["vender id"]), + deviceID: parseHexInt(row["device id"]), + desc: strings.TrimSpace(row["card desc"]), + } + } + + // GPU Card Info: slot, name, manufacturer, serialNum, firmVer, SBE/DBE counts + for _, row := range sections["gpu card info"] { + slot := strings.TrimSpace(row["slot"]) + name := strings.TrimSpace(row["name"]) + manufacturer := strings.TrimSpace(row["manufacturer"]) + serial := strings.TrimSpace(row["serialnum"]) + firmware := strings.TrimSpace(row["firmver"]) + + sbeCount, dbeCount := 0, 0 + fmt.Sscanf(strings.TrimSpace(row["sbe"]), "%d", &sbeCount) + fmt.Sscanf(strings.TrimSpace(row["dbe"]), "%d", &dbeCount) + + pcie := slotPCIe[slot] + gpu := models.GPU{ + Slot: slot, + Model: name, + Manufacturer: manufacturer, + SerialNumber: serial, + Firmware: firmware, + BDF: pcie.bdf, + VendorID: pcie.vendorID, + DeviceID: pcie.deviceID, + Status: "ok", + } + if dbeCount > 0 { + gpu.Status = "warning" + } + gpus = append(gpus, gpu) + } + + // OCP Card Info: NIC cards + for i, row := range sections["ocp card info"] { + desc := strings.TrimSpace(row["card desc"]) + sn := strings.TrimSpace(row["serialnumber"]) + nics = append(nics, models.NIC{ + Name: fmt.Sprintf("OCP%d", i+1), + Model: desc, + SerialNumber: sn, + }) + } + + return gpus, nics +} + +// splitPipeSections parses a multi-section file where each section starts with a +// plain header line (no "|") ending in "Info" or "info", followed by a pipe-table. +// Returns a map from lowercased section name → rows (each row is a map of lowercase header → value). +func splitPipeSections(content []byte) map[string][]map[string]string { + result := make(map[string][]map[string]string) + var sectionName string + var headers []string + + for _, line := range strings.Split(string(content), "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if !strings.Contains(trimmed, "|") { + if strings.HasSuffix(strings.ToLower(trimmed), "info") { + sectionName = strings.ToLower(trimmed) + headers = nil + } + continue + } + parts := strings.Split(line, "|") + if len(parts) < 2 { + continue + } + cols := make([]string, len(parts)) + for i, p := range parts { + cols[i] = strings.TrimSpace(p) + } + if headers == nil { + headers = make([]string, len(cols)) + for i, h := range cols { + headers[i] = strings.ToLower(h) + } + continue + } + row := make(map[string]string, len(headers)) + for i, h := range headers { + if i < len(cols) { + row[h] = cols[i] + } + } + result[sectionName] = append(result[sectionName], row) + } + return result +} + +func parseHexInt(s string) int { + s = strings.TrimSpace(s) + s = strings.TrimPrefix(strings.ToLower(s), "0x") + n, _ := strconv.ParseInt(s, 16, 64) + return int(n) +} + +// ── PSU ─────────────────────────────────────────────────────────────────────── + +// parsePSUInfo parses the pipe-delimited psu_info.txt. +// Columns: Slot | presence | Manufacturer | Type | SN | Version | Rated Power | InputMode | PartNum | DeviceName | Vin | ... +func parsePSUInfo(content []byte) []models.PSU { + var psus []models.PSU + var headers []string + + for _, line := range strings.Split(string(content), "\n") { + if !strings.Contains(line, "|") { + continue + } + parts := strings.Split(line, "|") + cols := make([]string, len(parts)) + for i, p := range parts { + cols[i] = strings.TrimSpace(p) + } + if headers == nil { + headers = make([]string, len(cols)) + for i, h := range cols { + headers[i] = strings.ToLower(h) + } + continue + } + row := make(map[string]string, len(headers)) + for i, h := range headers { + if i < len(cols) { + row[h] = cols[i] + } + } + if strings.ToLower(row["presence"]) != "present" { + continue + } + + wattage := 0 + fmt.Sscanf(row["rated power"], "%d", &wattage) + + inputVoltage := 0.0 + fmt.Sscanf(row["vin"], "%f", &inputVoltage) + + psus = append(psus, models.PSU{ + Slot: row["slot"], + Present: true, + Model: row["type"], + Vendor: row["manufacturer"], + SerialNumber: row["sn"], + PartNumber: row["partnum"], + Firmware: row["version"], + WattageW: wattage, + InputType: row["inputmode"], + InputVoltage: inputVoltage, + Status: "ok", + }) + } + return psus +} + +// ── Storage ─────────────────────────────────────────────────────────────────── + +// parseStorageControllerInfo parses RAID_Controller_Info.txt and adds firmware entries. +func parseStorageControllerInfo(content []byte, result *models.AnalysisResult) { + // File may contain multiple controller blocks; parse key:value pairs from each. + // We only look at the first occurrence of each key (first controller). + text := string(content) + blocks := strings.Split(text, "RAID Controller #") + for _, block := range blocks[1:] { // skip pre-block preamble + fields := parseKeyValueBlock([]byte(block)) + name := firstNonEmpty(fields["Component Name"], fields["Controller Name"], fields["Controller Type"]) + firmware := fields["Firmware Version"] + if name != "" && firmware != "" { + result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{ + DeviceName: name, + Description: fields["Controller Name"], + Version: firmware, + }) + } + } +} + +// parseDiskInfo parses a single PhysicalDrivesInfo/DiskN/disk_info file. +func parseDiskInfo(content []byte) *models.Storage { + fields := parseKeyValueBlock(content) + + model := fields["Model"] + sn := fields["Serial Number"] + if model == "" && sn == "" { + return nil + } + + sizeGB := 0 + var capFloat float64 + if _, err := fmt.Sscanf(fields["Capacity"], "%f GB", &capFloat); err == nil { + sizeGB = int(capFloat) + } + + var wearPct *int + if wearStr := fields["Remnant Media Wearout"]; wearStr != "" { + var pct int + if _, err := fmt.Sscanf(wearStr, "%d%%", &pct); err == nil { + wearPct = &pct + } + } + + status := "ok" + if h := strings.ToLower(fields["Health Status"]); h != "" && h != "normal" { + status = h + } + + return &models.Storage{ + Slot: firstNonEmpty(fields["Device Name"], fields["ID"]), + Type: fields["Media Type"], + Model: model, + SizeGB: sizeGB, + SerialNumber: sn, + Manufacturer: fields["Manufacturer"], + Firmware: fields["Firmware Version"], + Interface: fields["Interface Type"], + Present: true, + RemainingEndurancePct: wearPct, + Status: status, + } +} + +// parseKeyValueBlock parses "Key (spaces) : Value" lines from a text block. +func parseKeyValueBlock(content []byte) map[string]string { + result := make(map[string]string) + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "=") || strings.HasPrefix(line, "-") { + continue + } + idx := strings.Index(line, " : ") + if idx < 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+3:]) + if key != "" && result[key] == "" { + result[key] = val + } + } + return result +} + +// ── Events ──────────────────────────────────────────────────────────────────── + +// parseMaintenanceLog parses the iBMC maintenance_log file. +// Line format: "YYYY-MM-DD HH:MM:SS LEVEL : CODE,description" +func parseMaintenanceLog(content []byte) []models.Event { + var events []models.Event + for _, line := range strings.Split(string(content), "\n") { + line = strings.TrimSpace(line) + if len(line) < 20 { + continue + } + ts, err := time.Parse("2006-01-02 15:04:05", line[:19]) + if err != nil || ts.Year() <= 1970 { + continue // skip epoch-0 boot artifacts + } + + rest := strings.TrimSpace(line[19:]) + sepIdx := strings.Index(rest, " : ") + if sepIdx < 0 { + sepIdx = strings.Index(rest, ": ") + if sepIdx < 0 { + continue + } + } else { + sepIdx++ // skip leading space for " : " + } + + levelStr := strings.TrimSpace(rest[:sepIdx-1]) + body := strings.TrimSpace(rest[sepIdx+2:]) + + code := body + description := "" + if ci := strings.Index(body, ","); ci >= 0 { + code = body[:ci] + description = strings.TrimSpace(body[ci+1:]) + } + + var severity models.Severity + switch strings.ToUpper(levelStr) { + case "WARN", "WARNING": + severity = models.SeverityWarning + case "ERROR", "ERR", "CRIT", "CRITICAL": + severity = models.SeverityCritical + default: + severity = models.SeverityInfo + } + + events = append(events, models.Event{ + Timestamp: ts, + Source: "ibmc", + EventType: code, + Severity: severity, + Description: description, + RawData: line, + }) + } + return events +} + +// ── unused import guard ─────────────────────────────────────────────────────── +var _ = parser.ExtractedFile{} diff --git a/internal/parser/vendors/xfusion/parser.go b/internal/parser/vendors/xfusion/parser.go new file mode 100644 index 0000000..52fde15 --- /dev/null +++ b/internal/parser/vendors/xfusion/parser.go @@ -0,0 +1,126 @@ +// Package xfusion provides parser for xFusion iBMC diagnostic dump archives. +// Tested with: xFusion G5500 V7 iBMC dump (tar.gz format, exported via iBMC UI) +// +// Archive structure: dump_info/AppDump/... and dump_info/LogDump/... +// +// IMPORTANT: Increment parserVersion when modifying parser logic! +package xfusion + +import ( + "strings" + + "git.mchus.pro/mchus/logpile/internal/models" + "git.mchus.pro/mchus/logpile/internal/parser" +) + +const parserVersion = "1.0" + +func init() { + parser.Register(&Parser{}) +} + +// Parser implements VendorParser for xFusion iBMC dump archives. +type Parser struct{} + +func (p *Parser) Name() string { return "xFusion iBMC Dump Parser" } +func (p *Parser) Vendor() string { return "xfusion" } +func (p *Parser) Version() string { return parserVersion } + +// Detect checks if files match the xFusion iBMC dump format. +// Returns confidence score 0-100. +func (p *Parser) Detect(files []parser.ExtractedFile) int { + confidence := 0 + for _, f := range files { + path := strings.ToLower(f.Path) + switch { + case strings.Contains(path, "appdump/frudata/fruinfo.txt"): + confidence += 60 + case strings.Contains(path, "appdump/sensor_alarm/sensor_info.txt"): + confidence += 20 + case strings.Contains(path, "appdump/card_manage/card_info"): + confidence += 20 + } + if confidence >= 100 { + return 100 + } + } + return confidence +} + +// Parse parses xFusion iBMC dump and returns an analysis result. +func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) { + result := &models.AnalysisResult{ + Events: make([]models.Event, 0), + FRU: make([]models.FRUInfo, 0), + Sensors: make([]models.SensorReading, 0), + Hardware: &models.HardwareConfig{ + CPUs: make([]models.CPU, 0), + Memory: make([]models.MemoryDIMM, 0), + Storage: make([]models.Storage, 0), + GPUs: make([]models.GPU, 0), + NetworkCards: make([]models.NIC, 0), + PowerSupply: make([]models.PSU, 0), + Firmware: make([]models.FirmwareInfo, 0), + }, + } + + if f := findByPath(files, "appdump/frudata/fruinfo.txt"); f != nil { + parseFRUInfo(f.Content, result) + } + if f := findByPath(files, "appdump/sensor_alarm/sensor_info.txt"); f != nil { + result.Sensors = parseSensorInfo(f.Content) + } + if f := findByPath(files, "appdump/cpumem/cpu_info"); f != nil { + result.Hardware.CPUs = parseCPUInfo(f.Content) + } + if f := findByPath(files, "appdump/cpumem/mem_info"); f != nil { + result.Hardware.Memory = parseMemInfo(f.Content) + } + if f := findByPath(files, "appdump/card_manage/card_info"); f != nil { + gpus, nics := parseCardInfo(f.Content) + result.Hardware.GPUs = gpus + result.Hardware.NetworkCards = nics + } + if f := findByPath(files, "appdump/bmc/psu_info.txt"); f != nil { + result.Hardware.PowerSupply = parsePSUInfo(f.Content) + } + if f := findByPath(files, "appdump/storagemgnt/raid_controller_info.txt"); f != nil { + parseStorageControllerInfo(f.Content, result) + } + for _, f := range findDiskInfoFiles(files) { + disk := parseDiskInfo(f.Content) + if disk != nil { + result.Hardware.Storage = append(result.Hardware.Storage, *disk) + } + } + if f := findByPath(files, "logdump/maintenance_log"); f != nil { + result.Events = parseMaintenanceLog(f.Content) + } + + result.Protocol = "ipmi" + result.SourceType = models.SourceTypeArchive + + return result, nil +} + +// findByPath returns the first file whose lowercased path contains the given substring. +func findByPath(files []parser.ExtractedFile, substring string) *parser.ExtractedFile { + for i := range files { + if strings.Contains(strings.ToLower(files[i].Path), substring) { + return &files[i] + } + } + return nil +} + +// findDiskInfoFiles returns all PhysicalDrivesInfo disk_info files. +func findDiskInfoFiles(files []parser.ExtractedFile) []parser.ExtractedFile { + var out []parser.ExtractedFile + for _, f := range files { + path := strings.ToLower(f.Path) + if strings.Contains(path, "physicaldrivesinfo/") && strings.HasSuffix(path, "/disk_info") { + out = append(out, f) + } + } + return out +} diff --git a/internal/parser/vendors/xfusion/parser_test.go b/internal/parser/vendors/xfusion/parser_test.go new file mode 100644 index 0000000..915d7a3 --- /dev/null +++ b/internal/parser/vendors/xfusion/parser_test.go @@ -0,0 +1,219 @@ +package xfusion + +import ( + "testing" + + "git.mchus.pro/mchus/logpile/internal/parser" +) + +// loadTestArchive extracts the given archive path for use in tests. +// Skips the test if the file is not found (CI environments without testdata). +func loadTestArchive(t *testing.T, path string) []parser.ExtractedFile { + t.Helper() + files, err := parser.ExtractArchive(path) + if err != nil { + t.Skipf("cannot load test archive %s: %v", path, err) + } + return files +} + +func TestDetect_G5500V7(t *testing.T) { + files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz") + p := &Parser{} + score := p.Detect(files) + if score < 80 { + t.Fatalf("expected Detect score >= 80, got %d", score) + } +} + +func TestParse_G5500V7_BoardInfo(t *testing.T) { + files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz") + p := &Parser{} + result, err := p.Parse(files) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if result.Hardware == nil { + t.Fatal("Hardware is nil") + } + board := result.Hardware.BoardInfo + if board.SerialNumber != "210619KUGGXGS2000015" { + t.Errorf("BoardInfo.SerialNumber = %q, want 210619KUGGXGS2000015", board.SerialNumber) + } + if board.ProductName != "G5500 V7" { + t.Errorf("BoardInfo.ProductName = %q, want G5500 V7", board.ProductName) + } +} + +func TestParse_G5500V7_CPUs(t *testing.T) { + files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz") + p := &Parser{} + result, err := p.Parse(files) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(result.Hardware.CPUs) != 2 { + t.Fatalf("expected 2 CPUs, got %d", len(result.Hardware.CPUs)) + } + cpu1 := result.Hardware.CPUs[0] + if cpu1.Cores != 32 { + t.Errorf("CPU1 cores = %d, want 32", cpu1.Cores) + } + if cpu1.Threads != 64 { + t.Errorf("CPU1 threads = %d, want 64", cpu1.Threads) + } + if cpu1.SerialNumber == "" { + t.Error("CPU1 SerialNumber is empty") + } +} + +func TestParse_G5500V7_Memory(t *testing.T) { + files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz") + p := &Parser{} + result, err := p.Parse(files) + if err != nil { + t.Fatalf("Parse: %v", err) + } + // Only 2 DIMMs are populated (rest are "NO DIMM") + if len(result.Hardware.Memory) != 2 { + t.Fatalf("expected 2 populated DIMMs, got %d", len(result.Hardware.Memory)) + } + dimm := result.Hardware.Memory[0] + if dimm.SizeMB != 65536 { + t.Errorf("DIMM0 SizeMB = %d, want 65536", dimm.SizeMB) + } + if dimm.Type != "DDR5" { + t.Errorf("DIMM0 Type = %q, want DDR5", dimm.Type) + } +} + +func TestParse_G5500V7_GPUs(t *testing.T) { + files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz") + p := &Parser{} + result, err := p.Parse(files) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(result.Hardware.GPUs) != 8 { + t.Fatalf("expected 8 GPUs, got %d", len(result.Hardware.GPUs)) + } + for _, gpu := range result.Hardware.GPUs { + if gpu.SerialNumber == "" { + t.Errorf("GPU slot %s has empty SerialNumber", gpu.Slot) + } + if gpu.Model == "" { + t.Errorf("GPU slot %s has empty Model", gpu.Slot) + } + if gpu.Firmware == "" { + t.Errorf("GPU slot %s has empty Firmware", gpu.Slot) + } + } +} + +func TestParse_G5500V7_NICs(t *testing.T) { + files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz") + p := &Parser{} + result, err := p.Parse(files) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(result.Hardware.NetworkCards) < 1 { + t.Fatal("expected at least 1 NIC (OCP CX6), got 0") + } + nic := result.Hardware.NetworkCards[0] + if nic.SerialNumber == "" { + t.Errorf("NIC SerialNumber is empty") + } +} + +func TestParse_G5500V7_PSUs(t *testing.T) { + files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz") + p := &Parser{} + result, err := p.Parse(files) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(result.Hardware.PowerSupply) != 4 { + t.Fatalf("expected 4 PSUs, got %d", len(result.Hardware.PowerSupply)) + } + for _, psu := range result.Hardware.PowerSupply { + if psu.WattageW != 3000 { + t.Errorf("PSU slot %s wattage = %d, want 3000", psu.Slot, psu.WattageW) + } + if psu.SerialNumber == "" { + t.Errorf("PSU slot %s has empty SerialNumber", psu.Slot) + } + } +} + +func TestParse_G5500V7_Storage(t *testing.T) { + files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz") + p := &Parser{} + result, err := p.Parse(files) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(result.Hardware.Storage) != 2 { + t.Fatalf("expected 2 storage devices, got %d", len(result.Hardware.Storage)) + } + for _, disk := range result.Hardware.Storage { + if disk.SerialNumber == "" { + t.Errorf("disk slot %s has empty SerialNumber", disk.Slot) + } + if disk.Model == "" { + t.Errorf("disk slot %s has empty Model", disk.Slot) + } + } +} + +func TestParse_G5500V7_Sensors(t *testing.T) { + files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz") + p := &Parser{} + result, err := p.Parse(files) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(result.Sensors) < 20 { + t.Fatalf("expected at least 20 sensors, got %d", len(result.Sensors)) + } +} + +func TestParse_G5500V7_Events(t *testing.T) { + files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz") + p := &Parser{} + result, err := p.Parse(files) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(result.Events) < 5 { + t.Fatalf("expected at least 5 events, got %d", len(result.Events)) + } + // All events should have real timestamps (not epoch 0) + for _, ev := range result.Events { + if ev.Timestamp.Year() <= 1970 { + t.Errorf("event has epoch timestamp: %v %s", ev.Timestamp, ev.Description) + } + } +} + +func TestParse_G5500V7_FRU(t *testing.T) { + files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz") + p := &Parser{} + result, err := p.Parse(files) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if len(result.FRU) < 3 { + t.Fatalf("expected at least 3 FRU entries, got %d", len(result.FRU)) + } + // Check mainboard FRU serial + found := false + for _, f := range result.FRU { + if f.SerialNumber == "210619KUGGXGS2000015" { + found = true + } + } + if !found { + t.Error("mainboard serial 210619KUGGXGS2000015 not found in FRU") + } +}