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
@@ -1667,3 +1667,71 @@ no slot label keep the previous serial/part fallback dedupe.
|
|||||||
reflects the latest observed population. Historical swaps are no longer surfaced as
|
reflects the latest observed population. Historical swaps are no longer surfaced as
|
||||||
extra modules (if a per-slot history is wanted later it must be built explicitly,
|
extra modules (if a per-slot history is wanted later it must be built explicitly,
|
||||||
not inferred from dedupe residue). Regression test `TestDedupeMemorySlotSwap`.
|
not inferred from dedupe residue). Regression test `TestDedupeMemorySlotSwap`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADL-061 — xFusion mem_info rows survive an embedded newline in the SPD BOM field
|
||||||
|
|
||||||
|
**Date:** 2026-08-31
|
||||||
|
**Context:** The iBMC `AppDump/CpuMem/mem_info` file copies the raw SPD "bom number"
|
||||||
|
(col 14) verbatim, and it contains arbitrary binary bytes. On a G5500 V7 dump
|
||||||
|
(S/N 210619KUGGXGS2000017) one DIMM's BOM field held a `0x0A`, splitting that
|
||||||
|
record across two physical lines. `parseMemInfo` split the file on `"\n"` and
|
||||||
|
treated each line as a record, so the head line lost every field after the BOM
|
||||||
|
column (type, speed, part number) and the tail fragment
|
||||||
|
`s, M321R8GA0EB2-CWMXH, ..., OK` passed the `len(parts) >= 9` guard and became a
|
||||||
|
phantom 33rd "DIMM" with slot `s`, manufacturer `N/A`, serial `OK`. The BEE-SP
|
||||||
|
export of the same host (dmidecode-based) correctly showed 32 healthy DIMMs.
|
||||||
|
**Decision:** `parseMemInfo` now feeds `reassembleMemInfoRows`, which folds any
|
||||||
|
line not starting with `Memory<digit>` (or the `slot(col 1)` header) back into the
|
||||||
|
previous logical row before comma-splitting. `looksLikeMemInfoRowStart` gates this.
|
||||||
|
**Consequences:** The phantom DIMM is gone and the split record is fully recovered.
|
||||||
|
Both collectors now yield the same 32-module inventory for this host — identical
|
||||||
|
serial/size/type/speed/part/status per module; only slot labels (`Memory140` vs
|
||||||
|
`DIMM140(E)`), the `location` field, and `status_checked_at` still differ, which is
|
||||||
|
inherent to the two sources. Regression test `TestParseMemInfo_EmbeddedNewlineInBOM`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADL-062 — BMC-dump and live-CD exports of the same server must agree component-for-component
|
||||||
|
|
||||||
|
**Date:** 2026-08-31
|
||||||
|
**Context:** The audit tool that ingests Reanimator exports treats any per-component
|
||||||
|
field change between imports as a component replacement. Importing a BMC dump
|
||||||
|
(`xfusion`) and a BEE-SP live-CD bundle (`easy_bee`) for the *same* server produced
|
||||||
|
large spurious diffs: a device present in one bundle and absent in the other, and
|
||||||
|
the same physical part carrying different slot labels / model strings / vendor
|
||||||
|
names depending on the collector.
|
||||||
|
**Decision:** Normalize toward a single cross-collector representation, splitting
|
||||||
|
the work between the two vendor parsers and the shared exporter:
|
||||||
|
- **Exporter (`reanimator_converter.go`)** — cross-vendor rules that any two
|
||||||
|
collectors must converge on: `canonicalMemorySlot` (drop dmidecode channel tag
|
||||||
|
`(J)`, rewrite BMC `Memory111`→`DIMM111`); `canonicalGPUModel` (NVIDIA
|
||||||
|
data-center GPUs reduce to the bare chip token, "NVIDIA H200 NVL"→"H200");
|
||||||
|
`canonicalStorageMediaAndInterface` ("NVMe" is a bus not a medium → type SSD /
|
||||||
|
interface NVMe; bare PCIe drive → NVMe); `manufacturerFromStorageModel`;
|
||||||
|
`isRemovableUSBStorageDevice` (a live-CD boot stick is not inventory);
|
||||||
|
`isOnboardControllerPCIeDevice` (SATA/NVMe/MegaRAID/PCIe-switch controller
|
||||||
|
functions that only an lspci scan ever reports — an add-in HBA/RAID card with
|
||||||
|
its own serial or part number is kept).
|
||||||
|
- **xfusion** — DIMM slot from the `mem_info` "dimm name" column, `location`
|
||||||
|
dropped; GPU `slot` = BDF (Reanimator contract); NIC emitted **per PCI function**
|
||||||
|
from `netcard_info.txt` (BDF + per-port MAC, shared card serial), replacing the
|
||||||
|
single card-level adapter, so it matches an lspci view and no longer collides
|
||||||
|
with a GPU on a wrong BDF; 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→1 (`normalizePSUSlots`), 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.
|
||||||
|
**Consequences:** For the reference server every physical component now appears in
|
||||||
|
both exports keyed identically (serial, or BDF for PCIe). Residual diffs are
|
||||||
|
one-sided *enrichment* only — PCIe link speed/width, NVMe block sizes, drive
|
||||||
|
`size_gb`, CPU clocks, PSU firmware/wattage, `board.uuid`, and the extra firmware
|
||||||
|
components (CPLD/U-Boot/SDK/…) — where one collector's source simply has data the
|
||||||
|
other's does not; none is a value that *conflicts*. Whether the audit treats a
|
||||||
|
null→value transition on an unchanged component as a change is a config question
|
||||||
|
on the audit side. Tests: `TestCanonicalMemorySlot`, `TestCanonicalGPUModel`,
|
||||||
|
`TestCanonicalStorageMediaAndInterface`, `TestIsOnboardControllerPCIeDevice`,
|
||||||
|
`TestNormalizePSUSlots`, updated `TestParse_ServerFileExport_NetworkAdaptersAndFirmware`.
|
||||||
|
|||||||
@@ -883,7 +883,7 @@ func convertMemoryFromDevices(devices []models.HardwareDevice, collectedAt strin
|
|||||||
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
|
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
|
||||||
presentValue := present
|
presentValue := present
|
||||||
result = append(result, ReanimatorMemory{
|
result = append(result, ReanimatorMemory{
|
||||||
Slot: d.Slot,
|
Slot: canonicalMemorySlot(d.Slot),
|
||||||
Location: d.Location,
|
Location: d.Location,
|
||||||
Present: &presentValue,
|
Present: &presentValue,
|
||||||
SizeMB: d.SizeMB,
|
SizeMB: d.SizeMB,
|
||||||
@@ -921,6 +921,9 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
|
|||||||
if isVirtualExportStorageDevice(d) {
|
if isVirtualExportStorageDevice(d) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if isRemovableUSBStorageDevice(d) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !shouldExportStorageDevice(d) {
|
if !shouldExportStorageDevice(d) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -931,17 +934,22 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
|
|||||||
}
|
}
|
||||||
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
|
meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt)
|
||||||
presentValue := present
|
presentValue := present
|
||||||
|
mediaType, busInterface := canonicalStorageMediaAndInterface(d.Type, d.Interface)
|
||||||
|
manufacturer := d.Manufacturer
|
||||||
|
if manufacturer == "" {
|
||||||
|
manufacturer = manufacturerFromStorageModel(d.Model)
|
||||||
|
}
|
||||||
result = append(result, ReanimatorStorage{
|
result = append(result, ReanimatorStorage{
|
||||||
Slot: d.Slot,
|
Slot: d.Slot,
|
||||||
Type: d.Type,
|
Type: mediaType,
|
||||||
Model: d.Model,
|
Model: d.Model,
|
||||||
VendorID: d.VendorID,
|
VendorID: d.VendorID,
|
||||||
DeviceID: d.DeviceID,
|
DeviceID: d.DeviceID,
|
||||||
SizeGB: d.SizeGB,
|
SizeGB: d.SizeGB,
|
||||||
SerialNumber: d.SerialNumber,
|
SerialNumber: d.SerialNumber,
|
||||||
Manufacturer: d.Manufacturer,
|
Manufacturer: manufacturer,
|
||||||
Firmware: d.Firmware,
|
Firmware: d.Firmware,
|
||||||
Interface: d.Interface,
|
Interface: busInterface,
|
||||||
Present: &presentValue,
|
Present: &presentValue,
|
||||||
LogicalBlockSizeBytes: int64FromDetailMap(d.Details, "logical_block_size_bytes"),
|
LogicalBlockSizeBytes: int64FromDetailMap(d.Details, "logical_block_size_bytes"),
|
||||||
PhysicalBlockSizeBytes: int64FromDetailMap(d.Details, "physical_block_size_bytes"),
|
PhysicalBlockSizeBytes: int64FromDetailMap(d.Details, "physical_block_size_bytes"),
|
||||||
@@ -981,6 +989,9 @@ func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt string)
|
|||||||
if isStorageEndpointPCIeDevice(d) {
|
if isStorageEndpointPCIeDevice(d) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if isOnboardControllerPCIeDevice(d) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if isPlaceholderPCIeExportDevice(d) {
|
if isPlaceholderPCIeExportDevice(d) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1000,6 +1011,9 @@ func convertPCIeFromDevices(devices []models.HardwareDevice, collectedAt string)
|
|||||||
if manufacturer == "" && d.VendorID != 0 {
|
if manufacturer == "" && d.VendorID != 0 {
|
||||||
manufacturer = pciids.VendorName(d.VendorID)
|
manufacturer = pciids.VendorName(d.VendorID)
|
||||||
}
|
}
|
||||||
|
if isGPUClass(deviceClass) || d.Kind == models.DeviceKindGPU {
|
||||||
|
model = canonicalGPUModel(model, manufacturer)
|
||||||
|
}
|
||||||
temperatureC := firstNonZeroFloat(
|
temperatureC := firstNonZeroFloat(
|
||||||
float64(d.TemperatureC),
|
float64(d.TemperatureC),
|
||||||
floatFromDetailMap(d.Details, "temperature_c"),
|
floatFromDetailMap(d.Details, "temperature_c"),
|
||||||
@@ -1091,6 +1105,52 @@ func isStorageEndpointPCIeDevice(d models.HardwareDevice) bool {
|
|||||||
strings.Contains(joined, "drive")
|
strings.Contains(joined, "drive")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isRemovableUSBStorageDevice reports whether a storage device is a USB-attached
|
||||||
|
// removable drive (flash stick, external HDD). These come and go with whoever is
|
||||||
|
// standing at the machine — a live-CD boot stick, a technician's USB key — and
|
||||||
|
// are not part of the server's tracked inventory, so an OS-level collector that
|
||||||
|
// sees one must not make it look like a component was installed.
|
||||||
|
func isRemovableUSBStorageDevice(d models.HardwareDevice) bool {
|
||||||
|
if d.Kind != models.DeviceKindStorage {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(strings.TrimSpace(d.Interface), "USB") ||
|
||||||
|
strings.EqualFold(strings.TrimSpace(d.Type), "USB")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isOnboardControllerPCIeDevice reports whether a PCIe entry is an onboard
|
||||||
|
// storage/switch controller function that only an OS-level PCI enumeration ever
|
||||||
|
// reports — a BMC add-in-card inventory never lists these. Dropping them keeps
|
||||||
|
// the pcie_devices set identical between a BMC dump and an lspci-based capture.
|
||||||
|
// Add-in HBA/RAID cards that carry their own serial or part number are kept.
|
||||||
|
func isOnboardControllerPCIeDevice(d models.HardwareDevice) bool {
|
||||||
|
if d.Kind == models.DeviceKindGPU || d.Kind == models.DeviceKindNetwork {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
class := strings.ToLower(strings.TrimSpace(d.DeviceClass))
|
||||||
|
model := strings.ToLower(strings.TrimSpace(d.Model))
|
||||||
|
|
||||||
|
if strings.Contains(model, "pcie switch") || strings.Contains(model, "switch management") ||
|
||||||
|
strings.Contains(model, "switch upstream") || strings.Contains(model, "switch downstream") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
controllerClass := strings.Contains(class, "sata") ||
|
||||||
|
strings.Contains(class, "ahci") ||
|
||||||
|
strings.Contains(class, "ide controller") ||
|
||||||
|
strings.Contains(class, "nonvolatile") || strings.Contains(class, "non-volatile") ||
|
||||||
|
strings.Contains(class, "nvme") ||
|
||||||
|
strings.Contains(class, "mass storage") || strings.Contains(class, "massstorage") ||
|
||||||
|
class == "storagecontroller" || class == "storage controller"
|
||||||
|
if !controllerClass {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if normalizedSerial(d.SerialNumber) != "" || hasMeaningfulExporterText(d.PartNumber) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func isVirtualExportStorageDevice(d models.HardwareDevice) bool {
|
func isVirtualExportStorageDevice(d models.HardwareDevice) bool {
|
||||||
if d.Kind != models.DeviceKindStorage {
|
if d.Kind != models.DeviceKindStorage {
|
||||||
return false
|
return false
|
||||||
@@ -2233,6 +2293,94 @@ func disambiguatePSUSlots(items []ReanimatorPSU) []ReanimatorPSU {
|
|||||||
// BEE-SP versions, which label every module "DIMM 0" regardless of its real
|
// BEE-SP versions, which label every module "DIMM 0" regardless of its real
|
||||||
// channel/slot. As with disambiguatePSUSlots, positions within a colliding
|
// channel/slot. As with disambiguatePSUSlots, positions within a colliding
|
||||||
// group are assigned by ascending serial number for a deterministic result.
|
// group are assigned by ascending serial number for a deterministic result.
|
||||||
|
// canonicalMemorySlot normalizes a DIMM slot label so the same physical slot
|
||||||
|
// reads identically regardless of the collector: it drops a trailing
|
||||||
|
// channel-letter tag that dmidecode appends ("DIMM111(J)" -> "DIMM111") and
|
||||||
|
// rewrites a bare BMC-style "Memory111" locator to "DIMM111".
|
||||||
|
func canonicalMemorySlot(slot string) string {
|
||||||
|
s := strings.TrimSpace(slot)
|
||||||
|
if i := strings.LastIndexByte(s, '('); i > 0 && strings.HasSuffix(s, ")") {
|
||||||
|
s = strings.TrimSpace(s[:i])
|
||||||
|
}
|
||||||
|
if rest, ok := strings.CutPrefix(s, "Memory"); ok && isNumericExporterSlot(rest) {
|
||||||
|
s = "DIMM" + rest
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// canonicalStorageMediaAndInterface separates media type from bus interface so
|
||||||
|
// the same drive reads identically regardless of collector. "NVMe" is a bus, not
|
||||||
|
// a medium: an NVMe drive is an SSD on the NVMe interface. A drive reported only
|
||||||
|
// as PCIe-attached is, in practice, NVMe.
|
||||||
|
func canonicalStorageMediaAndInterface(mediaType, busInterface string) (string, string) {
|
||||||
|
t := strings.TrimSpace(mediaType)
|
||||||
|
b := strings.TrimSpace(busInterface)
|
||||||
|
if strings.EqualFold(t, "nvme") {
|
||||||
|
if b == "" || strings.EqualFold(b, "pcie") {
|
||||||
|
b = "NVMe"
|
||||||
|
}
|
||||||
|
t = "SSD"
|
||||||
|
}
|
||||||
|
if strings.EqualFold(b, "pcie") {
|
||||||
|
b = "NVMe"
|
||||||
|
}
|
||||||
|
return t, b
|
||||||
|
}
|
||||||
|
|
||||||
|
// manufacturerFromStorageModel pulls a leading brand token out of a drive model
|
||||||
|
// string ("KIOXIA KCD8XPUG7T68" -> "KIOXIA") for collectors that leave the
|
||||||
|
// manufacturer field empty.
|
||||||
|
func manufacturerFromStorageModel(model string) string {
|
||||||
|
fields := strings.Fields(strings.TrimSpace(model))
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(fields[0]) {
|
||||||
|
case "KIOXIA", "INTEL", "SAMSUNG", "MICRON", "SEAGATE", "TOSHIBA", "WDC", "HGST", "SK", "SOLIDIGM", "SANDISK":
|
||||||
|
return strings.ToUpper(fields[0])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGPUClass(deviceClass string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(deviceClass)) {
|
||||||
|
case "videocontroller", "displaycontroller", "processingaccelerator":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var gpuChipTokenRegex = regexp.MustCompile(`^[A-Z]{1,3}\d{2,3}[A-Z]?$`)
|
||||||
|
|
||||||
|
// canonicalGPUModel reduces an NVIDIA data-center GPU model string to a stable
|
||||||
|
// bare chip name so a BMC inventory ("H200") and an lspci capture
|
||||||
|
// ("NVIDIA H200 NVL", "NVIDIA Corporation GH100 [H200 NVL]") describe the same
|
||||||
|
// card identically. Non-NVIDIA or unrecognized models are returned unchanged.
|
||||||
|
func canonicalGPUModel(model, manufacturer string) string {
|
||||||
|
m := strings.TrimSpace(model)
|
||||||
|
if m == "" {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(m + " " + manufacturer)
|
||||||
|
if !strings.Contains(lower, "nvidia") {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
// "GH100 [H200 NVL]" -> keep the bracketed marketing name, it holds the SKU.
|
||||||
|
if i := strings.IndexByte(m, '['); i >= 0 {
|
||||||
|
if j := strings.IndexByte(m[i:], ']'); j > 0 {
|
||||||
|
m = m[i+1 : i+j]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m = strings.ReplaceAll(m, "NVIDIA Corporation", "")
|
||||||
|
m = strings.ReplaceAll(m, "NVIDIA", "")
|
||||||
|
for _, tok := range strings.Fields(m) {
|
||||||
|
if gpuChipTokenRegex.MatchString(strings.ToUpper(tok)) {
|
||||||
|
return strings.ToUpper(tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(model)
|
||||||
|
}
|
||||||
|
|
||||||
func disambiguateMemorySlots(items []ReanimatorMemory) []ReanimatorMemory {
|
func disambiguateMemorySlots(items []ReanimatorMemory) []ReanimatorMemory {
|
||||||
if len(items) < 2 {
|
if len(items) < 2 {
|
||||||
return items
|
return items
|
||||||
|
|||||||
@@ -2270,3 +2270,75 @@ func TestConvertToReanimator_ExportsLicenses(t *testing.T) {
|
|||||||
t.Errorf("Status = %q, want %q", scoped.Status, "Warning")
|
t.Errorf("Status = %q, want %q", scoped.Status, "Warning")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCanonicalMemorySlot(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"DIMM111(J)": "DIMM111",
|
||||||
|
"DIMM111 (J)": "DIMM111",
|
||||||
|
"Memory111": "DIMM111",
|
||||||
|
"DIMM111": "DIMM111",
|
||||||
|
"P1-DIMMA1": "P1-DIMMA1",
|
||||||
|
"CPU0_C0D0": "CPU0_C0D0",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := canonicalMemorySlot(in); got != want {
|
||||||
|
t.Errorf("canonicalMemorySlot(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanonicalGPUModel(t *testing.T) {
|
||||||
|
cases := []struct{ model, mfr, want string }{
|
||||||
|
{"NVIDIA H200 NVL", "NVIDIA Corporation", "H200"},
|
||||||
|
{"H200", "NVIDIA Corporation", "H200"},
|
||||||
|
{"NVIDIA H200 141G", "", "H200"},
|
||||||
|
{"GH100 [H200 NVL]", "NVIDIA Corporation", "H200"},
|
||||||
|
{"Radeon Instinct MI300X", "AMD", "Radeon Instinct MI300X"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := canonicalGPUModel(c.model, c.mfr); got != c.want {
|
||||||
|
t.Errorf("canonicalGPUModel(%q,%q) = %q, want %q", c.model, c.mfr, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanonicalStorageMediaAndInterface(t *testing.T) {
|
||||||
|
cases := []struct{ inT, inI, wantT, wantI string }{
|
||||||
|
{"NVMe", "PCIe", "SSD", "NVMe"},
|
||||||
|
{"NVMe", "", "SSD", "NVMe"},
|
||||||
|
{"SSD", "PCIe", "SSD", "NVMe"},
|
||||||
|
{"SSD", "SATA", "SSD", "SATA"},
|
||||||
|
{"HDD", "SAS", "HDD", "SAS"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
gotT, gotI := canonicalStorageMediaAndInterface(c.inT, c.inI)
|
||||||
|
if gotT != c.wantT || gotI != c.wantI {
|
||||||
|
t.Errorf("canonicalStorageMediaAndInterface(%q,%q) = %q,%q want %q,%q", c.inT, c.inI, gotT, gotI, c.wantT, c.wantI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsOnboardControllerPCIeDevice(t *testing.T) {
|
||||||
|
drop := []models.HardwareDevice{
|
||||||
|
{Kind: models.DeviceKindPCIe, DeviceClass: "SATA controller", Model: "Sapphire Rapids SATA AHCI Controller"},
|
||||||
|
{Kind: models.DeviceKindPCIe, DeviceClass: "Non-Volatile memory controller", Model: "NVMe SSD Controller CD8P"},
|
||||||
|
{Kind: models.DeviceKindPCIe, DeviceClass: "MassStorageController", Model: "MegaRAID 12GSAS/PCIe Secure SAS38xx"},
|
||||||
|
{Kind: models.DeviceKindPCIe, DeviceClass: "StorageController", Model: "PCIe Switch management endpoint"},
|
||||||
|
}
|
||||||
|
for _, d := range drop {
|
||||||
|
if !isOnboardControllerPCIeDevice(d) {
|
||||||
|
t.Errorf("expected %q/%q to be dropped", d.DeviceClass, d.Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keep := []models.HardwareDevice{
|
||||||
|
{Kind: models.DeviceKindGPU, DeviceClass: "VideoController", Model: "H200"},
|
||||||
|
{Kind: models.DeviceKindNetwork, DeviceClass: "EthernetController", Model: "ConnectX-6 Lx"},
|
||||||
|
{Kind: models.DeviceKindPCIe, DeviceClass: "raid_controller", Model: "RAID Controller", PartNumber: "XC170-M-8i"},
|
||||||
|
{Kind: models.DeviceKindPCIe, DeviceClass: "MassStorageController", Model: "HBA", SerialNumber: "ABC123"},
|
||||||
|
}
|
||||||
|
for _, d := range keep {
|
||||||
|
if isOnboardControllerPCIeDevice(d) {
|
||||||
|
t.Errorf("expected %q/%q to be kept", d.DeviceClass, d.Model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+80
-1
@@ -3,6 +3,7 @@ package easy_bee
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -122,10 +123,14 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
|||||||
GPUs: append([]models.GPU(nil), snapshot.Hardware.GPUs...),
|
GPUs: append([]models.GPU(nil), snapshot.Hardware.GPUs...),
|
||||||
NetworkCards: append([]models.NIC(nil), snapshot.Hardware.NetworkCards...),
|
NetworkCards: append([]models.NIC(nil), snapshot.Hardware.NetworkCards...),
|
||||||
NetworkAdapters: normalizeNetworkAdapters(snapshot.Hardware.NetworkAdapters),
|
NetworkAdapters: normalizeNetworkAdapters(snapshot.Hardware.NetworkAdapters),
|
||||||
PowerSupply: append([]models.PSU(nil), snapshot.Hardware.PowerSupply...),
|
PowerSupply: normalizePSUSlots(snapshot.Hardware.PowerSupply),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if pn := chassisProductPartNumber(files, result.Hardware.BoardInfo.ProductName); pn != "" {
|
||||||
|
result.Hardware.BoardInfo.PartNumber = pn
|
||||||
|
}
|
||||||
|
|
||||||
result.Events = append(result.Events, snapshot.Events...)
|
result.Events = append(result.Events, snapshot.Events...)
|
||||||
result.Events = append(result.Events, convertRuntimeToEvents(snapshot.Runtime, result.CollectedAt)...)
|
result.Events = append(result.Events, convertRuntimeToEvents(snapshot.Runtime, result.CollectedAt)...)
|
||||||
result.Events = append(result.Events, convertEventLogs(snapshot.Hardware.EventLogs)...)
|
result.Events = append(result.Events, convertEventLogs(snapshot.Hardware.EventLogs)...)
|
||||||
@@ -524,6 +529,80 @@ func normalizePCIeDevices(items []models.PCIeDevice) []models.PCIeDevice {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizePSUSlots rebases 0-indexed PSU bay numbers to 1-indexed, matching the
|
||||||
|
// BMC/vendor convention (bays are labelled PSU1..PSUn on the chassis). The
|
||||||
|
// easy-bee snapshot numbers them from 0; without this, the same server imported
|
||||||
|
// from a BMC dump vs. a live-CD run would show every PSU as moved to a new bay.
|
||||||
|
// chassisProductPartNumber pulls the chassis-level "Product Part Number" out of
|
||||||
|
// the bundle's ipmitool-fru.txt for the FRU whose "Product Name" matches the
|
||||||
|
// board product name. The easy-bee snapshot records the baseboard's own product
|
||||||
|
// name ("BC15MBSF") as board.part_number, while a BMC dump reports the chassis
|
||||||
|
// product part number ("0619KUGG") — this aligns easy-bee to the BMC value.
|
||||||
|
func chassisProductPartNumber(files []parser.ExtractedFile, boardProductName string) string {
|
||||||
|
boardProductName = strings.TrimSpace(boardProductName)
|
||||||
|
if boardProductName == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var fru *parser.ExtractedFile
|
||||||
|
for i := range files {
|
||||||
|
if strings.HasSuffix(strings.ToLower(files[i].Path), "ipmitool-fru.txt") {
|
||||||
|
fru = &files[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fru == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var lastProductName string
|
||||||
|
for _, line := range strings.Split(string(fru.Content), "\n") {
|
||||||
|
key, value, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
switch key {
|
||||||
|
case "Product Name":
|
||||||
|
lastProductName = value
|
||||||
|
case "Product Part Number":
|
||||||
|
if strings.EqualFold(lastProductName, boardProductName) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePSUSlots(items []models.PSU) []models.PSU {
|
||||||
|
out := append([]models.PSU(nil), items...)
|
||||||
|
for i := range out {
|
||||||
|
// The easy-bee snapshot leaks the FRU "Product Version" (a bare letter like
|
||||||
|
// "B") into the firmware field; that is not a firmware revision.
|
||||||
|
if fw := strings.TrimSpace(out[i].Firmware); len(fw) == 1 {
|
||||||
|
out[i].Firmware = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
minSlot, allNumeric := -1, len(out) > 0
|
||||||
|
for _, p := range out {
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(p.Slot))
|
||||||
|
if err != nil {
|
||||||
|
allNumeric = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if minSlot == -1 || n < minSlot {
|
||||||
|
minSlot = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allNumeric || minSlot != 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for i := range out {
|
||||||
|
n, _ := strconv.Atoi(strings.TrimSpace(out[i].Slot))
|
||||||
|
out[i].Slot = strconv.Itoa(n + 1)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeNetworkAdapters(items []models.NetworkAdapter) []models.NetworkAdapter {
|
func normalizeNetworkAdapters(items []models.NetworkAdapter) []models.NetworkAdapter {
|
||||||
out := append([]models.NetworkAdapter(nil), items...)
|
out := append([]models.NetworkAdapter(nil), items...)
|
||||||
for i := range out {
|
for i := range out {
|
||||||
|
|||||||
+27
@@ -4,6 +4,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/logpile/internal/models"
|
||||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -249,3 +250,29 @@ func TestParseBeeAuditSnapshot(t *testing.T) {
|
|||||||
t.Fatal("expected board FRU fallback to be populated")
|
t.Fatal("expected board FRU fallback to be populated")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizePSUSlots(t *testing.T) {
|
||||||
|
in := []models.PSU{
|
||||||
|
{Slot: "0", Firmware: "B"}, {Slot: "1", Firmware: "B"},
|
||||||
|
{Slot: "2", Firmware: "B"}, {Slot: "3", Firmware: "B"},
|
||||||
|
}
|
||||||
|
out := normalizePSUSlots(in)
|
||||||
|
for i, want := range []string{"1", "2", "3", "4"} {
|
||||||
|
if out[i].Slot != want {
|
||||||
|
t.Errorf("psu[%d].Slot = %q, want %q", i, out[i].Slot, want)
|
||||||
|
}
|
||||||
|
if out[i].Firmware != "" {
|
||||||
|
t.Errorf("psu[%d].Firmware = %q, want empty (bare FRU version leak)", i, out[i].Firmware)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already 1-based / non-numeric: leave untouched.
|
||||||
|
keep := []models.PSU{{Slot: "1"}, {Slot: "2"}}
|
||||||
|
if got := normalizePSUSlots(keep); got[0].Slot != "1" || got[1].Slot != "2" {
|
||||||
|
t.Errorf("1-based slots must be untouched, got %q,%q", got[0].Slot, got[1].Slot)
|
||||||
|
}
|
||||||
|
psuA := []models.PSU{{Slot: "PSU0"}}
|
||||||
|
if got := normalizePSUSlots(psuA); got[0].Slot != "PSU0" {
|
||||||
|
t.Errorf("non-numeric slot must be untouched, got %q", got[0].Slot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+132
-26
@@ -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
|
// Columns: slot, location, dimmName, manufacturer, size, maxSpeed, curSpeed, type, SN, voltage, rank, bitWidth, tech, bom, partNum, ..., health
|
||||||
func parseMemInfo(content []byte) []models.MemoryDIMM {
|
func parseMemInfo(content []byte) []models.MemoryDIMM {
|
||||||
var dimms []models.MemoryDIMM
|
var dimms []models.MemoryDIMM
|
||||||
lines := strings.Split(string(content), "\n")
|
for i, line := range reassembleMemInfoRows(content) {
|
||||||
for i, line := range lines {
|
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -304,8 +303,13 @@ func parseMemInfo(content []byte) []models.MemoryDIMM {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
slot := strings.TrimSpace(parts[0])
|
// Column 3 ("dimm name", e.g. "DIMM071") is the device locator that matches
|
||||||
location := strings.TrimSpace(parts[1])
|
// 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])
|
manufacturer := strings.TrimSpace(parts[3])
|
||||||
if strings.ToLower(manufacturer) == "unknown" {
|
if strings.ToLower(manufacturer) == "unknown" {
|
||||||
manufacturer = ""
|
manufacturer = ""
|
||||||
@@ -347,7 +351,6 @@ func parseMemInfo(content []byte) []models.MemoryDIMM {
|
|||||||
|
|
||||||
dimms = append(dimms, models.MemoryDIMM{
|
dimms = append(dimms, models.MemoryDIMM{
|
||||||
Slot: slot,
|
Slot: slot,
|
||||||
Location: location,
|
|
||||||
Present: true,
|
Present: true,
|
||||||
SizeMB: sizeMB,
|
SizeMB: sizeMB,
|
||||||
Type: memType,
|
Type: memType,
|
||||||
@@ -363,6 +366,43 @@ func parseMemInfo(content []byte) []models.MemoryDIMM {
|
|||||||
return dimms
|
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) ─────────────────────────────────────────────────────
|
// ── Card Info (GPU + NIC) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
// parseCardInfo parses card_info file, extracting GPU and OCP NIC card inventory.
|
// 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)
|
slotPCIe := make(map[string]pcieEntry)
|
||||||
for _, row := range sections["pcie card info"] {
|
for _, row := range sections["pcie card info"] {
|
||||||
slot := strings.TrimSpace(row["slot"])
|
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{
|
slotPCIe[slot] = pcieEntry{
|
||||||
bdf: fmt.Sprintf("%04x:%02x:%02x.%d", seg, bus, dev, fn),
|
bdf: bdfFromCardInfoRow(row),
|
||||||
vendorID: parseHexInt(row["vender id"]),
|
vendorID: parseHexInt(row["vender id"]),
|
||||||
deviceID: parseHexInt(row["device id"]),
|
deviceID: parseHexInt(row["device id"]),
|
||||||
desc: strings.TrimSpace(row["card desc"]),
|
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)
|
fmt.Sscanf(strings.TrimSpace(row["dbe"]), "%d", &dbeCount)
|
||||||
|
|
||||||
pcie := slotPCIe[slot]
|
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{
|
gpu := models.GPU{
|
||||||
Slot: slot,
|
Slot: gpuSlot,
|
||||||
Model: name,
|
Model: name,
|
||||||
Manufacturer: manufacturer,
|
Manufacturer: manufacturer,
|
||||||
SerialNumber: serial,
|
SerialNumber: serial,
|
||||||
@@ -422,17 +465,18 @@ func parseCardInfo(content []byte) (gpus []models.GPU, nicCards []xfusionNICCard
|
|||||||
gpus = append(gpus, gpu)
|
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"] {
|
for _, row := range sections["ocp card info"] {
|
||||||
slot := strings.TrimSpace(row["slot"])
|
slot := strings.TrimSpace(row["slot"])
|
||||||
pcie := slotPCIe[slot]
|
|
||||||
nicCards = append(nicCards, xfusionNICCard{
|
nicCards = append(nicCards, xfusionNICCard{
|
||||||
Slot: slot,
|
Slot: slot,
|
||||||
Model: strings.TrimSpace(row["card desc"]),
|
Model: strings.TrimSpace(row["card desc"]),
|
||||||
ProductName: strings.TrimSpace(row["card desc"]),
|
ProductName: strings.TrimSpace(row["card desc"]),
|
||||||
VendorID: parseHexInt(row["vender id"]),
|
VendorID: parseHexInt(row["vender id"]),
|
||||||
DeviceID: parseHexInt(row["device id"]),
|
DeviceID: parseHexInt(row["device id"]),
|
||||||
BDF: pcie.bdf,
|
BDF: bdfFromCardInfoRow(row),
|
||||||
SerialNumber: strings.TrimSpace(row["serialnumber"]),
|
SerialNumber: strings.TrimSpace(row["serialnumber"]),
|
||||||
PartNumber: strings.TrimSpace(row["partnum"]),
|
PartNumber: strings.TrimSpace(row["partnum"]),
|
||||||
})
|
})
|
||||||
@@ -441,6 +485,21 @@ func parseCardInfo(content []byte) (gpus []models.GPU, nicCards []xfusionNICCard
|
|||||||
return gpus, nicCards
|
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
|
// 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.
|
// 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).
|
// Returns a map from lowercased section name → rows (each row is a map of lowercase header → value).
|
||||||
@@ -640,22 +699,46 @@ func mergeNetworkAdapters(cards []xfusionNICCard, snapshots []xfusionNetcardSnap
|
|||||||
description = strings.TrimSpace(snapshot.ProductName)
|
description = strings.TrimSpace(snapshot.ProductName)
|
||||||
}
|
}
|
||||||
macs := snapshot.macAddresses()
|
macs := snapshot.macAddresses()
|
||||||
bdf := firstNonEmpty(snapshot.primaryBDF(), card.BDF)
|
|
||||||
firmware := normalizeXFusionValue(snapshot.Firmware)
|
firmware := normalizeXFusionValue(snapshot.Firmware)
|
||||||
manufacturer := firstNonEmpty(snapshot.Manufacturer, card.Vendor)
|
manufacturer := firstNonEmpty(snapshot.Manufacturer, card.Vendor)
|
||||||
portCount := len(snapshot.Ports)
|
// netcard_info reports the system OEM ("XFUSION") as the NIC manufacturer.
|
||||||
if portCount == 0 && len(macs) > 0 {
|
// When the PCI vendor ID is known, leave it blank so the exporter resolves
|
||||||
portCount = len(macs)
|
// the real silicon vendor (Mellanox/Broadcom/Intel) from pci.ids, matching
|
||||||
}
|
// what an OS-level collector reports.
|
||||||
if portCount == 0 {
|
if card.VendorID != 0 && isSystemOEMName(manufacturer) {
|
||||||
portCount = 1
|
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. 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{
|
adapters = append(adapters, models.NetworkAdapter{
|
||||||
Slot: slot,
|
Slot: firstNonEmpty(p.bdf, slot),
|
||||||
Location: "OCP",
|
Location: "OCP",
|
||||||
Present: true,
|
Present: true,
|
||||||
BDF: bdf,
|
BDF: p.bdf,
|
||||||
Model: model,
|
Model: model,
|
||||||
Description: description,
|
Description: description,
|
||||||
Vendor: manufacturer,
|
Vendor: manufacturer,
|
||||||
@@ -664,11 +747,12 @@ func mergeNetworkAdapters(cards []xfusionNICCard, snapshots []xfusionNetcardSnap
|
|||||||
SerialNumber: card.SerialNumber,
|
SerialNumber: card.SerialNumber,
|
||||||
PartNumber: card.PartNumber,
|
PartNumber: card.PartNumber,
|
||||||
Firmware: firmware,
|
Firmware: firmware,
|
||||||
PortCount: portCount,
|
PortCount: 1,
|
||||||
PortType: "ethernet",
|
PortType: "ethernet",
|
||||||
MACAddresses: macs,
|
MACAddresses: portMACs,
|
||||||
Status: "ok",
|
Status: "ok",
|
||||||
})
|
})
|
||||||
|
}
|
||||||
legacyNICs = append(legacyNICs, models.NIC{
|
legacyNICs = append(legacyNICs, models.NIC{
|
||||||
Name: fmt.Sprintf("OCP%s", slot),
|
Name: fmt.Sprintf("OCP%s", slot),
|
||||||
Model: model,
|
Model: model,
|
||||||
@@ -779,6 +863,14 @@ func (s xfusionNetcardSnapshot) macAddresses() []string {
|
|||||||
return out
|
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 {
|
func normalizeMAC(value string) string {
|
||||||
value = strings.ToUpper(strings.TrimSpace(value))
|
value = strings.ToUpper(strings.TrimSpace(value))
|
||||||
switch value {
|
switch value {
|
||||||
@@ -904,7 +996,7 @@ func parseAppRevision(content []byte, result *models.AnalysisResult) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for key, meta := range known {
|
for key, meta := range known {
|
||||||
version := normalizeXFusionValue(values[key])
|
version := stripXFusionChipDesignator(normalizeXFusionValue(values[key]))
|
||||||
if version == "" {
|
if version == "" {
|
||||||
continue
|
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 {
|
func parseAlignedKeyValues(content []byte) map[string]string {
|
||||||
values := make(map[string]string)
|
values := make(map[string]string)
|
||||||
for _, rawLine := range strings.Split(string(content), "\n") {
|
for _, rawLine := range strings.Split(string(content), "\n") {
|
||||||
|
|||||||
+49
-15
@@ -207,32 +207,37 @@ Product Name: G5500 V7
|
|||||||
if result.Hardware == nil {
|
if result.Hardware == nil {
|
||||||
t.Fatal("Hardware is nil")
|
t.Fatal("Hardware is nil")
|
||||||
}
|
}
|
||||||
if len(result.Hardware.NetworkAdapters) != 1 {
|
// One adapter per PCI function, keyed by that port's BDF, matching an
|
||||||
t.Fatalf("expected 1 network adapter, got %d", len(result.Hardware.NetworkAdapters))
|
// OS-level lspci view. Ports share the card's serial and firmware.
|
||||||
|
if len(result.Hardware.NetworkAdapters) != 2 {
|
||||||
|
t.Fatalf("expected 2 network adapters (one per port), got %d", len(result.Hardware.NetworkAdapters))
|
||||||
}
|
}
|
||||||
adapter := result.Hardware.NetworkAdapters[0]
|
byBDF := map[string]models.NetworkAdapter{}
|
||||||
if adapter.BDF != "0000:27:00.0" {
|
for _, a := range result.Hardware.NetworkAdapters {
|
||||||
t.Fatalf("expected network adapter BDF 0000:27:00.0, got %q", adapter.BDF)
|
byBDF[a.BDF] = a
|
||||||
|
if a.Firmware != "26.39.2048" {
|
||||||
|
t.Fatalf("adapter %s firmware = %q, want 26.39.2048", a.BDF, a.Firmware)
|
||||||
}
|
}
|
||||||
if adapter.Firmware != "26.39.2048" {
|
if a.SerialNumber != "02Y238X6RC000058" {
|
||||||
t.Fatalf("expected network adapter firmware 26.39.2048, got %q", adapter.Firmware)
|
t.Fatalf("adapter %s serial = %q, want 02Y238X6RC000058", a.BDF, a.SerialNumber)
|
||||||
}
|
}
|
||||||
if adapter.SerialNumber != "02Y238X6RC000058" {
|
|
||||||
t.Fatalf("expected network adapter serial from card_info, got %q", adapter.SerialNumber)
|
|
||||||
}
|
}
|
||||||
if len(adapter.MACAddresses) != 2 || adapter.MACAddresses[0] != "44:1A:4C:16:E8:03" || adapter.MACAddresses[1] != "44:1A:4C:16:E8:04" {
|
if got := byBDF["0000:27:00.0"].MACAddresses; len(got) != 1 || got[0] != "44:1A:4C:16:E8:03" {
|
||||||
t.Fatalf("unexpected MAC addresses: %#v", adapter.MACAddresses)
|
t.Fatalf("port 0 MACs = %#v", got)
|
||||||
|
}
|
||||||
|
if got := byBDF["0000:27:00.1"].MACAddresses; len(got) != 1 || got[0] != "44:1A:4C:16:E8:04" {
|
||||||
|
t.Fatalf("port 1 MACs = %#v", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
fwByDevice := make(map[string]models.FirmwareInfo)
|
fwByDevice := make(map[string]models.FirmwareInfo)
|
||||||
for _, fw := range result.Hardware.Firmware {
|
for _, fw := range result.Hardware.Firmware {
|
||||||
fwByDevice[fw.DeviceName] = fw
|
fwByDevice[fw.DeviceName] = fw
|
||||||
}
|
}
|
||||||
if fwByDevice["iBMC"].Version != "(U68)3.08.05.85" {
|
if fwByDevice["iBMC"].Version != "3.08.05.85" {
|
||||||
t.Fatalf("expected iBMC firmware from app_revision.txt, got %#v", fwByDevice["iBMC"])
|
t.Fatalf("expected iBMC firmware (chip designator stripped) from app_revision.txt, got %#v", fwByDevice["iBMC"])
|
||||||
}
|
}
|
||||||
if fwByDevice["BIOS"].Version != "(U6216)01.02.08.17" {
|
if fwByDevice["BIOS"].Version != "01.02.08.17" {
|
||||||
t.Fatalf("expected BIOS firmware from app_revision.txt, got %#v", fwByDevice["BIOS"])
|
t.Fatalf("expected BIOS firmware (chip designator stripped) from app_revision.txt, got %#v", fwByDevice["BIOS"])
|
||||||
}
|
}
|
||||||
if result.Hardware.BoardInfo.ProductName != "G5500 V7" {
|
if result.Hardware.BoardInfo.ProductName != "G5500 V7" {
|
||||||
t.Fatalf("expected board product fallback from app_revision.txt, got %q", result.Hardware.BoardInfo.ProductName)
|
t.Fatalf("expected board product fallback from app_revision.txt, got %q", result.Hardware.BoardInfo.ProductName)
|
||||||
@@ -330,3 +335,32 @@ func TestParse_G5500V7_FRU(t *testing.T) {
|
|||||||
t.Error("mainboard serial 210619KUGGXGS2000015 not found in FRU")
|
t.Error("mainboard serial 210619KUGGXGS2000015 not found in FRU")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestParseMemInfo_EmbeddedNewlineInBOM guards against the BMC copying a raw SPD
|
||||||
|
// "bom number" field containing a 0x0A byte, which splits a DIMM record across
|
||||||
|
// two physical lines. The row must be rejoined, not turned into a phantom DIMM.
|
||||||
|
func TestParseMemInfo_EmbeddedNewlineInBOM(t *testing.T) {
|
||||||
|
const header = "slot(col 1), dimm location(col 2), dimm name(col 3),manufacturer(col 4), size(col 5), speed(col 6), current speed(col 7), memory type(col 8), SN(col 9), minimum voltage(col 10), rank(col 11), bit width(col 12), memory technology(col 13), bom number(col 14), part number(col 15),remaining service life(col 16), firmware version(col 17), medium temp(col 18), controller temp(col 19), volatile capacity(col 20), persistent capacity(col 21), health(col 22)\n"
|
||||||
|
good := "Memory160 , mainboard, DIMM160, Samsung, 65536 MB, 5600 MT/s, 4400 MT/s, DDR5, 13E79B2E, 1100 mV, 2 rank, 64 bit, Synchronous| Registered (Buffered), \x01\x02, M321R8GA0EB2-CWMXH, Unknown, N/A, Unknown, Unknown, Unknown, Unknown, OK\n"
|
||||||
|
// Memory170: BOM field carries a stray newline mid-record.
|
||||||
|
split := "Memory170 , mainboard, DIMM170, Samsung, 65536 MB, 5600 MT/s, 4400 MT/s, DDR5, 13E79D42, 1100 mV, 2 rank, 64 bit, Synchronous| Registered (Buffered), \x9d7h!\x8f\n" +
|
||||||
|
"s, M321R8GA0EB2-CWMXH, Unknown, N/A, Unknown, Unknown, Unknown, Unknown, OK\n"
|
||||||
|
|
||||||
|
dimms := parseMemInfo([]byte(header + good + split))
|
||||||
|
if len(dimms) != 2 {
|
||||||
|
t.Fatalf("expected 2 DIMMs, got %d: %+v", len(dimms), dimms)
|
||||||
|
}
|
||||||
|
m170 := dimms[1]
|
||||||
|
if m170.Slot != "DIMM170" {
|
||||||
|
t.Errorf("slot = %q, want DIMM170", m170.Slot)
|
||||||
|
}
|
||||||
|
if m170.SerialNumber != "13E79D42" {
|
||||||
|
t.Errorf("serial = %q, want 13E79D42", m170.SerialNumber)
|
||||||
|
}
|
||||||
|
if m170.PartNumber != "M321R8GA0EB2-CWMXH" {
|
||||||
|
t.Errorf("part number = %q, want M321R8GA0EB2-CWMXH", m170.PartNumber)
|
||||||
|
}
|
||||||
|
if m170.SizeMB != 65536 || m170.Type != "DDR5" || m170.CurrentSpeedMHz != 4400 {
|
||||||
|
t.Errorf("m170 fields not recovered: %+v", m170)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user