fix(parser,exporter): reconcile BMC-dump and live-CD exports of the same server

The audit tool that ingests Reanimator exports treats any per-component field
change between imports as a component replacement. Importing an xFusion BMC dump
and an easy_bee BEE-SP bundle for one server produced large spurious diffs.

xfusion:
- parseMemInfo tolerates a stray 0x0A inside the binary SPD "bom number" column
  (it was splitting a DIMM record in two and emitting a phantom "slot s" module)
- DIMM slot from the "dimm name" column ("DIMM071"), mainboard "location" dropped
- GPU slot = BDF (Reanimator contract)
- NIC emitted per PCI function from netcard_info.txt (BDF + per-port MAC, shared
  card serial) instead of one card-level adapter with the wrong BDF, which had
  been colliding with a GPU and vanishing in dedup
- NIC manufacturer left blank when it is the system OEM so the exporter resolves
  the silicon vendor from pci.ids
- "(U6216)" chip designator stripped from firmware versions

easy_bee:
- PSU bay numbers rebased 0-indexed -> 1-indexed; bare single-letter PSU
  "firmware" (a leaked FRU version) cleared
- board.part_number taken from the bundle's ipmitool-fru.txt chassis
  "Product Part Number" to match the BMC value

exporter (cross-vendor):
- canonicalMemorySlot: drop dmidecode "(J)" channel tag, Memory111 -> DIMM111
- canonicalGPUModel: NVIDIA DC GPUs reduce to the bare chip token
- canonicalStorageMediaAndInterface: NVMe is a bus not a medium
- manufacturerFromStorageModel: fill blank drive vendor from the model prefix
- isRemovableUSBStorageDevice: drop live-CD boot sticks
- isOnboardControllerPCIeDevice: drop SATA/NVMe/MegaRAID/PCIe-switch controller
  functions that only an lspci scan reports (keep add-in cards with an identity)

For the reference server every physical component now appears in both exports
keyed identically; residual diffs are one-sided enrichment only.

Refs ADL-061, ADL-062.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFy4m7cVv4cqp25jJh2gSB
This commit is contained in:
Mikhail Chusavitin
2026-08-31 22:50:07 +03:00
co-authored by Claude Sonnet 5
parent 5b65c99b0e
commit 9b2c654182
7 changed files with 595 additions and 61 deletions
+146 -40
View File
@@ -285,8 +285,7 @@ func parseCacheSizeKB(s string) int {
// 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 {
for i, line := range reassembleMemInfoRows(content) {
if i == 0 {
continue
}
@@ -304,8 +303,13 @@ func parseMemInfo(content []byte) []models.MemoryDIMM {
continue
}
slot := strings.TrimSpace(parts[0])
location := strings.TrimSpace(parts[1])
// 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 = ""
@@ -347,7 +351,6 @@ func parseMemInfo(content []byte) []models.MemoryDIMM {
dimms = append(dimms, models.MemoryDIMM{
Slot: slot,
Location: location,
Present: true,
SizeMB: sizeMB,
Type: memType,
@@ -363,6 +366,43 @@ func parseMemInfo(content []byte) []models.MemoryDIMM {
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.
@@ -380,12 +420,8 @@ func parseCardInfo(content []byte) (gpus []models.GPU, nicCards []xfusionNICCard
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),
bdf: bdfFromCardInfoRow(row),
vendorID: parseHexInt(row["vender id"]),
deviceID: parseHexInt(row["device id"]),
desc: strings.TrimSpace(row["card desc"]),
@@ -405,8 +441,15 @@ func parseCardInfo(content []byte) (gpus []models.GPU, nicCards []xfusionNICCard
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: slot,
Slot: gpuSlot,
Model: name,
Manufacturer: manufacturer,
SerialNumber: serial,
@@ -422,17 +465,18 @@ func parseCardInfo(content []byte) (gpus []models.GPU, nicCards []xfusionNICCard
gpus = append(gpus, gpu)
}
// OCP Card Info: NIC cards
// 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"])
pcie := slotPCIe[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: pcie.bdf,
BDF: bdfFromCardInfoRow(row),
SerialNumber: strings.TrimSpace(row["serialnumber"]),
PartNumber: strings.TrimSpace(row["partnum"]),
})
@@ -441,6 +485,21 @@ func parseCardInfo(content []byte) (gpus []models.GPU, nicCards []xfusionNICCard
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).
@@ -640,35 +699,60 @@ func mergeNetworkAdapters(cards []xfusionNICCard, snapshots []xfusionNetcardSnap
description = strings.TrimSpace(snapshot.ProductName)
}
macs := snapshot.macAddresses()
bdf := firstNonEmpty(snapshot.primaryBDF(), card.BDF)
firmware := normalizeXFusionValue(snapshot.Firmware)
manufacturer := firstNonEmpty(snapshot.Manufacturer, card.Vendor)
portCount := len(snapshot.Ports)
if portCount == 0 && len(macs) > 0 {
portCount = len(macs)
}
if portCount == 0 {
portCount = 1
// 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 = ""
}
adapters = append(adapters, models.NetworkAdapter{
Slot: slot,
Location: "OCP",
Present: true,
BDF: bdf,
Model: model,
Description: description,
Vendor: manufacturer,
VendorID: card.VendorID,
DeviceID: card.DeviceID,
SerialNumber: card.SerialNumber,
PartNumber: card.PartNumber,
Firmware: firmware,
PortCount: portCount,
PortType: "ethernet",
MACAddresses: macs,
Status: "ok",
})
// 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. Ports share
// the physical card's serial/part number. Fall back to a single
// card-level entry only when no per-port BDF is available.
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
}
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: card.SerialNumber,
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,
@@ -779,6 +863,14 @@ func (s xfusionNetcardSnapshot) macAddresses() []string {
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 {
@@ -904,7 +996,7 @@ func parseAppRevision(content []byte, result *models.AnalysisResult) {
}
for key, meta := range known {
version := normalizeXFusionValue(values[key])
version := stripXFusionChipDesignator(normalizeXFusionValue(values[key]))
if version == "" {
continue
}
@@ -917,6 +1009,20 @@ func parseAppRevision(content []byte, result *models.AnalysisResult) {
}
}
// 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") {