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:
Vendored
+1
@@ -10,6 +10,7 @@ import (
|
|||||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/nvidia"
|
_ "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/nvidia_bug_report"
|
||||||
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/unraid"
|
_ "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"
|
_ "git.mchus.pro/mchus/logpile/internal/parser/vendors/xigmanas"
|
||||||
|
|
||||||
// Generic fallback parser (must be last for lowest priority)
|
// Generic fallback parser (must be last for lowest priority)
|
||||||
|
|||||||
+669
@@ -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
@@ -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
@@ -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