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
1205 lines
35 KiB
Go
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{}
|