Files
logpile/internal/parser/vendors/xfusion/hardware.go
Mikhail Chusavitin 30409eef67 feat: add xFusion iBMC dump parser (tar.gz format)
Parses xFusion G5500 V7 iBMC diagnostic dump archives with:
- FRU info (board serial, product name, component inventory)
- IPMI sensor readings (temperature, voltage, power, fan, current)
- CPU inventory (model, cores, threads, cache, serial)
- Memory DIMMs (size, speed, type, serial, manufacturer)
- GPU inventory from card_manage/card_info (serial, firmware, ECC counts)
- OCP NIC detection (ConnectX-6 Lx with serial)
- PSU inventory (4x 3000W, serial, firmware, voltage)
- Storage: RAID controller firmware + physical drives (model, serial, endurance)
- iBMC maintenance log events with severity mapping
- Registers as vendor "xfusion" in the parser registry

All 11 fixture tests pass against real G5500 V7 dump archive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 15:31:28 +03:00

670 lines
19 KiB
Go

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