From 7e9af89c466eeafacb414964526972c5d68aeecb Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Sat, 4 Apr 2026 15:07:10 +0300 Subject: [PATCH] Add xFusion file-export parser support --- bible-local/01-overview.md | 1 + bible-local/06-parsers.md | 25 + bible-local/10-decisions.md | 26 + internal/parser/vendors/xfusion/hardware.go | 450 +++++++++++++++++- internal/parser/vendors/xfusion/parser.go | 57 ++- .../parser/vendors/xfusion/parser_test.go | 113 +++++ .../parser/vendors/xigmanas/parser_test.go | 3 + internal/server/device_repository.go | 6 + internal/server/device_repository_test.go | 35 ++ 9 files changed, 684 insertions(+), 32 deletions(-) diff --git a/bible-local/01-overview.md b/bible-local/01-overview.md index 69dcfee..469af0c 100644 --- a/bible-local/01-overview.md +++ b/bible-local/01-overview.md @@ -34,6 +34,7 @@ All modes converge on the same normalized hardware model and exporter pipeline. - NVIDIA HGX Field Diagnostics - NVIDIA Bug Report - Unraid +- xFusion iBMC dump / file export - XigmaNAS - Generic fallback parser diff --git a/bible-local/06-parsers.md b/bible-local/06-parsers.md index bda6b50..d46f3bb 100644 --- a/bible-local/06-parsers.md +++ b/bible-local/06-parsers.md @@ -58,6 +58,7 @@ When `vendor_id` and `device_id` are known but the model name is missing or gene | `nvidia` | HGX Field Diagnostics | GPU- and fabric-heavy diagnostic input | | `nvidia_bug_report` | `nvidia-bug-report-*.log.gz` | dmidecode, lspci, NVIDIA driver sections | | `unraid` | Unraid diagnostics/log bundles | Server and storage-focused parsing | +| `xfusion` | xFusion iBMC `tar.gz` dump / file export | AppDump + RTOSDump + LogDump merge for hardware and firmware | | `xigmanas` | XigmaNAS plain logs | FreeBSD/NAS-oriented inventory | | `generic` | fallback | Low-confidence text fallback when nothing else matches | @@ -148,6 +149,29 @@ entire internal `zbb` schema. --- +### xFusion iBMC Dump / File Export (`xfusion`) + +**Status:** Ready (v1.1.0). Tested on xFusion G5500 V7 `tar.gz` exports. + +**Archive format:** `tar.gz` dump exported from the iBMC UI, including `AppDump/`, `RTOSDump/`, +and `LogDump/` trees. + +**Detection:** `AppDump/FruData/fruinfo.txt`, `AppDump/card_manage/card_info`, +`RTOSDump/versioninfo/app_revision.txt`, and `LogDump/netcard/netcard_info.txt`. + +**Extracted data (current):** +- Board / FRU inventory from `fruinfo.txt` +- CPU inventory from `CpuMem/cpu_info` +- Memory DIMM inventory from `CpuMem/mem_info` +- GPU inventory from `card_info` +- OCP NIC inventory by merging `card_info` with `LogDump/netcard/netcard_info.txt` +- PSU inventory from `BMC/psu_info.txt` +- Physical storage from `StorageMgnt/PhysicalDrivesInfo/*/disk_info` +- System firmware entries from `RTOSDump/versioninfo/app_revision.txt` +- Maintenance events from `LogDump/maintenance_log` + +--- + ### Generic text fallback (`generic`) **Status:** Ready (v1.0.0). @@ -173,6 +197,7 @@ entire internal `zbb` schema. | NVIDIA HGX Field Diag | `nvidia` | Ready | Various HGX servers | | NVIDIA Bug Report | `nvidia_bug_report` | Ready | H100 systems | | Unraid | `unraid` | Ready | Unraid diagnostics archives | +| xFusion iBMC dump | `xfusion` | Ready | G5500 V7 file-export `tar.gz` bundles | | XigmaNAS | `xigmanas` | Ready | FreeBSD NAS logs | | H3C SDS G5 | `h3c_g5` | Ready | H3C UniServer R4900 G5 SDS archives | | H3C SDS G6 | `h3c_g6` | Ready | H3C UniServer R4700 G6 SDS archives | diff --git a/bible-local/10-decisions.md b/bible-local/10-decisions.md index e559c98..e6094d1 100644 --- a/bible-local/10-decisions.md +++ b/bible-local/10-decisions.md @@ -1094,3 +1094,29 @@ endpoint inventory signal. - Canonical NIC inventory prefers resolved PCI product names over generic Redfish placeholder names. - The raw Redfish snapshot still remains available in `raw_payloads.redfish_tree` for low-level troubleshooting if topology details are ever needed. + +--- + +## ADL-042 — xFusion file-export archives merge AppDump inventory with RTOS/Log snapshots + +**Date:** 2026-04-04 +**Context:** xFusion iBMC `tar.gz` exports expose the base inventory in `AppDump/`, but the most +useful NIC and firmware details live elsewhere: NIC firmware/MAC snapshots in +`LogDump/netcard/netcard_info.txt` and system firmware versions in +`RTOSDump/versioninfo/app_revision.txt`. Parsing only `AppDump/` left xFusion uploads detectable but +incomplete for UI and Reanimator consumers. + +**Decision:** +- Treat xFusion file-export `tar.gz` bundles as a first-class archive parser input. +- Merge OCP NIC identity from `AppDump/card_manage/card_info` with the latest per-slot snapshot + from `LogDump/netcard/netcard_info.txt` to produce `hardware.network_adapters`. +- Import system-level firmware from `RTOSDump/versioninfo/app_revision.txt` into + `hardware.firmware`. +- Allow FRU fallback from `RTOSDump/versioninfo/fruinfo.txt` when `AppDump/FruData/fruinfo.txt` + is absent. + +**Consequences:** +- xFusion uploads now preserve NIC BDF, MAC, firmware, and serial identity in normalized output. +- System firmware such as BIOS and iBMC versions survives xFusion file exports. +- xFusion archives participate more reliably in canonical device/export flows without special UI + cases. diff --git a/internal/parser/vendors/xfusion/hardware.go b/internal/parser/vendors/xfusion/hardware.go index bd28ebf..f6472d4 100644 --- a/internal/parser/vendors/xfusion/hardware.go +++ b/internal/parser/vendors/xfusion/hardware.go @@ -10,6 +10,33 @@ import ( "git.mchus.pro/mchus/logpile/internal/parser" ) +type xfusionNICCard struct { + Slot string + Model string + ProductName string + Vendor string + VendorID int + DeviceID int + BDF string + SerialNumber string + PartNumber string +} + +type xfusionNetcardPort struct { + BDF string + MAC string + ActualMAC string +} + +type xfusionNetcardSnapshot struct { + Timestamp time.Time + Slot string + ProductName string + Manufacturer string + Firmware string + Ports []xfusionNetcardPort +} + // ── FRU ────────────────────────────────────────────────────────────────────── // parseFRUInfo parses fruinfo.txt and populates result.FRU and result.Hardware.BoardInfo. @@ -232,15 +259,15 @@ func parseCPUInfo(content []byte) []models.CPU { } cpus = append(cpus, models.CPU{ - Socket: socketNum, - Model: model, - Cores: cores, - Threads: threads, - L1CacheKB: l1, - L2CacheKB: l2, - L3CacheKB: l3, + Socket: socketNum, + Model: model, + Cores: cores, + Threads: threads, + L1CacheKB: l1, + L2CacheKB: l2, + L3CacheKB: l3, SerialNumber: sn, - Status: "ok", + Status: "ok", }) } return cpus @@ -338,9 +365,9 @@ func parseMemInfo(content []byte) []models.MemoryDIMM { // ── Card Info (GPU + NIC) ───────────────────────────────────────────────────── -// parseCardInfo parses card_info file, extracting GPU and NIC entries. +// parseCardInfo parses card_info file, extracting GPU and OCP NIC card inventory. // 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) { +func parseCardInfo(content []byte) (gpus []models.GPU, nicCards []xfusionNICCard) { sections := splitPipeSections(content) // Build BDF and VendorID/DeviceID map from PCIe Card Info: slot → info @@ -396,17 +423,22 @@ func parseCardInfo(content []byte) (gpus []models.GPU, nics []models.NIC) { } // 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, + for _, row := range sections["ocp card info"] { + slot := strings.TrimSpace(row["slot"]) + pcie := slotPCIe[slot] + nicCards = append(nicCards, xfusionNICCard{ + Slot: slot, + Model: strings.TrimSpace(row["card desc"]), + ProductName: strings.TrimSpace(row["card desc"]), + VendorID: parseHexInt(row["vender id"]), + DeviceID: parseHexInt(row["device id"]), + BDF: pcie.bdf, + SerialNumber: strings.TrimSpace(row["serialnumber"]), + PartNumber: strings.TrimSpace(row["partnum"]), }) } - return gpus, nics + return gpus, nicCards } // splitPipeSections parses a multi-section file where each section starts with a @@ -462,6 +494,301 @@ func parseHexInt(s string) int { return int(n) } +func parseNetcardInfo(content []byte) []xfusionNetcardSnapshot { + if len(content) == 0 { + return nil + } + + var snapshots []xfusionNetcardSnapshot + var current *xfusionNetcardSnapshot + var currentPort *xfusionNetcardPort + + flushPort := func() { + if current == nil || currentPort == nil { + return + } + current.Ports = append(current.Ports, *currentPort) + currentPort = nil + } + flushSnapshot := func() { + if current == nil || !current.hasData() { + return + } + flushPort() + snapshots = append(snapshots, *current) + current = nil + } + + for _, rawLine := range strings.Split(string(content), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" { + flushPort() + continue + } + if ts, ok := parseXFusionUTCTimestamp(line); ok { + if current == nil { + current = &xfusionNetcardSnapshot{Timestamp: ts} + continue + } + if current.hasData() { + flushSnapshot() + current = &xfusionNetcardSnapshot{Timestamp: ts} + continue + } + current.Timestamp = ts + continue + } + if current == nil { + current = &xfusionNetcardSnapshot{} + } + if port := parseNetcardPortHeader(line); port != nil { + flushPort() + currentPort = port + continue + } + if currentPort != nil { + if value, ok := parseSimpleKV(line, "MacAddr"); ok { + currentPort.MAC = value + continue + } + if value, ok := parseSimpleKV(line, "ActualMac"); ok { + currentPort.ActualMAC = value + continue + } + } + if value, ok := parseSimpleKV(line, "ProductName"); ok { + current.ProductName = value + continue + } + if value, ok := parseSimpleKV(line, "Manufacture"); ok { + current.Manufacturer = value + continue + } + if value, ok := parseSimpleKV(line, "FirmwareVersion"); ok { + current.Firmware = value + continue + } + if value, ok := parseSimpleKV(line, "SlotId"); ok { + current.Slot = value + } + } + flushSnapshot() + + bestIndexBySlot := make(map[string]int) + for i, snapshot := range snapshots { + slot := strings.TrimSpace(snapshot.Slot) + if slot == "" { + continue + } + prevIdx, exists := bestIndexBySlot[slot] + if !exists || snapshot.isBetterThan(snapshots[prevIdx]) { + bestIndexBySlot[slot] = i + } + } + + ordered := make([]xfusionNetcardSnapshot, 0, len(bestIndexBySlot)) + for i, snapshot := range snapshots { + slot := strings.TrimSpace(snapshot.Slot) + bestIdx, ok := bestIndexBySlot[slot] + if !ok || bestIdx != i { + continue + } + ordered = append(ordered, snapshot) + delete(bestIndexBySlot, slot) + } + return ordered +} + +func mergeNetworkAdapters(cards []xfusionNICCard, snapshots []xfusionNetcardSnapshot) ([]models.NetworkAdapter, []models.NIC) { + bySlotCard := make(map[string]xfusionNICCard, len(cards)) + bySlotSnapshot := make(map[string]xfusionNetcardSnapshot, len(snapshots)) + orderedSlots := make([]string, 0, len(cards)+len(snapshots)) + seenSlots := make(map[string]struct{}, len(cards)+len(snapshots)) + + for _, card := range cards { + slot := strings.TrimSpace(card.Slot) + if slot == "" { + continue + } + bySlotCard[slot] = card + if _, seen := seenSlots[slot]; !seen { + orderedSlots = append(orderedSlots, slot) + seenSlots[slot] = struct{}{} + } + } + for _, snapshot := range snapshots { + slot := strings.TrimSpace(snapshot.Slot) + if slot == "" { + continue + } + bySlotSnapshot[slot] = snapshot + if _, seen := seenSlots[slot]; !seen { + orderedSlots = append(orderedSlots, slot) + seenSlots[slot] = struct{}{} + } + } + + adapters := make([]models.NetworkAdapter, 0, len(orderedSlots)) + legacyNICs := make([]models.NIC, 0, len(orderedSlots)) + for _, slot := range orderedSlots { + card := bySlotCard[slot] + snapshot := bySlotSnapshot[slot] + + model := firstNonEmpty(card.Model, snapshot.ProductName) + description := "" + if !strings.EqualFold(strings.TrimSpace(model), strings.TrimSpace(snapshot.ProductName)) { + description = strings.TrimSpace(snapshot.ProductName) + } + macs := snapshot.macAddresses() + bdf := firstNonEmpty(snapshot.primaryBDF(), card.BDF) + firmware := normalizeXFusionValue(snapshot.Firmware) + manufacturer := firstNonEmpty(snapshot.Manufacturer, card.Vendor) + portCount := len(snapshot.Ports) + if portCount == 0 && len(macs) > 0 { + portCount = len(macs) + } + if portCount == 0 { + portCount = 1 + } + + adapters = append(adapters, models.NetworkAdapter{ + Slot: slot, + Location: "OCP", + Present: true, + BDF: bdf, + Model: model, + Description: description, + Vendor: manufacturer, + VendorID: card.VendorID, + DeviceID: card.DeviceID, + SerialNumber: card.SerialNumber, + PartNumber: card.PartNumber, + Firmware: firmware, + PortCount: portCount, + PortType: "ethernet", + MACAddresses: macs, + Status: "ok", + }) + legacyNICs = append(legacyNICs, models.NIC{ + Name: fmt.Sprintf("OCP%s", slot), + Model: model, + Description: description, + MACAddress: firstNonEmpty(macs...), + SerialNumber: card.SerialNumber, + }) + } + + return adapters, legacyNICs +} + +func parseXFusionUTCTimestamp(line string) (time.Time, bool) { + ts, err := time.Parse("2006-01-02 15:04:05 MST", strings.TrimSpace(line)) + if err != nil { + return time.Time{}, false + } + return ts, true +} + +func parseNetcardPortHeader(line string) *xfusionNetcardPort { + fields := strings.Fields(strings.TrimSpace(line)) + if len(fields) < 2 || !strings.HasPrefix(strings.ToLower(fields[0]), "port") { + return nil + } + joined := strings.Join(fields[1:], " ") + if !strings.HasPrefix(strings.ToLower(joined), "bdf:") { + return nil + } + return &xfusionNetcardPort{BDF: strings.TrimSpace(joined[len("BDF:"):])} +} + +func parseSimpleKV(line, key string) (string, bool) { + idx := strings.Index(line, ":") + if idx < 0 { + return "", false + } + gotKey := strings.TrimSpace(line[:idx]) + if !strings.EqualFold(gotKey, key) { + return "", false + } + return strings.TrimSpace(line[idx+1:]), true +} + +func normalizeXFusionValue(value string) string { + value = strings.TrimSpace(value) + switch strings.ToUpper(value) { + case "", "N/A", "NA", "UNKNOWN": + return "" + default: + return value + } +} + +func (s xfusionNetcardSnapshot) hasData() bool { + return strings.TrimSpace(s.Slot) != "" || + strings.TrimSpace(s.ProductName) != "" || + strings.TrimSpace(s.Manufacturer) != "" || + strings.TrimSpace(s.Firmware) != "" || + len(s.Ports) > 0 +} + +func (s xfusionNetcardSnapshot) score() int { + score := len(s.Ports) + if normalizeXFusionValue(s.Firmware) != "" { + score += 10 + } + score += len(s.macAddresses()) * 2 + return score +} + +func (s xfusionNetcardSnapshot) isBetterThan(other xfusionNetcardSnapshot) bool { + if s.score() != other.score() { + return s.score() > other.score() + } + if !s.Timestamp.Equal(other.Timestamp) { + return s.Timestamp.After(other.Timestamp) + } + return len(s.Ports) > len(other.Ports) +} + +func (s xfusionNetcardSnapshot) primaryBDF() string { + for _, port := range s.Ports { + if bdf := strings.TrimSpace(port.BDF); bdf != "" { + return bdf + } + } + return "" +} + +func (s xfusionNetcardSnapshot) macAddresses() []string { + out := make([]string, 0, len(s.Ports)) + seen := make(map[string]struct{}, len(s.Ports)) + for _, port := range s.Ports { + for _, candidate := range []string{port.ActualMAC, port.MAC} { + mac := normalizeMAC(candidate) + if mac == "" { + continue + } + if _, exists := seen[mac]; exists { + continue + } + seen[mac] = struct{}{} + out = append(out, mac) + break + } + } + return out +} + +func normalizeMAC(value string) string { + value = strings.ToUpper(strings.TrimSpace(value)) + switch value { + case "", "N/A", "NA", "UNKNOWN", "00:00:00:00:00:00": + return "" + default: + return value + } +} + // ── PSU ─────────────────────────────────────────────────────────────────────── // parsePSUInfo parses the pipe-delimited psu_info.txt. @@ -525,6 +852,11 @@ func parsePSUInfo(content []byte) []models.PSU { 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). + seen := make(map[string]struct{}, len(result.Hardware.Firmware)) + for _, fw := range result.Hardware.Firmware { + key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description)) + seen[key] = struct{}{} + } text := string(content) blocks := strings.Split(text, "RAID Controller #") for _, block := range blocks[1:] { // skip pre-block preamble @@ -532,7 +864,7 @@ func parseStorageControllerInfo(content []byte, result *models.AnalysisResult) { 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{ + appendXFusionFirmware(result, seen, models.FirmwareInfo{ DeviceName: name, Description: fields["Controller Name"], Version: firmware, @@ -541,6 +873,86 @@ func parseStorageControllerInfo(content []byte, result *models.AnalysisResult) { } } +func parseAppRevision(content []byte, result *models.AnalysisResult) { + type firmwareLine struct { + deviceName string + description string + buildKey string + } + + known := map[string]firmwareLine{ + "Active iBMC Version": {deviceName: "iBMC", description: "active iBMC", buildKey: "Active iBMC Built"}, + "Active BIOS Version": {deviceName: "BIOS", description: "active BIOS", buildKey: "Active BIOS Built"}, + "CPLD Version": {deviceName: "CPLD", description: "mainboard CPLD"}, + "SDK Version": {deviceName: "SDK", description: "iBMC SDK", buildKey: "SDK Built"}, + "Active Uboot Version": {deviceName: "U-Boot", description: "active U-Boot"}, + "Active Secure Bootloader Version": {deviceName: "Secure Bootloader", description: "active secure bootloader"}, + "Active Secure Firmware Version": {deviceName: "Secure Firmware", description: "active secure firmware"}, + } + + values := parseAlignedKeyValues(content) + if result.Hardware.BoardInfo.ProductName == "" { + if productName := values["Product Name"]; productName != "" { + result.Hardware.BoardInfo.ProductName = productName + } + } + + seen := make(map[string]struct{}, len(result.Hardware.Firmware)) + for _, fw := range result.Hardware.Firmware { + key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description)) + seen[key] = struct{}{} + } + + for key, meta := range known { + version := normalizeXFusionValue(values[key]) + if version == "" { + continue + } + appendXFusionFirmware(result, seen, models.FirmwareInfo{ + DeviceName: meta.deviceName, + Description: meta.description, + Version: version, + BuildTime: normalizeXFusionValue(values[meta.buildKey]), + }) + } +} + +func parseAlignedKeyValues(content []byte) map[string]string { + values := make(map[string]string) + for _, rawLine := range strings.Split(string(content), "\n") { + line := strings.TrimRight(rawLine, "\r") + if !strings.Contains(line, ":") { + continue + } + idx := strings.Index(line, ":") + if idx < 0 { + continue + } + key := strings.TrimRight(line[:idx], " \t") + value := strings.TrimSpace(line[idx+1:]) + if key == "" || value == "" || values[key] != "" { + continue + } + values[key] = value + } + return values +} + +func appendXFusionFirmware(result *models.AnalysisResult, seen map[string]struct{}, fw models.FirmwareInfo) { + if result == nil || result.Hardware == nil { + return + } + key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description)) + if key == "" { + return + } + if _, exists := seen[key]; exists { + return + } + seen[key] = struct{}{} + result.Hardware.Firmware = append(result.Hardware.Firmware, fw) +} + // parseDiskInfo parses a single PhysicalDrivesInfo/DiskN/disk_info file. func parseDiskInfo(content []byte) *models.Storage { fields := parseKeyValueBlock(content) diff --git a/internal/parser/vendors/xfusion/parser.go b/internal/parser/vendors/xfusion/parser.go index 52fde15..37e5382 100644 --- a/internal/parser/vendors/xfusion/parser.go +++ b/internal/parser/vendors/xfusion/parser.go @@ -13,7 +13,7 @@ import ( "git.mchus.pro/mchus/logpile/internal/parser" ) -const parserVersion = "1.0" +const parserVersion = "1.1" func init() { parser.Register(&Parser{}) @@ -34,11 +34,15 @@ func (p *Parser) Detect(files []parser.ExtractedFile) int { path := strings.ToLower(f.Path) switch { case strings.Contains(path, "appdump/frudata/fruinfo.txt"): - confidence += 60 + confidence += 50 + case strings.Contains(path, "rtosdump/versioninfo/app_revision.txt"): + confidence += 30 case strings.Contains(path, "appdump/sensor_alarm/sensor_info.txt"): - confidence += 20 + confidence += 10 case strings.Contains(path, "appdump/card_manage/card_info"): confidence += 20 + case strings.Contains(path, "logdump/netcard/netcard_info.txt"): + confidence += 20 } if confidence >= 100 { return 100 @@ -54,17 +58,21 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er 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), + Firmware: make([]models.FirmwareInfo, 0), + Devices: make([]models.HardwareDevice, 0), + CPUs: make([]models.CPU, 0), + Memory: make([]models.MemoryDIMM, 0), + Storage: make([]models.Storage, 0), + Volumes: make([]models.StorageVolume, 0), + PCIeDevices: make([]models.PCIeDevice, 0), + GPUs: make([]models.GPU, 0), + NetworkCards: make([]models.NIC, 0), + NetworkAdapters: make([]models.NetworkAdapter, 0), + PowerSupply: make([]models.PSU, 0), }, } - if f := findByPath(files, "appdump/frudata/fruinfo.txt"); f != nil { + if f := findByAnyPath(files, "appdump/frudata/fruinfo.txt", "rtosdump/versioninfo/fruinfo.txt"); f != nil { parseFRUInfo(f.Content, result) } if f := findByPath(files, "appdump/sensor_alarm/sensor_info.txt"); f != nil { @@ -76,10 +84,20 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er if f := findByPath(files, "appdump/cpumem/mem_info"); f != nil { result.Hardware.Memory = parseMemInfo(f.Content) } + var nicCards []xfusionNICCard if f := findByPath(files, "appdump/card_manage/card_info"); f != nil { - gpus, nics := parseCardInfo(f.Content) + gpus, cards := parseCardInfo(f.Content) result.Hardware.GPUs = gpus - result.Hardware.NetworkCards = nics + nicCards = cards + } + if f := findByPath(files, "logdump/netcard/netcard_info.txt"); f != nil || len(nicCards) > 0 { + var content []byte + if f != nil { + content = f.Content + } + adapters, legacyNICs := mergeNetworkAdapters(nicCards, parseNetcardInfo(content)) + result.Hardware.NetworkAdapters = adapters + result.Hardware.NetworkCards = legacyNICs } if f := findByPath(files, "appdump/bmc/psu_info.txt"); f != nil { result.Hardware.PowerSupply = parsePSUInfo(f.Content) @@ -87,6 +105,9 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er if f := findByPath(files, "appdump/storagemgnt/raid_controller_info.txt"); f != nil { parseStorageControllerInfo(f.Content, result) } + if f := findByPath(files, "rtosdump/versioninfo/app_revision.txt"); f != nil { + parseAppRevision(f.Content, result) + } for _, f := range findDiskInfoFiles(files) { disk := parseDiskInfo(f.Content) if disk != nil { @@ -99,6 +120,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er result.Protocol = "ipmi" result.SourceType = models.SourceTypeArchive + parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware) return result, nil } @@ -113,6 +135,15 @@ func findByPath(files []parser.ExtractedFile, substring string) *parser.Extracte return nil } +func findByAnyPath(files []parser.ExtractedFile, substrings ...string) *parser.ExtractedFile { + for _, substring := range substrings { + if f := findByPath(files, substring); f != nil { + return f + } + } + return nil +} + // findDiskInfoFiles returns all PhysicalDrivesInfo disk_info files. func findDiskInfoFiles(files []parser.ExtractedFile) []parser.ExtractedFile { var out []parser.ExtractedFile diff --git a/internal/parser/vendors/xfusion/parser_test.go b/internal/parser/vendors/xfusion/parser_test.go index 915d7a3..4a18e73 100644 --- a/internal/parser/vendors/xfusion/parser_test.go +++ b/internal/parser/vendors/xfusion/parser_test.go @@ -1,8 +1,10 @@ package xfusion import ( + "strings" "testing" + "git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/parser" ) @@ -26,6 +28,29 @@ func TestDetect_G5500V7(t *testing.T) { } } +func TestDetect_ServerFileExportMarkers(t *testing.T) { + p := &Parser{} + score := p.Detect([]parser.ExtractedFile{ + {Path: "dump_info/RTOSDump/versioninfo/app_revision.txt", Content: []byte("Product Name: G5500 V7")}, + {Path: "dump_info/LogDump/netcard/netcard_info.txt", Content: []byte("2026-02-04 03:54:06 UTC")}, + {Path: "dump_info/AppDump/card_manage/card_info", Content: []byte("OCP Card Info")}, + }) + if score < 70 { + t.Fatalf("expected Detect score >= 70 for xFusion file export markers, got %d", score) + } +} + +func TestDetect_Negative(t *testing.T) { + p := &Parser{} + score := p.Detect([]parser.ExtractedFile{ + {Path: "logs/messages.txt", Content: []byte("plain text")}, + {Path: "inventory.json", Content: []byte(`{"vendor":"other"}`)}, + }) + if score != 0 { + t.Fatalf("expected Detect score 0 for non-xFusion input, got %d", score) + } +} + func TestParse_G5500V7_BoardInfo(t *testing.T) { files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz") p := &Parser{} @@ -126,6 +151,94 @@ func TestParse_G5500V7_NICs(t *testing.T) { } } +func TestParse_ServerFileExport_NetworkAdaptersAndFirmware(t *testing.T) { + p := &Parser{} + files := []parser.ExtractedFile{ + { + Path: "dump_info/AppDump/card_manage/card_info", + Content: []byte(strings.TrimSpace(` +Pcie Card Info +Slot | Vender Id | Device Id | Sub Vender Id | Sub Device Id | Segment Number | Bus Number | Device Number | Function Number | Card Desc | Board Id | PCB Version | CPLD Version | Sub Card Bom Id | PartNum | SerialNumber | OriginalPartNum +1 | 0x15b3 | 0x101f | 0x1f24 | 0x2011 | 0x00 | 0x27 | 0x00 | 0x00 | MT2894 Family [ConnectX-6 Lx] | N/A | N/A | N/A | N/A | 0302Y238 | 02Y238X6RC000058 | + +OCP Card Info +Slot | Vender Id | Device Id | Sub Vender Id | Sub Device Id | Segment Number | Bus Number | Device Number | Function Number | Card Desc | Board Id | PCB Version | CPLD Version | Sub Card Bom Id | PartNum | SerialNumber | OriginalPartNum +1 | 0x15b3 | 0x101f | 0x1f24 | 0x2011 | 0x00 | 0x27 | 0x00 | 0x00 | MT2894 Family [ConnectX-6 Lx] | N/A | N/A | N/A | N/A | 0302Y238 | 02Y238X6RC000058 | +`)), + }, + { + Path: "dump_info/LogDump/netcard/netcard_info.txt", + Content: []byte(strings.TrimSpace(` +2026-02-04 03:54:06 UTC +ProductName :XC385 +Manufacture :XFUSION +FirmwareVersion :26.39.2048 +SlotId :1 +Port0 BDF:0000:27:00.0 + MacAddr:44:1A:4C:16:E8:03 + ActualMac:44:1A:4C:16:E8:03 +Port1 BDF:0000:27:00.1 + MacAddr:00:00:00:00:00:00 + ActualMac:44:1A:4C:16:E8:04 +`)), + }, + { + Path: "dump_info/RTOSDump/versioninfo/app_revision.txt", + Content: []byte(strings.TrimSpace(` +------------------- iBMC INFO ------------------- +Active iBMC Version: (U68)3.08.05.85 +Active iBMC Built: 16:46:26 Jan 4 2026 +SDK Version: 13.16.30.16 +SDK Built: 07:55:18 Dec 12 2025 +Active BIOS Version: (U6216)01.02.08.17 +Active BIOS Built: 00:00:00 Jan 05 2026 +Product Name: G5500 V7 +`)), + }, + } + + result, err := p.Parse(files) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if result.Protocol != "ipmi" || result.SourceType != models.SourceTypeArchive { + t.Fatalf("unexpected source metadata: protocol=%q source_type=%q", result.Protocol, result.SourceType) + } + if result.Hardware == nil { + t.Fatal("Hardware is nil") + } + if len(result.Hardware.NetworkAdapters) != 1 { + t.Fatalf("expected 1 network adapter, got %d", len(result.Hardware.NetworkAdapters)) + } + adapter := result.Hardware.NetworkAdapters[0] + if adapter.BDF != "0000:27:00.0" { + t.Fatalf("expected network adapter BDF 0000:27:00.0, got %q", adapter.BDF) + } + if adapter.Firmware != "26.39.2048" { + t.Fatalf("expected network adapter firmware 26.39.2048, got %q", adapter.Firmware) + } + if adapter.SerialNumber != "02Y238X6RC000058" { + t.Fatalf("expected network adapter serial from card_info, got %q", adapter.SerialNumber) + } + if len(adapter.MACAddresses) != 2 || adapter.MACAddresses[0] != "44:1A:4C:16:E8:03" || adapter.MACAddresses[1] != "44:1A:4C:16:E8:04" { + t.Fatalf("unexpected MAC addresses: %#v", adapter.MACAddresses) + } + + fwByDevice := make(map[string]models.FirmwareInfo) + for _, fw := range result.Hardware.Firmware { + fwByDevice[fw.DeviceName] = fw + } + if fwByDevice["iBMC"].Version != "(U68)3.08.05.85" { + t.Fatalf("expected iBMC firmware from app_revision.txt, got %#v", fwByDevice["iBMC"]) + } + if fwByDevice["BIOS"].Version != "(U6216)01.02.08.17" { + t.Fatalf("expected BIOS firmware from app_revision.txt, got %#v", fwByDevice["BIOS"]) + } + if result.Hardware.BoardInfo.ProductName != "G5500 V7" { + t.Fatalf("expected board product fallback from app_revision.txt, got %q", result.Hardware.BoardInfo.ProductName) + } +} + func TestParse_G5500V7_PSUs(t *testing.T) { files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz") p := &Parser{} diff --git a/internal/parser/vendors/xigmanas/parser_test.go b/internal/parser/vendors/xigmanas/parser_test.go index c49541d..d35349c 100644 --- a/internal/parser/vendors/xigmanas/parser_test.go +++ b/internal/parser/vendors/xigmanas/parser_test.go @@ -44,6 +44,9 @@ func TestParserParseExample(t *testing.T) { examplePath := filepath.Join("..", "..", "..", "..", "example", "xigmanas.txt") raw, err := os.ReadFile(examplePath) if err != nil { + if os.IsNotExist(err) { + t.Skipf("example file %s not present", examplePath) + } t.Fatalf("read example file: %v", err) } diff --git a/internal/server/device_repository.go b/internal/server/device_repository.go index 72348f8..d9ede06 100644 --- a/internal/server/device_repository.go +++ b/internal/server/device_repository.go @@ -243,6 +243,7 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice { Source: "network_adapters", Slot: nic.Slot, Location: nic.Location, + BDF: nic.BDF, DeviceClass: "NetworkController", VendorID: nic.VendorID, DeviceID: nic.DeviceID, @@ -254,6 +255,11 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice { PortCount: nic.PortCount, PortType: nic.PortType, MACAddresses: nic.MACAddresses, + LinkWidth: nic.LinkWidth, + LinkSpeed: nic.LinkSpeed, + MaxLinkWidth: nic.MaxLinkWidth, + MaxLinkSpeed: nic.MaxLinkSpeed, + NUMANode: nic.NUMANode, Present: &present, Status: nic.Status, StatusCheckedAt: nic.StatusCheckedAt, diff --git a/internal/server/device_repository_test.go b/internal/server/device_repository_test.go index ff0212a..64c7ec7 100644 --- a/internal/server/device_repository_test.go +++ b/internal/server/device_repository_test.go @@ -122,6 +122,41 @@ func TestBuildHardwareDevices_ZeroSizeMemoryWithInventoryIsIncluded(t *testing.T } } +func TestBuildHardwareDevices_NetworkAdapterPreservesPCIeMetadata(t *testing.T) { + hw := &models.HardwareConfig{ + NetworkAdapters: []models.NetworkAdapter{ + { + Slot: "1", + Location: "OCP", + Present: true, + BDF: "0000:27:00.0", + Model: "ConnectX-6 Lx", + VendorID: 0x15b3, + DeviceID: 0x101f, + SerialNumber: "NIC-001", + Firmware: "26.39.2048", + MACAddresses: []string{"44:1A:4C:16:E8:03", "44:1A:4C:16:E8:04"}, + LinkWidth: 16, + LinkSpeed: "32 GT/s", + NUMANode: 1, + Status: "ok", + }, + }, + } + + devices := BuildHardwareDevices(hw) + for _, d := range devices { + if d.Kind != models.DeviceKindNetwork { + continue + } + if d.BDF != "0000:27:00.0" || d.LinkWidth != 16 || d.LinkSpeed != "32 GT/s" || d.NUMANode != 1 { + t.Fatalf("expected network PCIe metadata to be preserved, got %+v", d) + } + return + } + t.Fatal("expected network device in canonical inventory") +} + func TestBuildSpecification_ZeroSizeMemoryWithInventoryIsShown(t *testing.T) { hw := &models.HardwareConfig{ Memory: []models.MemoryDIMM{