Files
logpile/internal/parser/vendors/xfusion/hardware.go
T
Mikhail ChusavitinandClaude Sonnet 5 8e7f22077d fix(xfusion): emit OCP NIC ports with no serial so reanimator keys them by MAC
Verified against reanimator/core: it keys a NIC component on serial → MAC →
synthetic(slot). A BMC dump carrying the card serial collapses both ports into
one serial-keyed component, while a live-CD bundle (no serial) keeps two
MAC-keyed components, so alternating the two sources produced a spurious
COMPONENT_REMOVED + 2x COMPONENT_INSTALLED every time. Emitting the per-port
NetworkAdapter entries with an empty serial makes both sources key on the port
MAC and yield the same two components. The card serial still reaches the export
via the FRU record and the legacy NIC entry.

Also records ADL-062 findings from reading the ingesting audit tool: it persists
only vendor/model/serial per component (no size/speed/link/clock/wattage), so the
remaining one-sided field diffs between the two bundles cannot generate change
events.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFy4m7cVv4cqp25jJh2gSB
2026-08-31 22:55:45 +03:00

1205 lines
35 KiB
Go

package xfusion
import (
"fmt"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
)
type xfusionNICCard struct {
Slot string
Model string
ProductName string
Vendor string
VendorID int
DeviceID int
BDF string
SerialNumber string
PartNumber string
}
type xfusionNetcardPort struct {
BDF string
MAC string
ActualMAC string
}
type xfusionNetcardSnapshot struct {
Timestamp time.Time
Slot string
ProductName string
Manufacturer string
Firmware string
Ports []xfusionNetcardPort
}
// ── FRU ──────────────────────────────────────────────────────────────────────
// parseFRUInfo parses fruinfo.txt and populates result.FRU and result.Hardware.BoardInfo.
// 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
for i, line := range reassembleMemInfoRows(content) {
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
}
// Column 3 ("dimm name", e.g. "DIMM071") is the device locator that matches
// dmidecode/OS-level collectors; column 1 ("Memory071") is BMC-internal only.
// Column 2 ("mainboard") carries no cross-source signal, so drop it.
slot := strings.TrimSpace(parts[2])
if slot == "" {
slot = strings.TrimSpace(parts[0])
}
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,
Present: true,
SizeMB: sizeMB,
Type: memType,
MaxSpeedMHz: maxSpeedMHz,
CurrentSpeedMHz: curSpeedMHz,
Manufacturer: manufacturer,
SerialNumber: sn,
PartNumber: partNum,
Ranks: ranks,
Status: status,
})
}
return dimms
}
// reassembleMemInfoRows splits mem_info into logical rows, rejoining any line
// that is a continuation of the previous one.
//
// The BMC copies the raw SPD "bom number" field into column 14 verbatim, and it
// contains arbitrary binary bytes — including, occasionally, a 0x0A newline that
// splits a DIMM record across two physical lines. A genuine data row always
// starts with the slot token "Memory<digits>"; any line that does not is a
// tail fragment and is folded back into the row above it.
func reassembleMemInfoRows(content []byte) []string {
physical := strings.Split(string(content), "\n")
var rows []string
for _, raw := range physical {
line := strings.TrimRight(raw, "\r")
if len(rows) > 0 && !looksLikeMemInfoRowStart(line) {
rows[len(rows)-1] += line
continue
}
rows = append(rows, line)
}
return rows
}
// looksLikeMemInfoRowStart reports whether a line begins a new mem_info row,
// i.e. starts with "Memory" followed by a digit (after leading whitespace).
// The header line ("slot(col 1), ...") also returns true so it stays row 0.
func looksLikeMemInfoRowStart(line string) bool {
t := strings.TrimLeft(line, " \t")
if strings.HasPrefix(t, "slot(col 1)") {
return true
}
rest, ok := strings.CutPrefix(t, "Memory")
if !ok || rest == "" {
return false
}
return rest[0] >= '0' && rest[0] <= '9'
}
// ── Card Info (GPU + NIC) ─────────────────────────────────────────────────────
// parseCardInfo parses card_info file, extracting GPU and OCP NIC card inventory.
// The file has named sections ("GPU Card Info", "OCP Card Info", etc.) each with a pipe-table.
func parseCardInfo(content []byte) (gpus []models.GPU, nicCards []xfusionNICCard) {
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"])
slotPCIe[slot] = pcieEntry{
bdf: bdfFromCardInfoRow(row),
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]
// Reanimator contract: pcie_devices.slot is the BDF. Use it when known so
// the GPU matches the same device seen by an OS-level lspci collection;
// fall back to the physical slot label only when no BDF is available.
gpuSlot := slot
if pcie.bdf != "" {
gpuSlot = pcie.bdf
}
gpu := models.GPU{
Slot: gpuSlot,
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. The "Pcie Card Info" table only lists GPU slots,
// so slotPCIe[slot] here would resolve to an unrelated GPU's BDF — build the
// BDF from this row's own segment/bus/device/function columns instead.
for _, row := range sections["ocp card info"] {
slot := strings.TrimSpace(row["slot"])
nicCards = append(nicCards, xfusionNICCard{
Slot: slot,
Model: strings.TrimSpace(row["card desc"]),
ProductName: strings.TrimSpace(row["card desc"]),
VendorID: parseHexInt(row["vender id"]),
DeviceID: parseHexInt(row["device id"]),
BDF: bdfFromCardInfoRow(row),
SerialNumber: strings.TrimSpace(row["serialnumber"]),
PartNumber: strings.TrimSpace(row["partnum"]),
})
}
return gpus, nicCards
}
// bdfFromCardInfoRow builds a "0000:bb:dd.f" BDF from a card_info pipe-table row
// that carries segment/bus/device/function columns. Returns "" if the bus column
// is absent (some sections, e.g. "RAID Card Info", have no PCI address columns).
func bdfFromCardInfoRow(row map[string]string) string {
if strings.TrimSpace(row["bus number"]) == "" {
return ""
}
return fmt.Sprintf("%04x:%02x:%02x.%d",
parseHexInt(row["segment number"]),
parseHexInt(row["bus number"]),
parseHexInt(row["device number"]),
parseHexInt(row["function number"]),
)
}
// 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)
}
func parseNetcardInfo(content []byte) []xfusionNetcardSnapshot {
if len(content) == 0 {
return nil
}
var snapshots []xfusionNetcardSnapshot
var current *xfusionNetcardSnapshot
var currentPort *xfusionNetcardPort
flushPort := func() {
if current == nil || currentPort == nil {
return
}
current.Ports = append(current.Ports, *currentPort)
currentPort = nil
}
flushSnapshot := func() {
if current == nil || !current.hasData() {
return
}
flushPort()
snapshots = append(snapshots, *current)
current = nil
}
for _, rawLine := range strings.Split(string(content), "\n") {
line := strings.TrimSpace(rawLine)
if line == "" {
flushPort()
continue
}
if ts, ok := parseXFusionUTCTimestamp(line); ok {
if current == nil {
current = &xfusionNetcardSnapshot{Timestamp: ts}
continue
}
if current.hasData() {
flushSnapshot()
current = &xfusionNetcardSnapshot{Timestamp: ts}
continue
}
current.Timestamp = ts
continue
}
if current == nil {
current = &xfusionNetcardSnapshot{}
}
if port := parseNetcardPortHeader(line); port != nil {
flushPort()
currentPort = port
continue
}
if currentPort != nil {
if value, ok := parseSimpleKV(line, "MacAddr"); ok {
currentPort.MAC = value
continue
}
if value, ok := parseSimpleKV(line, "ActualMac"); ok {
currentPort.ActualMAC = value
continue
}
}
if value, ok := parseSimpleKV(line, "ProductName"); ok {
current.ProductName = value
continue
}
if value, ok := parseSimpleKV(line, "Manufacture"); ok {
current.Manufacturer = value
continue
}
if value, ok := parseSimpleKV(line, "FirmwareVersion"); ok {
current.Firmware = value
continue
}
if value, ok := parseSimpleKV(line, "SlotId"); ok {
current.Slot = value
}
}
flushSnapshot()
bestIndexBySlot := make(map[string]int)
for i, snapshot := range snapshots {
slot := strings.TrimSpace(snapshot.Slot)
if slot == "" {
continue
}
prevIdx, exists := bestIndexBySlot[slot]
if !exists || snapshot.isBetterThan(snapshots[prevIdx]) {
bestIndexBySlot[slot] = i
}
}
ordered := make([]xfusionNetcardSnapshot, 0, len(bestIndexBySlot))
for i, snapshot := range snapshots {
slot := strings.TrimSpace(snapshot.Slot)
bestIdx, ok := bestIndexBySlot[slot]
if !ok || bestIdx != i {
continue
}
ordered = append(ordered, snapshot)
delete(bestIndexBySlot, slot)
}
return ordered
}
func mergeNetworkAdapters(cards []xfusionNICCard, snapshots []xfusionNetcardSnapshot) ([]models.NetworkAdapter, []models.NIC) {
bySlotCard := make(map[string]xfusionNICCard, len(cards))
bySlotSnapshot := make(map[string]xfusionNetcardSnapshot, len(snapshots))
orderedSlots := make([]string, 0, len(cards)+len(snapshots))
seenSlots := make(map[string]struct{}, len(cards)+len(snapshots))
for _, card := range cards {
slot := strings.TrimSpace(card.Slot)
if slot == "" {
continue
}
bySlotCard[slot] = card
if _, seen := seenSlots[slot]; !seen {
orderedSlots = append(orderedSlots, slot)
seenSlots[slot] = struct{}{}
}
}
for _, snapshot := range snapshots {
slot := strings.TrimSpace(snapshot.Slot)
if slot == "" {
continue
}
bySlotSnapshot[slot] = snapshot
if _, seen := seenSlots[slot]; !seen {
orderedSlots = append(orderedSlots, slot)
seenSlots[slot] = struct{}{}
}
}
adapters := make([]models.NetworkAdapter, 0, len(orderedSlots))
legacyNICs := make([]models.NIC, 0, len(orderedSlots))
for _, slot := range orderedSlots {
card := bySlotCard[slot]
snapshot := bySlotSnapshot[slot]
model := firstNonEmpty(card.Model, snapshot.ProductName)
description := ""
if !strings.EqualFold(strings.TrimSpace(model), strings.TrimSpace(snapshot.ProductName)) {
description = strings.TrimSpace(snapshot.ProductName)
}
macs := snapshot.macAddresses()
firmware := normalizeXFusionValue(snapshot.Firmware)
manufacturer := firstNonEmpty(snapshot.Manufacturer, card.Vendor)
// netcard_info reports the system OEM ("XFUSION") as the NIC manufacturer.
// When the PCI vendor ID is known, leave it blank so the exporter resolves
// the real silicon vendor (Mellanox/Broadcom/Intel) from pci.ids, matching
// what an OS-level collector reports.
if card.VendorID != 0 && isSystemOEMName(manufacturer) {
manufacturer = ""
}
// Emit one adapter per PCI function (port), keyed by that port's own BDF,
// so the card matches an OS-level lspci view of the same NIC. Fall back to
// a single card-level entry only when no per-port BDF is available.
//
// The per-port entries deliberately carry NO serial number. The physical
// card has one serial across both ports; an in-band collector (lspci) sees
// none and identifies each port by its MAC. Reanimator keys a NIC on
// serial → MAC → synthetic(slot): if we emitted the card serial here, its
// two ports would collapse to one component keyed by that serial, while
// the same NIC from a live-CD bundle stays two components keyed by MAC —
// producing a spurious remove+reinstall every time the two sources
// alternate. Leaving serial empty makes both sources key on MAC and agree.
type portEntry struct {
bdf string
mac string
}
var ports []portEntry
for _, p := range snapshot.Ports {
if bdf := strings.TrimSpace(p.BDF); bdf != "" {
ports = append(ports, portEntry{bdf: bdf, mac: firstNonEmpty(normalizeMAC(p.ActualMAC), normalizeMAC(p.MAC))})
}
}
if len(ports) == 0 {
ports = append(ports, portEntry{bdf: card.BDF})
}
for _, p := range ports {
var portMACs []string
if p.mac != "" {
portMACs = []string{p.mac}
} else if len(ports) == 1 {
portMACs = macs
}
// No serial: an lspci-based collector has none, and reanimator keys a
// serial-bearing NIC on the serial (collapsing ports) but a
// serial-less one on the MAC. Keeping this empty makes a BMC dump and
// a live-CD run agree. The card serial still reaches the export via
// this card's FRU record.
serialPerPort := ""
if len(portMACs) == 0 {
serialPerPort = card.SerialNumber
}
adapters = append(adapters, models.NetworkAdapter{
Slot: firstNonEmpty(p.bdf, slot),
Location: "OCP",
Present: true,
BDF: p.bdf,
Model: model,
Description: description,
Vendor: manufacturer,
VendorID: card.VendorID,
DeviceID: card.DeviceID,
SerialNumber: serialPerPort,
PartNumber: card.PartNumber,
Firmware: firmware,
PortCount: 1,
PortType: "ethernet",
MACAddresses: portMACs,
Status: "ok",
})
}
legacyNICs = append(legacyNICs, models.NIC{
Name: fmt.Sprintf("OCP%s", slot),
Model: model,
Description: description,
MACAddress: firstNonEmpty(macs...),
SerialNumber: card.SerialNumber,
})
}
return adapters, legacyNICs
}
func parseXFusionUTCTimestamp(line string) (time.Time, bool) {
ts, err := time.Parse("2006-01-02 15:04:05 MST", strings.TrimSpace(line))
if err != nil {
return time.Time{}, false
}
return ts, true
}
func parseNetcardPortHeader(line string) *xfusionNetcardPort {
fields := strings.Fields(strings.TrimSpace(line))
if len(fields) < 2 || !strings.HasPrefix(strings.ToLower(fields[0]), "port") {
return nil
}
joined := strings.Join(fields[1:], " ")
if !strings.HasPrefix(strings.ToLower(joined), "bdf:") {
return nil
}
return &xfusionNetcardPort{BDF: strings.TrimSpace(joined[len("BDF:"):])}
}
func parseSimpleKV(line, key string) (string, bool) {
idx := strings.Index(line, ":")
if idx < 0 {
return "", false
}
gotKey := strings.TrimSpace(line[:idx])
if !strings.EqualFold(gotKey, key) {
return "", false
}
return strings.TrimSpace(line[idx+1:]), true
}
func normalizeXFusionValue(value string) string {
value = strings.TrimSpace(value)
switch strings.ToUpper(value) {
case "", "N/A", "NA", "UNKNOWN":
return ""
default:
return value
}
}
func (s xfusionNetcardSnapshot) hasData() bool {
return strings.TrimSpace(s.Slot) != "" ||
strings.TrimSpace(s.ProductName) != "" ||
strings.TrimSpace(s.Manufacturer) != "" ||
strings.TrimSpace(s.Firmware) != "" ||
len(s.Ports) > 0
}
func (s xfusionNetcardSnapshot) score() int {
score := len(s.Ports)
if normalizeXFusionValue(s.Firmware) != "" {
score += 10
}
score += len(s.macAddresses()) * 2
return score
}
func (s xfusionNetcardSnapshot) isBetterThan(other xfusionNetcardSnapshot) bool {
if s.score() != other.score() {
return s.score() > other.score()
}
if !s.Timestamp.Equal(other.Timestamp) {
return s.Timestamp.After(other.Timestamp)
}
return len(s.Ports) > len(other.Ports)
}
func (s xfusionNetcardSnapshot) primaryBDF() string {
for _, port := range s.Ports {
if bdf := strings.TrimSpace(port.BDF); bdf != "" {
return bdf
}
}
return ""
}
func (s xfusionNetcardSnapshot) macAddresses() []string {
out := make([]string, 0, len(s.Ports))
seen := make(map[string]struct{}, len(s.Ports))
for _, port := range s.Ports {
for _, candidate := range []string{port.ActualMAC, port.MAC} {
mac := normalizeMAC(candidate)
if mac == "" {
continue
}
if _, exists := seen[mac]; exists {
continue
}
seen[mac] = struct{}{}
out = append(out, mac)
break
}
}
return out
}
func isSystemOEMName(name string) bool {
switch strings.ToLower(strings.TrimSpace(name)) {
case "xfusion", "huawei", "oem", "":
return true
}
return false
}
func normalizeMAC(value string) string {
value = strings.ToUpper(strings.TrimSpace(value))
switch value {
case "", "N/A", "NA", "UNKNOWN", "00:00:00:00:00:00":
return ""
default:
return value
}
}
// ── PSU ───────────────────────────────────────────────────────────────────────
// parsePSUInfo parses the pipe-delimited psu_info.txt.
// 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).
seen := make(map[string]struct{}, len(result.Hardware.Firmware))
for _, fw := range result.Hardware.Firmware {
key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description))
seen[key] = struct{}{}
}
text := string(content)
blocks := strings.Split(text, "RAID Controller #")
for _, block := range blocks[1:] { // skip pre-block preamble
fields := parseKeyValueBlock([]byte(block))
name := firstNonEmpty(fields["Component Name"], fields["Controller Name"], fields["Controller Type"])
firmware := fields["Firmware Version"]
if name != "" && firmware != "" {
appendXFusionFirmware(result, seen, models.FirmwareInfo{
DeviceName: name,
Description: fields["Controller Name"],
Version: firmware,
})
}
}
}
func parseAppRevision(content []byte, result *models.AnalysisResult) {
type firmwareLine struct {
deviceName string
description string
buildKey string
}
known := map[string]firmwareLine{
"Active iBMC Version": {deviceName: "iBMC", description: "active iBMC", buildKey: "Active iBMC Built"},
"Active BIOS Version": {deviceName: "BIOS", description: "active BIOS", buildKey: "Active BIOS Built"},
"CPLD Version": {deviceName: "CPLD", description: "mainboard CPLD"},
"SDK Version": {deviceName: "SDK", description: "iBMC SDK", buildKey: "SDK Built"},
"Active Uboot Version": {deviceName: "U-Boot", description: "active U-Boot"},
"Active Secure Bootloader Version": {deviceName: "Secure Bootloader", description: "active secure bootloader"},
"Active Secure Firmware Version": {deviceName: "Secure Firmware", description: "active secure firmware"},
}
values := parseAlignedKeyValues(content)
if result.Hardware.BoardInfo.ProductName == "" {
if productName := values["Product Name"]; productName != "" {
result.Hardware.BoardInfo.ProductName = productName
}
}
seen := make(map[string]struct{}, len(result.Hardware.Firmware))
for _, fw := range result.Hardware.Firmware {
key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description))
seen[key] = struct{}{}
}
for key, meta := range known {
version := stripXFusionChipDesignator(normalizeXFusionValue(values[key]))
if version == "" {
continue
}
appendXFusionFirmware(result, seen, models.FirmwareInfo{
DeviceName: meta.deviceName,
Description: meta.description,
Version: version,
BuildTime: normalizeXFusionValue(values[meta.buildKey]),
})
}
}
// stripXFusionChipDesignator removes a leading PCB reference-designator tag that
// the iBMC prepends to firmware versions, e.g. "(U6216)01.02.08.17" ->
// "01.02.08.17", so the BIOS/BMC version matches what an in-band tool reports.
func stripXFusionChipDesignator(version string) string {
v := strings.TrimSpace(version)
if !strings.HasPrefix(v, "(U") {
return v
}
if i := strings.IndexByte(v, ')'); i > 0 {
return strings.TrimSpace(v[i+1:])
}
return v
}
func parseAlignedKeyValues(content []byte) map[string]string {
values := make(map[string]string)
for _, rawLine := range strings.Split(string(content), "\n") {
line := strings.TrimRight(rawLine, "\r")
if !strings.Contains(line, ":") {
continue
}
idx := strings.Index(line, ":")
if idx < 0 {
continue
}
key := strings.TrimRight(line[:idx], " \t")
value := strings.TrimSpace(line[idx+1:])
if key == "" || value == "" || values[key] != "" {
continue
}
values[key] = value
}
return values
}
func appendXFusionFirmware(result *models.AnalysisResult, seen map[string]struct{}, fw models.FirmwareInfo) {
if result == nil || result.Hardware == nil {
return
}
key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description))
if key == "" {
return
}
if _, exists := seen[key]; exists {
return
}
seen[key] = struct{}{}
result.Hardware.Firmware = append(result.Hardware.Firmware, fw)
}
// parseDiskInfo parses a single PhysicalDrivesInfo/DiskN/disk_info file.
func parseDiskInfo(content []byte) *models.Storage {
fields := parseKeyValueBlock(content)
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{}