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>
This commit is contained in:
1
internal/parser/vendors/vendors.go
vendored
1
internal/parser/vendors/vendors.go
vendored
@@ -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)
|
||||
|
||||
669
internal/parser/vendors/xfusion/hardware.go
vendored
Normal file
669
internal/parser/vendors/xfusion/hardware.go
vendored
Normal file
@@ -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{}
|
||||
126
internal/parser/vendors/xfusion/parser.go
vendored
Normal file
126
internal/parser/vendors/xfusion/parser.go
vendored
Normal file
@@ -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
|
||||
}
|
||||
219
internal/parser/vendors/xfusion/parser_test.go
vendored
Normal file
219
internal/parser/vendors/xfusion/parser_test.go
vendored
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user