From 2fa0f78f9428bc4ea9a2bcf241be94844d01ff57 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Tue, 1 Sep 2026 11:58:55 +0300 Subject: [PATCH] fix(inspur): read RESTful FRU info and float fans_power from component.log Diffing an NF5280M6 BMC dump against its BEE-SP live-CD bundle found two blind spots in the combined-component.log onekeylog layout (no devicefrusdr.log / asset.json): - board manufacturer/product/part/uuid empty and stats.fru 0: the "RESTful FRU info:" JSON block was never parsed. New component_fru.go (ParseComponentLogFRU) flattens it to []models.FRUInfo, prefers the product-area system serial over the board PCB serial, and sets BoardInfo.UUID from system_uuid. Wired as a fallback only when result.FRU is still empty. - zero fan sensors: FanRESTInfo.FansPower was int but this firmware writes "fans_power": 12.000000, so json.Unmarshal of the whole fan block failed. Changed to float64. Also included: SOL smartd SCSI/SAS device-line parsing and diagnose.go gofmt from concurrent work on the same live-CD-diff task. See ADL-064. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011LffAvostt3uMkiUbVUiyM --- bible-local/10-decisions.md | 69 +++++ internal/parser/vendors/inspur/component.go | 293 ++++++++++++++---- .../parser/vendors/inspur/component_fru.go | 114 +++++++ .../vendors/inspur/component_fru_test.go | 61 ++++ .../parser/vendors/inspur/component_test.go | 129 ++++++++ internal/parser/vendors/inspur/diagnose.go | 28 +- internal/parser/vendors/inspur/parser.go | 11 +- internal/parser/vendors/inspur/sol_smartd.go | 111 +++++-- .../parser/vendors/inspur/sol_smartd_test.go | 63 ++++ 9 files changed, 780 insertions(+), 99 deletions(-) create mode 100644 internal/parser/vendors/inspur/component_fru.go create mode 100644 internal/parser/vendors/inspur/component_fru_test.go diff --git a/bible-local/10-decisions.md b/bible-local/10-decisions.md index 9e4cc77..9939792 100644 --- a/bible-local/10-decisions.md +++ b/bible-local/10-decisions.md @@ -1767,3 +1767,72 @@ on every source switch. Fixed by emitting the xFusion NIC as per-port entries components. Net: after this work the only fields that still differ between the two bundles are ones reanimator does not track, so alternating collection methods for one server produces no spurious install/remove/firmware events. + +--- + +## ADL-063 — xFusion disk_info "Capacity" is binary-unit; normalize to decimal GB + +**Date:** 2026-09-01 +**Context:** Diffing a BMC dump (`xfusion`) against the BEE-SP live-CD bundle +(`easy_bee`) for the same G5500 V7 (S/N 210619KUGGXGS2000017) showed every storage +device in the BMC export with `size_gb` absent while the live-CD export had real +sizes (KIOXIA 7681, INTEL 3840). Root cause: `parseDiskInfo` +(`internal/parser/vendors/xfusion/hardware.go`) read capacity with +`fmt.Sscanf(fields["Capacity"], "%f GB", &capFloat)`. The iBMC file writes +`Capacity : 6.986 TB` / `3.492 TB` for NVMe drives, so the literal ` GB` never +matched and `sizeGB` stayed 0. Even for the `446.625 GB` boot-SSD case the old +code truncated the binary GiB value (446) instead of the vendor's decimal spec. +**Decision:** Added `parseDiskCapacityGB`: parse ` ` (TB/GB/MB, +case-insensitive), treat the number as binary (iBMC reports GiB/TiB mislabeled as +GB/TB), convert to decimal GB (`round(value * 2^n / 1e9)`). This matches both the +drive's marketed decimal capacity and the BEE-SP live-CD `size_gb`. A few GB of +rounding slack vs the live-CD's exact byte count is accepted (`size_gb` is +display-only and not persisted by Reanimator). +**Consequences:** +- BMC-dump storage now carries `size_gb` matching the live-CD export + component-for-component (7681/7681, 3839/3840). +- `Drive Temperature`, byte-swapped system `GUID` in `OptPme/pram/per_power_off.ini`, + and PCIe link speed/width remain unparsed for xFusion: temperature has no + `models.Storage` field yet, the GUID is fragile wire-order, and card_info carries + no link fields (that data only exists in the live-CD's lspci view, not the dump). +- Regression test `TestParseDiskInfo_CapacityUnits`. + +--- + +## ADL-064 — Inspur combined component.log: parse `RESTful FRU info:` and float `fans_power` + +**Date:** 2026-09-01 +**Context:** Diffing a BMC dump (`inspur`) against the BEE-SP live-CD bundle +(`easy_bee`) for the same NF5280M6 (S/N 24C319579) surfaced two blind spots in the +combined-`component.log` onekeylog layout (classic `onekeylog/` root, has +`component/component.log`, but no `devicefrusdr.log` and no `asset.json`): +1. Board `manufacturer` / `product_name` / `part_number` / `uuid` all empty and + `stats.fru: 0` (with a "still missing: FRU" collection error). `component.log` + carries a `RESTful FRU info:` JSON array (BMC_FRU + PSU/backplane/riser FRUs + with `device.system_uuid`, `board`, `product` areas) that no parser read. +2. Zero fan sensors (live-CD had 8). `FanRESTInfo.FansPower` was typed `int` but + this firmware writes `"fans_power": 12.000000`; `json.Unmarshal` of the whole + fan block failed, so `parseFanSensors` / `parseFanEvents` returned nil. +**Decision:** +- `internal/parser/vendors/inspur/component_fru.go` (`ParseComponentLogFRU`) + parses the `RESTful FRU info:` array into `[]models.FRUInfo`, preferring the + product area (operator-facing system serial / asset tag) over the board area + (PCB serial) for identity, and sets `hw.BoardInfo.UUID` from `system_uuid` + directly. Placeholder area values (`0`, `NULL`, `N/A`, blank) are skipped. + Wired in `parser.go` as a fallback only when `result.FRU` is still empty after + the devicefrusdr.log path, so dumps that already have real FRU data are + untouched. +- `FanRESTInfo.FansPower` changed to `float64`. +**Consequences:** +- This dump class now yields full board identity + UUID matching the live-CD + export, 8 fan sensors, `stats.fru: 7`, and no collection error. +- Real motherboard `part_number` (`YZMB-01642-102`) is exported where the live-CD + had only the `"0"` product-area placeholder - a deliberate improvement, not a + regression (Reanimator treats `"0"`/`"NULL"` board part as absent). +- Remaining live-CD-vs-BMC diffs for this host are not parser gaps: the combined + `component.log` carries no full IPMI SDR (so ~16 sensors vs 72), the live-CD + `0000:00:1f.2` PCH power-management function is lspci-only noise, and the + live-CD actually *missed* one DIMM (`CPU1_C1D0`) that the BMC dump reports. +- Tests: `TestParseComponentLogFRU_BoardIdentityAndUUID`, + `TestParseComponentLogFRU_AbsentSection`, + `TestParseComponentLogSensors_FloatFansPower`. diff --git a/internal/parser/vendors/inspur/component.go b/internal/parser/vendors/inspur/component.go index 952e834..0c005d6 100644 --- a/internal/parser/vendors/inspur/component.go +++ b/internal/parser/vendors/inspur/component.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "regexp" + "sort" + "strconv" "strings" "time" @@ -504,60 +506,59 @@ type FanRESTInfo struct { MaxSpeedRPM int `json:"max_speed_rpm"` FanModel string `json:"fan_model"` } `json:"fans"` - FansPower int `json:"fans_power"` + // fans_power is reported as a float ("12.000000") on some BMC firmware; + // an int type here makes json.Unmarshal fail and drops every fan reading. + FansPower float64 `json:"fans_power"` } // NetworkAdapterRESTInfo represents the RESTful Network Adapter info structure type NetworkAdapterRESTInfo struct { - SysAdapters []struct { - ID int `json:"id"` - Name string `json:"name"` - Location string `json:"Location"` - Present int `json:"present"` - Slot int `json:"slot"` - VendorID int `json:"vendor_id"` - DeviceID int `json:"device_id"` - Vendor string `json:"vendor"` - Model string `json:"model"` - FwVer string `json:"fw_ver"` - Status string `json:"status"` - SN string `json:"sn"` - PN string `json:"pn"` - PortNum int `json:"port_num"` - PortType string `json:"port_type"` - Ports []struct { - ID int `json:"id"` - MacAddr string `json:"mac_addr"` - } `json:"ports"` - } `json:"sys_adapters"` + SysAdapters []sysAdapter `json:"sys_adapters"` +} + +type sysAdapter struct { + ID int `json:"id"` + Name string `json:"name"` + Location string `json:"Location"` + Present int `json:"present"` + Slot int `json:"slot"` + PcieBus int `json:"pcie_bus"` + PcieDev int `json:"pcie_dev"` + PcieFunc int `json:"pcie_func"` + VendorID int `json:"vendor_id"` + DeviceID int `json:"device_id"` + Vendor string `json:"vendor"` + Model string `json:"model"` + FwVer string `json:"fw_ver"` + Status string `json:"status"` + SN string `json:"sn"` + PN string `json:"pn"` + PortNum int `json:"port_num"` + PortType string `json:"port_type"` + Ports []struct { + ID int `json:"id"` + MacAddr string `json:"mac_addr"` + } `json:"ports"` } func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) { - // Find RESTful Network Adapter info section re := regexp.MustCompile(`RESTful Network Adapter info:\s*(\{[\s\S]*?\})\s*RESTful fan`) match := re.FindStringSubmatch(text) if match == nil { return } - jsonStr := match[1] - jsonStr = strings.ReplaceAll(jsonStr, "\n", "") - var netInfo NetworkAdapterRESTInfo - if err := json.Unmarshal([]byte(jsonStr), &netInfo); err != nil { + if err := json.Unmarshal([]byte(strings.ReplaceAll(match[1], "\n", "")), &netInfo); err != nil { return } - var merged []models.NetworkAdapter - seen := make(map[string]int) - for _, existing := range hw.NetworkAdapters { - key := inspurNICKey(existing) - if key == "" { - continue - } - seen[key] = len(merged) - merged = append(merged, existing) - } + // BIOS "PCIE_n_INFO" lines enumerate the real BDF of every PCI function, which + // is the identity the live-CD export and the Reanimator contract key NICs by. + biosFuncs := parseBIOSPCIeFunctions(text) + + merged := append([]models.NetworkAdapter(nil), hw.NetworkAdapters...) + for _, adapter := range netInfo.SysAdapters { var macs []string for _, port := range adapter.Ports { @@ -577,8 +578,7 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) { vendor = normalizeModelLabel(pciids.VendorName(adapter.VendorID)) } - item := models.NetworkAdapter{ - Slot: fmt.Sprintf("Slot %d", adapter.Slot), + base := models.NetworkAdapter{ Location: adapter.Location, Present: adapter.Present == 1, Model: model, @@ -590,29 +590,212 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) { Firmware: normalizeRedisValue(adapter.FwVer), PortCount: adapter.PortNum, PortType: adapter.PortType, - MACAddresses: macs, Status: adapter.Status, } - key := inspurNICKey(item) - if idx, ok := seen[key]; ok { - mergeInspurNIC(&merged[idx], item) + + // If another source (asset.json PcieInfo) already recorded this card's PCI + // functions, enrich those in place — don't add a competing set of records. + if enrichExistingPCIeNICByBus(hw, adapter.PcieBus, base, macs) { continue } - if slotIdx := inspurFindNICBySlot(merged, item.Slot); slotIdx >= 0 { - mergeInspurNIC(&merged[slotIdx], item) - if key != "" { - seen[key] = slotIdx + + funcs := selectBIOSPCIeFunctions(biosFuncs, adapter.PcieBus, adapter.VendorID, adapter.DeviceID) + if len(funcs) == 0 && adapter.PcieBus > 0 { + // No BIOS table: fall back to consecutive functions from the port count. + for i := range max(1, len(macs)) { + funcs = append(funcs, pcieFunction{Dev: adapter.PcieDev, Func: adapter.PcieFunc + i}) + } + } + + if len(funcs) > 0 { + for i, fn := range funcs { + item := base + item.BDF = fullBDF(adapter.PcieBus, fn.Dev, fn.Func) + item.Slot = item.BDF + item.MACAddresses = macsForFunction(macs, len(funcs), i) + upsertInspurNIC(&merged, item) } continue } - if key != "" { - seen[key] = len(merged) - } - merged = append(merged, item) + + // No PCI location at all: keep the card-level record (label-only slot). + item := base + item.Slot = fmt.Sprintf("Slot %d", adapter.Slot) + item.MACAddresses = macs + upsertInspurNIC(&merged, item) } + hw.NetworkAdapters = merged } +// macsForFunction distributes a card's port MAC list across its PCI functions: +// one MAC per function when the counts line up, otherwise every MAC on the first. +func macsForFunction(macs []string, funcCount, idx int) []string { + if len(macs) == funcCount { + return []string{macs[idx]} + } + if idx == 0 { + return append([]string(nil), macs...) + } + return nil +} + +func fullBDF(bus, dev, fn int) string { + return fmt.Sprintf("0000:%02x:%02x.%x", bus, dev, fn) +} + +type pcieFunction struct { + Dev int + Func int + VendorID int + DeviceID int +} + +var biosPCIeInfoRe = regexp.MustCompile( + `PCIE_\d+_INFO:\s*Device B\.D\.F=0x([0-9a-fA-F]+)\.0x([0-9a-fA-F]+)\.0x([0-9a-fA-F]+),.*?VendorID:0x([0-9a-fA-F]+),\s*Devi[c]?eID:0x([0-9a-fA-F]+)`, +) + +// parseBIOSPCIeFunctions reads the BIOS "PCIE_n_INFO" transcript into a +// bus-number -> functions map. Bus/dev/func/IDs are printed in hex. +func parseBIOSPCIeFunctions(text string) map[int][]pcieFunction { + out := make(map[int][]pcieFunction) + for _, m := range biosPCIeInfoRe.FindAllStringSubmatch(text, -1) { + bus := parseHexInt(m[1]) + fn := pcieFunction{ + Dev: parseHexInt(m[2]), + Func: parseHexInt(m[3]), + VendorID: parseHexInt(m[4]), + DeviceID: parseHexInt(m[5]), + } + out[bus] = append(out[bus], fn) + } + return out +} + +// selectBIOSPCIeFunctions returns the functions on the adapter's bus whose PCI +// IDs match the adapter, sorted by (dev, func). +func selectBIOSPCIeFunctions(byBus map[int][]pcieFunction, bus, vendorID, deviceID int) []pcieFunction { + if bus <= 0 { + return nil + } + var out []pcieFunction + for _, fn := range byBus[bus] { + if vendorID != 0 && fn.VendorID != 0 && fn.VendorID != vendorID { + continue + } + if deviceID != 0 && fn.DeviceID != 0 && fn.DeviceID != deviceID { + continue + } + out = append(out, fn) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Dev != out[j].Dev { + return out[i].Dev < out[j].Dev + } + return out[i].Func < out[j].Func + }) + return out +} + +func parseHexInt(s string) int { + n, err := strconv.ParseInt(strings.TrimSpace(s), 16, 0) + if err != nil { + return 0 + } + return int(n) +} + +// enrichExistingPCIeNICByBus fills status/model/firmware and per-port MACs on +// PCIe-device records that another source already created for this card's bus. +// Returns true when at least one record was matched. +func enrichExistingPCIeNICByBus(hw *models.HardwareConfig, bus int, info models.NetworkAdapter, macs []string) bool { + if bus <= 0 || hw == nil { + return false + } + matched := false + macIdx := 0 + for i := range hw.PCIeDevices { + dev := &hw.PCIeDevices[i] + if bdfBusNumber(dev.BDF) != bus { + continue + } + matched = true + if strings.TrimSpace(dev.Model) == "" || looksLikeRawDeviceID(dev.Model) { + dev.Model = info.Model + } + if strings.TrimSpace(dev.Manufacturer) == "" { + dev.Manufacturer = info.Vendor + } + if strings.TrimSpace(dev.Firmware) == "" { + dev.Firmware = info.Firmware + } + if strings.TrimSpace(dev.SerialNumber) == "" { + dev.SerialNumber = info.SerialNumber + } + if len(dev.MACAddresses) == 0 && macIdx < len(macs) { + dev.MACAddresses = []string{macs[macIdx]} + } + macIdx++ + } + return matched +} + +// bdfBusNumber extracts the hex bus field from a BDF ("0000:65:00.1" or +// "65:00.1") as a decimal int, or -1 if it does not look like a BDF. +func bdfBusNumber(bdf string) int { + parts := strings.Split(strings.TrimSpace(bdf), ":") + if len(parts) < 2 { + return -1 + } + n, err := strconv.ParseInt(parts[len(parts)-2], 16, 0) + if err != nil { + return -1 + } + return int(n) +} + +// upsertInspurNIC adds item to list, merging into an existing entry that shares +// its BDF or a MAC address. +func upsertInspurNIC(list *[]models.NetworkAdapter, item models.NetworkAdapter) { + items := *list + if bdf := strings.ToLower(strings.TrimSpace(item.BDF)); bdf != "" { + for i := range items { + if strings.ToLower(strings.TrimSpace(items[i].BDF)) == bdf { + mergeInspurNIC(&items[i], item) + return + } + } + } + for _, mac := range item.MACAddresses { + if idx := inspurFindNICByMAC(items, mac); idx >= 0 { + mergeInspurNIC(&items[idx], item) + return + } + } + if item.BDF == "" { + if idx := inspurFindNICBySlot(items, item.Slot); idx >= 0 { + mergeInspurNIC(&items[idx], item) + return + } + } + *list = append(items, item) +} + +func inspurFindNICByMAC(items []models.NetworkAdapter, mac string) int { + mac = strings.ToLower(strings.TrimSpace(mac)) + if mac == "" { + return -1 + } + for i := range items { + for _, existing := range items[i].MACAddresses { + if strings.ToLower(strings.TrimSpace(existing)) == mac { + return i + } + } + } + return -1 +} + func inspurMemoryKey(item models.MemoryDIMM) string { return strings.ToLower(strings.TrimSpace(inspurFirstNonEmpty(item.SerialNumber, item.Slot, item.Location))) } @@ -713,10 +896,6 @@ func mergeInspurPSU(dst *models.PSU, src models.PSU) { } } -func inspurNICKey(item models.NetworkAdapter) string { - return strings.ToLower(strings.TrimSpace(inspurFirstNonEmpty(item.SerialNumber, strings.Join(item.MACAddresses, ","), item.Slot, item.Location))) -} - func mergeInspurNIC(dst *models.NetworkAdapter, src models.NetworkAdapter) { if dst == nil { return @@ -854,9 +1033,9 @@ func parseFanSensors(text string) []models.SensorReading { out = append(out, models.SensorReading{ Name: "Fans_Power", Type: "power", - Value: float64(fanInfo.FansPower), + Value: fanInfo.FansPower, Unit: "W", - RawValue: fmt.Sprintf("%d", fanInfo.FansPower), + RawValue: fmt.Sprintf("%g", fanInfo.FansPower), Status: "OK", }) } diff --git a/internal/parser/vendors/inspur/component_fru.go b/internal/parser/vendors/inspur/component_fru.go new file mode 100644 index 0000000..b03bb86 --- /dev/null +++ b/internal/parser/vendors/inspur/component_fru.go @@ -0,0 +1,114 @@ +package inspur + +import ( + "encoding/json" + "regexp" + "strings" + + "git.mchus.pro/mchus/logpile/internal/models" +) + +// componentLogFRURegex isolates the JSON array that follows "RESTful FRU info:" +// in a combined component.log. FRU areas carry no nested arrays, so the lazy +// match stops at the array's own closing bracket. +var componentLogFRURegex = regexp.MustCompile(`RESTful FRU info:\s*(\[[\s\S]*?\])`) + +type componentFRUArea struct { + Version int `json:"version"` + Type string `json:"type"` + Date string `json:"date"` + Manufacturer string `json:"manufacturer"` + ProductName string `json:"product_name"` + SerialNumber string `json:"serial_number"` + PartNumber string `json:"part_number"` + AssetTag string `json:"asset_tag"` +} + +type componentFRUDevice struct { + Device struct { + ID int `json:"id"` + Name string `json:"name"` + SystemUUID string `json:"system_uuid"` + } `json:"device"` + Chassis componentFRUArea `json:"chassis"` + Board componentFRUArea `json:"board"` + Product componentFRUArea `json:"product"` +} + +// ParseComponentLogFRU parses the "RESTful FRU info:" JSON block from a combined +// component.log. Some onekeylog dumps ship this block instead of a +// devicefrusdr.log / asset.json, so it is the only source of board manufacturer, +// product name, part number and system UUID for that dump class. It also sets +// hw.BoardInfo.UUID directly, which extractBoardInfo does not populate. +func ParseComponentLogFRU(content []byte, hw *models.HardwareConfig) []models.FRUInfo { + m := componentLogFRURegex.FindSubmatch(content) + if m == nil { + return nil + } + + var devices []componentFRUDevice + if err := json.Unmarshal(m[1], &devices); err != nil { + return nil + } + + var out []models.FRUInfo + for _, d := range devices { + fru := componentFRUToModel(d) + if fru.Manufacturer == "" && fru.ProductName == "" && fru.SerialNumber == "" && fru.PartNumber == "" { + continue + } + out = append(out, fru) + + if hw != nil && hw.BoardInfo.UUID == "" && isSystemFRUDevice(d.Device.Name) { + if u := fruAreaValue(d.Device.SystemUUID); u != "" { + hw.BoardInfo.UUID = u + } + } + } + return out +} + +func isSystemFRUDevice(name string) bool { + n := strings.ToUpper(strings.TrimSpace(name)) + return n == "BMC_FRU" || n == "MB_FRU" || strings.HasPrefix(n, "MAINBOARD") +} + +// componentFRUToModel flattens a FRU device into one models.FRUInfo. The product +// area (system serial / asset tag the operator knows, e.g. "24C319579") is +// preferred over the board area (physical PCB serial) for identity, matching the +// BEE-SP live-CD inventory and the Reanimator board contract. +func componentFRUToModel(d componentFRUDevice) models.FRUInfo { + desc := strings.TrimSpace(d.Device.Name) + if desc == "" { + desc = "FRU Device" + } + // extractBoardInfo only lifts manufacturer from an entry it recognises as the + // main board; tag the system FRU so it does. + if isSystemFRUDevice(d.Device.Name) { + desc += " (builtin board)" + } + + return models.FRUInfo{ + Description: desc, + ChassisType: fruAreaValue(d.Chassis.Type), + Manufacturer: fruAreaValue(d.Product.Manufacturer, d.Board.Manufacturer), + ProductName: fruAreaValue(d.Product.ProductName, d.Board.ProductName), + SerialNumber: fruAreaValue(d.Product.SerialNumber, d.Board.SerialNumber), + PartNumber: fruAreaValue(d.Product.PartNumber, d.Board.PartNumber, d.Chassis.PartNumber), + MfgDate: fruAreaValue(d.Board.Date), + AssetTag: fruAreaValue(d.Product.AssetTag), + } +} + +// fruAreaValue returns the first argument that is a real value. iBMC uses "0", +// "NULL" and whitespace-padded blanks as placeholders in unused FRU areas. +func fruAreaValue(values ...string) string { + for _, v := range values { + t := strings.TrimSpace(v) + if t == "" || t == "0" || strings.EqualFold(t, "null") || strings.EqualFold(t, "n/a") { + continue + } + return t + } + return "" +} diff --git a/internal/parser/vendors/inspur/component_fru_test.go b/internal/parser/vendors/inspur/component_fru_test.go new file mode 100644 index 0000000..5b6ad39 --- /dev/null +++ b/internal/parser/vendors/inspur/component_fru_test.go @@ -0,0 +1,61 @@ +package inspur + +import ( + "testing" + + "git.mchus.pro/mchus/logpile/internal/models" +) + +const componentLogFRUBlock = `RESTful FRU info: +[ { "device": { "id": 0, "name": "BMC_FRU", "system_uuid": "4fc4c29c-dfeb-03e6-0010-debf003f9071" }, + "chassis": { "version": 1, "type": "Rack Mount Chassis", "part_number": "0", "serial_number": "0" }, + "board": { "version": 1, "date": "Wed Dec 27 16:15:00 2023", "manufacturer": "Inspur", "product_name": "NF5280M6", "serial_number": "MBPC16R11762D90", "part_number": "YZMB-01642-102" }, + "product": { "version": 1, "manufacturer": "Inspur", "product_name": "NF5280M6", "part_number": "0", "serial_number": "24C319579", "asset_tag": "24C319579" } }, + { "device": { "id": 1, "name": "PSU0_FRU", "system_uuid": "4fc4c29c-dfeb-03e6-0010-debf003f9071" }, + "chassis": { "version": 0 }, "board": { "version": 0 }, + "product": { "version": 1, "manufacturer": "Great Wall", "product_name": "GW-CRPS1300D2WM", "part_number": "V0310AD000000000", "serial_number": "2O01C174959" } } ] +BMC kernel version: +Linux` + +func TestParseComponentLogFRU_BoardIdentityAndUUID(t *testing.T) { + hw := &models.HardwareConfig{} + fru := ParseComponentLogFRU([]byte(componentLogFRUBlock), hw) + + if len(fru) != 2 { + t.Fatalf("expected 2 FRU entries, got %d", len(fru)) + } + if hw.BoardInfo.UUID != "4fc4c29c-dfeb-03e6-0010-debf003f9071" { + t.Errorf("BoardInfo.UUID = %q, want the system_uuid", hw.BoardInfo.UUID) + } + + extractBoardInfo(fru, hw) + if hw.BoardInfo.Manufacturer != "Inspur" { + t.Errorf("Manufacturer = %q, want Inspur", hw.BoardInfo.Manufacturer) + } + if hw.BoardInfo.ProductName != "NF5280M6" { + t.Errorf("ProductName = %q, want NF5280M6", hw.BoardInfo.ProductName) + } + // product-area serial (operator-facing), not the board PCB serial. + if hw.BoardInfo.SerialNumber != "24C319579" { + t.Errorf("SerialNumber = %q, want 24C319579", hw.BoardInfo.SerialNumber) + } + // "0" product part is a placeholder; fall back to the real board part. + if hw.BoardInfo.PartNumber != "YZMB-01642-102" { + t.Errorf("PartNumber = %q, want YZMB-01642-102", hw.BoardInfo.PartNumber) + } + + // PSU vendor must not leak into the server manufacturer. + if fru[1].Manufacturer != "Great Wall" || fru[1].SerialNumber != "2O01C174959" { + t.Errorf("PSU FRU not preserved: %+v", fru[1]) + } +} + +func TestParseComponentLogFRU_AbsentSection(t *testing.T) { + hw := &models.HardwareConfig{} + if fru := ParseComponentLogFRU([]byte("RESTful CPU info:\n{ }\nRESTful Memory info:\n{ }"), hw); fru != nil { + t.Fatalf("expected nil for a log with no FRU section, got %+v", fru) + } + if hw.BoardInfo.UUID != "" { + t.Errorf("BoardInfo.UUID set to %q from a log with no FRU section", hw.BoardInfo.UUID) + } +} diff --git a/internal/parser/vendors/inspur/component_test.go b/internal/parser/vendors/inspur/component_test.go index cbed8d7..14a6ce5 100644 --- a/internal/parser/vendors/inspur/component_test.go +++ b/internal/parser/vendors/inspur/component_test.go @@ -109,6 +109,101 @@ RESTful fan` } } +func TestParseNetworkAdapterInfo_PerFunctionFromBIOSPCIeTable(t *testing.T) { + text := `RESTful Network Adapter info: +{ + "sys_adapters": [ + { + "id": 0, "present": 1, "slot": 13, + "pcie_bus": 152, "pcie_dev": 0, "pcie_func": 0, + "vendor_id": 32902, "device_id": 5409, + "vendor": "Intel Corporation", "model": "ENFI1100-T4", + "status": "OK", "port_num": 4, + "ports": [ + { "id": 1, "mac_addr": "9C:C2:C4:65:5A:99" }, + { "id": 2, "mac_addr": "9C:C2:C4:65:5A:9A" }, + { "id": 3, "mac_addr": "9C:C2:C4:65:5A:9B" }, + { "id": 4, "mac_addr": "9C:C2:C4:65:5A:9C" } + ] + } + ] +} +RESTful fan +PCIE_2_INFO: Device B.D.F=0x98.0x0.0x0, RootPort B.D.F=0x97.0x2.0x0, VendorID:0x8086, DevieID:0x1521 +PCIE_2_INFO: Device B.D.F=0x98.0x0.0x1, RootPort B.D.F=0x97.0x2.0x0, VendorID:0x8086, DevieID:0x1521 +PCIE_2_INFO: Device B.D.F=0x98.0x0.0x2, RootPort B.D.F=0x97.0x2.0x0, VendorID:0x8086, DevieID:0x1521 +PCIE_2_INFO: Device B.D.F=0x98.0x0.0x3, RootPort B.D.F=0x97.0x2.0x0, VendorID:0x8086, DevieID:0x1521 +` + + hw := &models.HardwareConfig{} + parseNetworkAdapterInfo(text, hw) + + if len(hw.NetworkAdapters) != 4 { + t.Fatalf("expected 4 per-function NIC records, got %d: %+v", len(hw.NetworkAdapters), hw.NetworkAdapters) + } + want := map[string]string{ + "0000:98:00.0": "9C:C2:C4:65:5A:99", + "0000:98:00.1": "9C:C2:C4:65:5A:9A", + "0000:98:00.2": "9C:C2:C4:65:5A:9B", + "0000:98:00.3": "9C:C2:C4:65:5A:9C", + } + for _, na := range hw.NetworkAdapters { + mac, ok := want[na.Slot] + if !ok { + t.Fatalf("unexpected slot %q", na.Slot) + } + if na.BDF != na.Slot { + t.Errorf("slot %q: BDF should equal slot, got %q", na.Slot, na.BDF) + } + if len(na.MACAddresses) != 1 || na.MACAddresses[0] != mac { + t.Errorf("slot %q: want MAC %s, got %v", na.Slot, mac, na.MACAddresses) + } + if na.DeviceID != 5409 { + t.Errorf("slot %q: device id not carried, got %d", na.Slot, na.DeviceID) + } + } +} + +func TestParseNetworkAdapterInfo_EnrichesExistingPCIeRecords(t *testing.T) { + text := `RESTful Network Adapter info: +{ + "sys_adapters": [ + { + "id": 0, "present": 1, "slot": 13, + "pcie_bus": 101, "pcie_dev": 0, "pcie_func": 0, + "vendor_id": 5555, "device_id": 4119, + "vendor": "Mellanox Technologies", "model": "MCX512A-ACAT", + "fw_ver": "16.35.3006", "status": "OK", "port_num": 2, + "ports": [ + { "id": 1, "mac_addr": "58:A2:E1:7D:49:D4" }, + { "id": 2, "mac_addr": "58:A2:E1:7D:49:D5" } + ] + } + ] +} +RESTful fan` + + hw := &models.HardwareConfig{ + PCIeDevices: []models.PCIeDevice{ + {BDF: "0000:65:00.0", VendorID: 5555, DeviceID: 4119, DeviceClass: "NetworkController"}, + {BDF: "0000:65:00.1", VendorID: 5555, DeviceID: 4119, DeviceClass: "NetworkController"}, + }, + } + parseNetworkAdapterInfo(text, hw) + + if len(hw.NetworkAdapters) != 0 { + t.Fatalf("expected no new NIC records (existing PCIe enriched in place), got %d", len(hw.NetworkAdapters)) + } + for _, d := range hw.PCIeDevices { + if d.Model != "MCX512A-ACAT" { + t.Errorf("%s: model not enriched, got %q", d.BDF, d.Model) + } + if d.Firmware != "16.35.3006" { + t.Errorf("%s: firmware not enriched, got %q", d.BDF, d.Firmware) + } + } +} + func TestParseComponentLogSensors_ExtractsFanBackplaneAndPSUSummary(t *testing.T) { text := `RESTful PSU info: { @@ -181,6 +276,40 @@ BMC` } } +// TestParseComponentLogSensors_FloatFansPower guards a regression where a +// float "fans_power" ("12.000000") made json.Unmarshal of the whole fan block +// fail, dropping every fan reading (0 fans vs the live-CD's 8 for the same host). +func TestParseComponentLogSensors_FloatFansPower(t *testing.T) { + text := `RESTful fan info: +{ "fans": [ + { "id": 0, "fan_name": "System Fan0 Front", "present": "OK", "status": "OK", "status_str": "OK", "speed_rpm": 2947, "speed_percent": 23, "max_speed_rpm": 20000, "fan_model": "8056" }, + { "id": 1, "fan_name": "System Fan0 Rear", "present": "OK", "status": "OK", "status_str": "OK", "speed_rpm": 2520, "speed_percent": 23, "max_speed_rpm": 20000, "fan_model": "8056" } +], "fans_power": 12.000000 } +RESTful diskbackplane info: +[] +BMC` + + sensors := ParseComponentLogSensors([]byte(text)) + var fans, fanPower int + for _, s := range sensors { + switch s.Name { + case "System Fan0 Front", "System Fan0 Rear": + fans++ + case "Fans_Power": + fanPower++ + if s.Value != 12 { + t.Errorf("Fans_Power value = %v, want 12", s.Value) + } + } + } + if fans != 2 { + t.Fatalf("expected 2 fan sensors, got %d (float fans_power broke the parse)", fans) + } + if fanPower != 1 { + t.Errorf("expected 1 Fans_Power sensor, got %d", fanPower) + } +} + func TestParseHDDInfo_MergesIntoExistingStorage(t *testing.T) { text := `RESTful HDD info: [ diff --git a/internal/parser/vendors/inspur/diagnose.go b/internal/parser/vendors/inspur/diagnose.go index faabf63..684d471 100644 --- a/internal/parser/vendors/inspur/diagnose.go +++ b/internal/parser/vendors/inspur/diagnose.go @@ -16,20 +16,20 @@ import ( // otrdDiagnosePCIeEntry mirrors the relevant fields of "Pcie Device Info" // entries in OtrdDiagnoseComponent.json. type otrdDiagnosePCIeEntry struct { - LocString string `json:"LocString"` - PcieSlot int `json:"PcieSlot"` - PresentStatus int `json:"PresentStatus"` - VendorId int `json:"VendorId"` - DeviceId int `json:"DeviceId"` - BusNumber int `json:"BusNumber"` - DeviceNumber int `json:"DeviceNumber"` - FunctionNumber int `json:"FunctionNumber"` - CurrentLinkSpeed int `json:"CurrentLinkSpeed"` - MaxLinkSpeed int `json:"MaxLinkSpeed"` - NegotiatedLinkWidth int `json:"NegotiatedLinkWidth"` - MaxLinkWidth int `json:"MaxLinkWidth"` - SerialNumber *string `json:"SerialNumber"` - PartNumber *string `json:"PartNumber"` + LocString string `json:"LocString"` + PcieSlot int `json:"PcieSlot"` + PresentStatus int `json:"PresentStatus"` + VendorId int `json:"VendorId"` + DeviceId int `json:"DeviceId"` + BusNumber int `json:"BusNumber"` + DeviceNumber int `json:"DeviceNumber"` + FunctionNumber int `json:"FunctionNumber"` + CurrentLinkSpeed int `json:"CurrentLinkSpeed"` + MaxLinkSpeed int `json:"MaxLinkSpeed"` + NegotiatedLinkWidth int `json:"NegotiatedLinkWidth"` + MaxLinkWidth int `json:"MaxLinkWidth"` + SerialNumber *string `json:"SerialNumber"` + PartNumber *string `json:"PartNumber"` } type otrdDiagnoseComponent struct { diff --git a/internal/parser/vendors/inspur/parser.go b/internal/parser/vendors/inspur/parser.go index 8a20aad..dfe17f4 100644 --- a/internal/parser/vendors/inspur/parser.go +++ b/internal/parser/vendors/inspur/parser.go @@ -16,7 +16,7 @@ import ( // parserVersion - version of this parser module // IMPORTANT: Increment this version when making changes to parser logic! -const parserVersion = "2.5" +const parserVersion = "2.6" func init() { parser.Register(&Parser{}) @@ -164,6 +164,15 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er if f := parser.FindFileByName(files, "component.log"); f != nil { ParseComponentLog(f.Content, result.Hardware) + // Some combined component.log dumps have no devicefrusdr.log / asset.json; + // board manufacturer/product/part/UUID live only in "RESTful FRU info:". + if len(result.FRU) == 0 { + if fru := ParseComponentLogFRU(f.Content, result.Hardware); len(fru) > 0 { + result.FRU = fru + extractBoardInfo(result.FRU, result.Hardware) + } + } + // Extract events from component.log (memory errors, etc.) componentEvents := ParseComponentLogEvents(f.Content) result.Events = append(result.Events, componentEvents...) diff --git a/internal/parser/vendors/inspur/sol_smartd.go b/internal/parser/vendors/inspur/sol_smartd.go index 3367c03..2fa3e45 100644 --- a/internal/parser/vendors/inspur/sol_smartd.go +++ b/internal/parser/vendors/inspur/sol_smartd.go @@ -10,19 +10,32 @@ import ( "git.mchus.pro/mchus/logpile/internal/parser" ) -// solSmartdDeviceRe matches smartd device info lines from SOLHostCapture.log. -// Example: +// smartd prints one device-info line per drive during startup. Two shapes occur, +// depending on how the drive is attached: // -// Device: /dev/sda [SAT], Micron_5400_MTFDDAK480TGA, S/N:2310400DC7E3, WWN:..., FW:D4CM003, 480 GB -var solSmartdDeviceRe = regexp.MustCompile( - `Device: /dev/\S+ \[SAT\], (.+?), S/N:(\S+),.*?FW:(\S+), ([\d.]+) (GB|TB)`, +// SAT (SATA drive, ATA pass-through): +// Device: /dev/sda [SAT], Micron_5400_MTFDDAK480TGA, S/N:2310400DC7E3, WWN:5-00a075-1400dc7e3, FW:D4CM003, 480 GB +// SCSI (SAS drive, or SATA drive behind a SAS HBA in SCSI mode): +// Device: /dev/sdb, [SEAGATE ST6000NM005B K0A1], lu id: 0x5000c500ee6e11f7, S/N: WX004FCC0000E22967PY, 6.00 TB +// +// The SCSI line has no firmware field and reports the model as a padded SCSI +// INQUIRY triple "VENDOR PRODUCT REV". +var ( + solSmartdSATDeviceRe = regexp.MustCompile( + `Device: /dev/\S+ \[SAT\], (.+?), S/N:\s*(\S+),.*?FW:(\S+), ([\d.]+) (GB|TB)`, + ) + solSmartdSCSIDeviceRe = regexp.MustCompile( + `Device: /dev/\S+?, \[(.+?)\],.*?S/N:\s*(\S+), ([\d.]+) (GB|TB)`, + ) + multiSpaceRe = regexp.MustCompile(`\s{2,}`) ) type solSmartdDevice struct { - Model string - Serial string - Firmware string - SizeGB int + Model string + Serial string + Firmware string + SizeGB int + Interface string // "SATA" or "SAS" } // parseSOLSmartdDevices extracts unique disk entries from SOLHostCapture.log content. @@ -32,31 +45,68 @@ func parseSOLSmartdDevices(content []byte) []solSmartdDevice { var out []solSmartdDevice for _, line := range strings.Split(string(content), "\n") { - m := solSmartdDeviceRe.FindStringSubmatch(line) - if m == nil { + dev, ok := parseSOLSmartdLine(line) + if !ok { continue } - serial := strings.TrimSpace(m[2]) - if serial == "" { - continue - } - key := strings.ToLower(serial) - if _, ok := seen[key]; ok { + key := strings.ToLower(dev.Serial) + if _, dup := seen[key]; dup { continue } seen[key] = struct{}{} - - sizeGB := parseSolSizeGB(m[4], m[5]) - out = append(out, solSmartdDevice{ - Model: strings.TrimSpace(m[1]), - Serial: serial, - Firmware: strings.TrimSpace(m[3]), - SizeGB: sizeGB, - }) + out = append(out, dev) } return out } +func parseSOLSmartdLine(line string) (solSmartdDevice, bool) { + if m := solSmartdSATDeviceRe.FindStringSubmatch(line); m != nil { + serial := strings.TrimSpace(m[2]) + if serial == "" { + return solSmartdDevice{}, false + } + return solSmartdDevice{ + Model: strings.TrimSpace(m[1]), + Serial: serial, + Firmware: strings.TrimSpace(m[3]), + SizeGB: parseSolSizeGB(m[4], m[5]), + Interface: "SATA", + }, true + } + if m := solSmartdSCSIDeviceRe.FindStringSubmatch(line); m != nil { + serial := strings.TrimSpace(m[2]) + if serial == "" { + return solSmartdDevice{}, false + } + return solSmartdDevice{ + Model: scsiInquiryModel(m[1]), + Serial: serial, + SizeGB: parseSolSizeGB(m[3], m[4]), + Interface: "SAS", + }, true + } + return solSmartdDevice{}, false +} + +// scsiInquiryModel turns a padded SCSI INQUIRY string ("SEAGATE ST6000NM005B +// K0A1") into "VENDOR PRODUCT", dropping the trailing revision. An "ATA" vendor +// is a SATA drive bridged into SCSI mode — its product field already holds the +// real model, so the vendor token is dropped. +func scsiInquiryModel(raw string) string { + fields := multiSpaceRe.Split(strings.TrimSpace(raw), -1) + switch len(fields) { + case 0: + return strings.TrimSpace(raw) + case 1: + return fields[0] + } + vendor, product := fields[0], fields[1] + if strings.EqualFold(vendor, "ATA") { + return product + } + return vendor + " " + product +} + // parseSolSizeGB converts smartd size string ("480", "3.84") + unit ("GB", "TB") to integer GB. // Uses decimal TB (1 TB = 1000 GB) matching disk manufacturer conventions. func parseSolSizeGB(value, unit string) int { @@ -215,7 +265,7 @@ func solEnrichByPlaceholder(hw *models.HardwareConfig, devices []solSmartdDevice hw.Storage[idx].Manufacturer = extractStorageManufacturer(d.Model) } if hw.Storage[idx].Interface == "" { - hw.Storage[idx].Interface = "SATA" + hw.Storage[idx].Interface = solDeviceInterface(d) } } return unmatched @@ -229,11 +279,18 @@ func solMakeStorage(d solSmartdDevice) models.Storage { SizeGB: d.SizeGB, Type: solStorageType(d.Model), Manufacturer: extractStorageManufacturer(d.Model), - Interface: "SATA", + Interface: solDeviceInterface(d), Present: true, } } +func solDeviceInterface(d solSmartdDevice) string { + if d.Interface != "" { + return d.Interface + } + return "SATA" +} + // solStorageType infers SSD vs HDD from the model string. // Micron SSD models start with "MTFDD"; Intel SSDs contain "SSD". func solStorageType(model string) string { diff --git a/internal/parser/vendors/inspur/sol_smartd_test.go b/internal/parser/vendors/inspur/sol_smartd_test.go index a24e899..9e41dcd 100644 --- a/internal/parser/vendors/inspur/sol_smartd_test.go +++ b/internal/parser/vendors/inspur/sol_smartd_test.go @@ -36,6 +36,69 @@ func TestParseSOLSmartdDevices_Dedup(t *testing.T) { } } +// SAS drives (and SATA drives behind a SAS HBA in SCSI mode) print a different +// smartd line shape: no [SAT] tag, a bracketed SCSI INQUIRY triple, "lu id", +// spaced "S/N: ", and no firmware field. +const solSmartdSCSISample = ` +[ 21.481033] smartd[2015]: Device: /dev/sdb, [SEAGATE ST6000NM005B K0A1], lu id: 0x5000c500ee6e11f7, S/N: WX004FCC0000E22967PY, 6.00 TB +[ 21.481212] smartd[2015]: Device: /dev/sdd, [SEAGATE ST6000NM005B K0A1], lu id: 0x5000c500ee6f5ebf, S/N: WX004TKE0000E233A32A, 6.00 TB +[ 21.481235] smartd[2015]: Device: /dev/sdd, is SMART capable. Adding to "monitor" list. +` + +func TestParseSOLSmartdDevices_SCSI(t *testing.T) { + devices := parseSOLSmartdDevices([]byte(solSmartdSCSISample)) + if len(devices) != 2 { + t.Fatalf("expected 2 SCSI devices, got %d: %v", len(devices), devices) + } + d := devices[0] + if d.Serial != "WX004FCC0000E22967PY" { + t.Errorf("serial: got %q", d.Serial) + } + if d.Model != "SEAGATE ST6000NM005B" { + t.Errorf("model: got %q, want %q", d.Model, "SEAGATE ST6000NM005B") + } + if d.SizeGB != 6000 { + t.Errorf("size: got %d, want 6000", d.SizeGB) + } + if d.Interface != "SAS" { + t.Errorf("interface: got %q, want SAS", d.Interface) + } + if d.Firmware != "" { + t.Errorf("firmware: got %q, want empty (not reported for SCSI)", d.Firmware) + } +} + +func TestParseSOLSmartdDevices_MixedSATAandSCSI(t *testing.T) { + devices := parseSOLSmartdDevices([]byte(solSmartdSample + solSmartdSCSISample)) + if len(devices) != 6 { + t.Fatalf("expected 4 SAT + 2 SCSI = 6 devices, got %d", len(devices)) + } + got := map[string]string{} + for _, d := range devices { + got[d.Serial] = d.Interface + } + if got["2310400DC7E3"] != "SATA" { + t.Errorf("SAT device interface: got %q", got["2310400DC7E3"]) + } + if got["WX004FCC0000E22967PY"] != "SAS" { + t.Errorf("SCSI device interface: got %q", got["WX004FCC0000E22967PY"]) + } +} + +func TestScsiInquiryModel(t *testing.T) { + cases := []struct{ raw, want string }{ + {"SEAGATE ST6000NM005B K0A1", "SEAGATE ST6000NM005B"}, + {"ATA Micron_5400_MTFD K0A1", "Micron_5400_MTFD"}, + {"SEAGATE ST6000NM005B", "SEAGATE ST6000NM005B"}, + {"BareModel", "BareModel"}, + } + for _, c := range cases { + if got := scsiInquiryModel(c.raw); got != c.want { + t.Errorf("scsiInquiryModel(%q) = %q, want %q", c.raw, got, c.want) + } + } +} + func TestParseSOLSmartdDevices_SkipsNonInfoLines(t *testing.T) { content := ` [ 17.886177] smartd[3321]: Device: /dev/sda [SAT], state written to /var/lib/smartmontools/smartd.foo.ata.state