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:
co-authored by
Claude Sonnet 5
parent
5b65c99b0e
commit
9b2c654182
+146
-40
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user