Add xFusion file-export parser support

This commit is contained in:
Mikhail Chusavitin
2026-04-04 15:07:10 +03:00
parent db74df9994
commit 2ca83c68b0
9 changed files with 684 additions and 32 deletions

View File

@@ -34,6 +34,7 @@ All modes converge on the same normalized hardware model and exporter pipeline.
- NVIDIA HGX Field Diagnostics - NVIDIA HGX Field Diagnostics
- NVIDIA Bug Report - NVIDIA Bug Report
- Unraid - Unraid
- xFusion iBMC dump / file export
- XigmaNAS - XigmaNAS
- Generic fallback parser - Generic fallback parser

View File

@@ -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` | HGX Field Diagnostics | GPU- and fabric-heavy diagnostic input |
| `nvidia_bug_report` | `nvidia-bug-report-*.log.gz` | dmidecode, lspci, NVIDIA driver sections | | `nvidia_bug_report` | `nvidia-bug-report-*.log.gz` | dmidecode, lspci, NVIDIA driver sections |
| `unraid` | Unraid diagnostics/log bundles | Server and storage-focused parsing | | `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 | | `xigmanas` | XigmaNAS plain logs | FreeBSD/NAS-oriented inventory |
| `generic` | fallback | Low-confidence text fallback when nothing else matches | | `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`) ### Generic text fallback (`generic`)
**Status:** Ready (v1.0.0). **Status:** Ready (v1.0.0).
@@ -173,6 +197,7 @@ entire internal `zbb` schema.
| NVIDIA HGX Field Diag | `nvidia` | Ready | Various HGX servers | | NVIDIA HGX Field Diag | `nvidia` | Ready | Various HGX servers |
| NVIDIA Bug Report | `nvidia_bug_report` | Ready | H100 systems | | NVIDIA Bug Report | `nvidia_bug_report` | Ready | H100 systems |
| Unraid | `unraid` | Ready | Unraid diagnostics archives | | Unraid | `unraid` | Ready | Unraid diagnostics archives |
| xFusion iBMC dump | `xfusion` | Ready | G5500 V7 file-export `tar.gz` bundles |
| XigmaNAS | `xigmanas` | Ready | FreeBSD NAS logs | | XigmaNAS | `xigmanas` | Ready | FreeBSD NAS logs |
| H3C SDS G5 | `h3c_g5` | Ready | H3C UniServer R4900 G5 SDS archives | | H3C SDS G5 | `h3c_g5` | Ready | H3C UniServer R4900 G5 SDS archives |
| H3C SDS G6 | `h3c_g6` | Ready | H3C UniServer R4700 G6 SDS archives | | H3C SDS G6 | `h3c_g6` | Ready | H3C UniServer R4700 G6 SDS archives |

View File

@@ -1094,3 +1094,29 @@ endpoint inventory signal.
- Canonical NIC inventory prefers resolved PCI product names over generic Redfish placeholder names. - 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 - The raw Redfish snapshot still remains available in `raw_payloads.redfish_tree` for low-level
troubleshooting if topology details are ever needed. 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.

View File

@@ -10,6 +10,33 @@ import (
"git.mchus.pro/mchus/logpile/internal/parser" "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 ────────────────────────────────────────────────────────────────────── // ── FRU ──────────────────────────────────────────────────────────────────────
// parseFRUInfo parses fruinfo.txt and populates result.FRU and result.Hardware.BoardInfo. // parseFRUInfo parses fruinfo.txt and populates result.FRU and result.Hardware.BoardInfo.
@@ -338,9 +365,9 @@ func parseMemInfo(content []byte) []models.MemoryDIMM {
// ── Card Info (GPU + NIC) ───────────────────────────────────────────────────── // ── 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. // 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) sections := splitPipeSections(content)
// Build BDF and VendorID/DeviceID map from PCIe Card Info: slot → info // 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 // OCP Card Info: NIC cards
for i, row := range sections["ocp card info"] { for _, row := range sections["ocp card info"] {
desc := strings.TrimSpace(row["card desc"]) slot := strings.TrimSpace(row["slot"])
sn := strings.TrimSpace(row["serialnumber"]) pcie := slotPCIe[slot]
nics = append(nics, models.NIC{ nicCards = append(nicCards, xfusionNICCard{
Name: fmt.Sprintf("OCP%d", i+1), Slot: slot,
Model: desc, Model: strings.TrimSpace(row["card desc"]),
SerialNumber: sn, 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 // splitPipeSections parses a multi-section file where each section starts with a
@@ -462,6 +494,301 @@ func parseHexInt(s string) int {
return int(n) 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 ─────────────────────────────────────────────────────────────────────── // ── PSU ───────────────────────────────────────────────────────────────────────
// parsePSUInfo parses the pipe-delimited psu_info.txt. // 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) { func parseStorageControllerInfo(content []byte, result *models.AnalysisResult) {
// File may contain multiple controller blocks; parse key:value pairs from each. // File may contain multiple controller blocks; parse key:value pairs from each.
// We only look at the first occurrence of each key (first controller). // 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) text := string(content)
blocks := strings.Split(text, "RAID Controller #") blocks := strings.Split(text, "RAID Controller #")
for _, block := range blocks[1:] { // skip pre-block preamble 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"]) name := firstNonEmpty(fields["Component Name"], fields["Controller Name"], fields["Controller Type"])
firmware := fields["Firmware Version"] firmware := fields["Firmware Version"]
if name != "" && firmware != "" { if name != "" && firmware != "" {
result.Hardware.Firmware = append(result.Hardware.Firmware, models.FirmwareInfo{ appendXFusionFirmware(result, seen, models.FirmwareInfo{
DeviceName: name, DeviceName: name,
Description: fields["Controller Name"], Description: fields["Controller Name"],
Version: firmware, 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. // parseDiskInfo parses a single PhysicalDrivesInfo/DiskN/disk_info file.
func parseDiskInfo(content []byte) *models.Storage { func parseDiskInfo(content []byte) *models.Storage {
fields := parseKeyValueBlock(content) fields := parseKeyValueBlock(content)

View File

@@ -13,7 +13,7 @@ import (
"git.mchus.pro/mchus/logpile/internal/parser" "git.mchus.pro/mchus/logpile/internal/parser"
) )
const parserVersion = "1.0" const parserVersion = "1.1"
func init() { func init() {
parser.Register(&Parser{}) parser.Register(&Parser{})
@@ -34,11 +34,15 @@ func (p *Parser) Detect(files []parser.ExtractedFile) int {
path := strings.ToLower(f.Path) path := strings.ToLower(f.Path)
switch { switch {
case strings.Contains(path, "appdump/frudata/fruinfo.txt"): 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"): case strings.Contains(path, "appdump/sensor_alarm/sensor_info.txt"):
confidence += 20 confidence += 10
case strings.Contains(path, "appdump/card_manage/card_info"): case strings.Contains(path, "appdump/card_manage/card_info"):
confidence += 20 confidence += 20
case strings.Contains(path, "logdump/netcard/netcard_info.txt"):
confidence += 20
} }
if confidence >= 100 { if confidence >= 100 {
return 100 return 100
@@ -54,17 +58,21 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
FRU: make([]models.FRUInfo, 0), FRU: make([]models.FRUInfo, 0),
Sensors: make([]models.SensorReading, 0), Sensors: make([]models.SensorReading, 0),
Hardware: &models.HardwareConfig{ Hardware: &models.HardwareConfig{
Firmware: make([]models.FirmwareInfo, 0),
Devices: make([]models.HardwareDevice, 0),
CPUs: make([]models.CPU, 0), CPUs: make([]models.CPU, 0),
Memory: make([]models.MemoryDIMM, 0), Memory: make([]models.MemoryDIMM, 0),
Storage: make([]models.Storage, 0), Storage: make([]models.Storage, 0),
Volumes: make([]models.StorageVolume, 0),
PCIeDevices: make([]models.PCIeDevice, 0),
GPUs: make([]models.GPU, 0), GPUs: make([]models.GPU, 0),
NetworkCards: make([]models.NIC, 0), NetworkCards: make([]models.NIC, 0),
NetworkAdapters: make([]models.NetworkAdapter, 0),
PowerSupply: make([]models.PSU, 0), PowerSupply: make([]models.PSU, 0),
Firmware: make([]models.FirmwareInfo, 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) parseFRUInfo(f.Content, result)
} }
if f := findByPath(files, "appdump/sensor_alarm/sensor_info.txt"); f != nil { 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 { if f := findByPath(files, "appdump/cpumem/mem_info"); f != nil {
result.Hardware.Memory = parseMemInfo(f.Content) result.Hardware.Memory = parseMemInfo(f.Content)
} }
var nicCards []xfusionNICCard
if f := findByPath(files, "appdump/card_manage/card_info"); f != nil { 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.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 { if f := findByPath(files, "appdump/bmc/psu_info.txt"); f != nil {
result.Hardware.PowerSupply = parsePSUInfo(f.Content) 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 { if f := findByPath(files, "appdump/storagemgnt/raid_controller_info.txt"); f != nil {
parseStorageControllerInfo(f.Content, result) parseStorageControllerInfo(f.Content, result)
} }
if f := findByPath(files, "rtosdump/versioninfo/app_revision.txt"); f != nil {
parseAppRevision(f.Content, result)
}
for _, f := range findDiskInfoFiles(files) { for _, f := range findDiskInfoFiles(files) {
disk := parseDiskInfo(f.Content) disk := parseDiskInfo(f.Content)
if disk != nil { if disk != nil {
@@ -99,6 +120,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
result.Protocol = "ipmi" result.Protocol = "ipmi"
result.SourceType = models.SourceTypeArchive result.SourceType = models.SourceTypeArchive
parser.ApplyManufacturedYearWeekFromFRU(result.FRU, result.Hardware)
return result, nil return result, nil
} }
@@ -113,6 +135,15 @@ func findByPath(files []parser.ExtractedFile, substring string) *parser.Extracte
return nil 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. // findDiskInfoFiles returns all PhysicalDrivesInfo disk_info files.
func findDiskInfoFiles(files []parser.ExtractedFile) []parser.ExtractedFile { func findDiskInfoFiles(files []parser.ExtractedFile) []parser.ExtractedFile {
var out []parser.ExtractedFile var out []parser.ExtractedFile

View File

@@ -1,8 +1,10 @@
package xfusion package xfusion
import ( import (
"strings"
"testing" "testing"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser" "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) { func TestParse_G5500V7_BoardInfo(t *testing.T) {
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz") files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
p := &Parser{} 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) { func TestParse_G5500V7_PSUs(t *testing.T) {
files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz") files := loadTestArchive(t, "../../../../example/G5500V7_210619KUGGXGS2000015_20260318-1128.tar.gz")
p := &Parser{} p := &Parser{}

View File

@@ -44,6 +44,9 @@ func TestParserParseExample(t *testing.T) {
examplePath := filepath.Join("..", "..", "..", "..", "example", "xigmanas.txt") examplePath := filepath.Join("..", "..", "..", "..", "example", "xigmanas.txt")
raw, err := os.ReadFile(examplePath) raw, err := os.ReadFile(examplePath)
if err != nil { if err != nil {
if os.IsNotExist(err) {
t.Skipf("example file %s not present", examplePath)
}
t.Fatalf("read example file: %v", err) t.Fatalf("read example file: %v", err)
} }

View File

@@ -243,6 +243,7 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice {
Source: "network_adapters", Source: "network_adapters",
Slot: nic.Slot, Slot: nic.Slot,
Location: nic.Location, Location: nic.Location,
BDF: nic.BDF,
DeviceClass: "NetworkController", DeviceClass: "NetworkController",
VendorID: nic.VendorID, VendorID: nic.VendorID,
DeviceID: nic.DeviceID, DeviceID: nic.DeviceID,
@@ -254,6 +255,11 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice {
PortCount: nic.PortCount, PortCount: nic.PortCount,
PortType: nic.PortType, PortType: nic.PortType,
MACAddresses: nic.MACAddresses, MACAddresses: nic.MACAddresses,
LinkWidth: nic.LinkWidth,
LinkSpeed: nic.LinkSpeed,
MaxLinkWidth: nic.MaxLinkWidth,
MaxLinkSpeed: nic.MaxLinkSpeed,
NUMANode: nic.NUMANode,
Present: &present, Present: &present,
Status: nic.Status, Status: nic.Status,
StatusCheckedAt: nic.StatusCheckedAt, StatusCheckedAt: nic.StatusCheckedAt,

View File

@@ -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) { func TestBuildSpecification_ZeroSizeMemoryWithInventoryIsShown(t *testing.T) {
hw := &models.HardwareConfig{ hw := &models.HardwareConfig{
Memory: []models.MemoryDIMM{ Memory: []models.MemoryDIMM{