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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LffAvostt3uMkiUbVUiyM
This commit is contained in:
Mikhail Chusavitin
2026-09-01 11:58:55 +03:00
co-authored by Claude Sonnet 5
parent ab8636da04
commit 2fa0f78f94
9 changed files with 780 additions and 99 deletions
+69
View File
@@ -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 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 bundles are ones reanimator does not track, so alternating collection methods for
one server produces no spurious install/remove/firmware events. 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 `<number> <unit>` (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`.
+236 -57
View File
@@ -4,6 +4,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"regexp" "regexp"
"sort"
"strconv"
"strings" "strings"
"time" "time"
@@ -504,60 +506,59 @@ type FanRESTInfo struct {
MaxSpeedRPM int `json:"max_speed_rpm"` MaxSpeedRPM int `json:"max_speed_rpm"`
FanModel string `json:"fan_model"` FanModel string `json:"fan_model"`
} `json:"fans"` } `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 // NetworkAdapterRESTInfo represents the RESTful Network Adapter info structure
type NetworkAdapterRESTInfo struct { type NetworkAdapterRESTInfo struct {
SysAdapters []struct { SysAdapters []sysAdapter `json:"sys_adapters"`
ID int `json:"id"` }
Name string `json:"name"`
Location string `json:"Location"` type sysAdapter struct {
Present int `json:"present"` ID int `json:"id"`
Slot int `json:"slot"` Name string `json:"name"`
VendorID int `json:"vendor_id"` Location string `json:"Location"`
DeviceID int `json:"device_id"` Present int `json:"present"`
Vendor string `json:"vendor"` Slot int `json:"slot"`
Model string `json:"model"` PcieBus int `json:"pcie_bus"`
FwVer string `json:"fw_ver"` PcieDev int `json:"pcie_dev"`
Status string `json:"status"` PcieFunc int `json:"pcie_func"`
SN string `json:"sn"` VendorID int `json:"vendor_id"`
PN string `json:"pn"` DeviceID int `json:"device_id"`
PortNum int `json:"port_num"` Vendor string `json:"vendor"`
PortType string `json:"port_type"` Model string `json:"model"`
Ports []struct { FwVer string `json:"fw_ver"`
ID int `json:"id"` Status string `json:"status"`
MacAddr string `json:"mac_addr"` SN string `json:"sn"`
} `json:"ports"` PN string `json:"pn"`
} `json:"sys_adapters"` 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) { 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`) re := regexp.MustCompile(`RESTful Network Adapter info:\s*(\{[\s\S]*?\})\s*RESTful fan`)
match := re.FindStringSubmatch(text) match := re.FindStringSubmatch(text)
if match == nil { if match == nil {
return return
} }
jsonStr := match[1]
jsonStr = strings.ReplaceAll(jsonStr, "\n", "")
var netInfo NetworkAdapterRESTInfo 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 return
} }
var merged []models.NetworkAdapter // BIOS "PCIE_n_INFO" lines enumerate the real BDF of every PCI function, which
seen := make(map[string]int) // is the identity the live-CD export and the Reanimator contract key NICs by.
for _, existing := range hw.NetworkAdapters { biosFuncs := parseBIOSPCIeFunctions(text)
key := inspurNICKey(existing)
if key == "" { merged := append([]models.NetworkAdapter(nil), hw.NetworkAdapters...)
continue
}
seen[key] = len(merged)
merged = append(merged, existing)
}
for _, adapter := range netInfo.SysAdapters { for _, adapter := range netInfo.SysAdapters {
var macs []string var macs []string
for _, port := range adapter.Ports { for _, port := range adapter.Ports {
@@ -577,8 +578,7 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) {
vendor = normalizeModelLabel(pciids.VendorName(adapter.VendorID)) vendor = normalizeModelLabel(pciids.VendorName(adapter.VendorID))
} }
item := models.NetworkAdapter{ base := models.NetworkAdapter{
Slot: fmt.Sprintf("Slot %d", adapter.Slot),
Location: adapter.Location, Location: adapter.Location,
Present: adapter.Present == 1, Present: adapter.Present == 1,
Model: model, Model: model,
@@ -590,29 +590,212 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) {
Firmware: normalizeRedisValue(adapter.FwVer), Firmware: normalizeRedisValue(adapter.FwVer),
PortCount: adapter.PortNum, PortCount: adapter.PortNum,
PortType: adapter.PortType, PortType: adapter.PortType,
MACAddresses: macs,
Status: adapter.Status, Status: adapter.Status,
} }
key := inspurNICKey(item)
if idx, ok := seen[key]; ok { // If another source (asset.json PcieInfo) already recorded this card's PCI
mergeInspurNIC(&merged[idx], item) // functions, enrich those in place — don't add a competing set of records.
if enrichExistingPCIeNICByBus(hw, adapter.PcieBus, base, macs) {
continue continue
} }
if slotIdx := inspurFindNICBySlot(merged, item.Slot); slotIdx >= 0 {
mergeInspurNIC(&merged[slotIdx], item) funcs := selectBIOSPCIeFunctions(biosFuncs, adapter.PcieBus, adapter.VendorID, adapter.DeviceID)
if key != "" { if len(funcs) == 0 && adapter.PcieBus > 0 {
seen[key] = slotIdx // 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 continue
} }
if key != "" {
seen[key] = len(merged) // No PCI location at all: keep the card-level record (label-only slot).
} item := base
merged = append(merged, item) item.Slot = fmt.Sprintf("Slot %d", adapter.Slot)
item.MACAddresses = macs
upsertInspurNIC(&merged, item)
} }
hw.NetworkAdapters = merged 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 { func inspurMemoryKey(item models.MemoryDIMM) string {
return strings.ToLower(strings.TrimSpace(inspurFirstNonEmpty(item.SerialNumber, item.Slot, item.Location))) 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) { func mergeInspurNIC(dst *models.NetworkAdapter, src models.NetworkAdapter) {
if dst == nil { if dst == nil {
return return
@@ -854,9 +1033,9 @@ func parseFanSensors(text string) []models.SensorReading {
out = append(out, models.SensorReading{ out = append(out, models.SensorReading{
Name: "Fans_Power", Name: "Fans_Power",
Type: "power", Type: "power",
Value: float64(fanInfo.FansPower), Value: fanInfo.FansPower,
Unit: "W", Unit: "W",
RawValue: fmt.Sprintf("%d", fanInfo.FansPower), RawValue: fmt.Sprintf("%g", fanInfo.FansPower),
Status: "OK", Status: "OK",
}) })
} }
+114
View File
@@ -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 ""
}
+61
View File
@@ -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)
}
}
+129
View File
@@ -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) { func TestParseComponentLogSensors_ExtractsFanBackplaneAndPSUSummary(t *testing.T) {
text := `RESTful PSU info: 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) { func TestParseHDDInfo_MergesIntoExistingStorage(t *testing.T) {
text := `RESTful HDD info: text := `RESTful HDD info:
[ [
+14 -14
View File
@@ -16,20 +16,20 @@ import (
// otrdDiagnosePCIeEntry mirrors the relevant fields of "Pcie Device Info" // otrdDiagnosePCIeEntry mirrors the relevant fields of "Pcie Device Info"
// entries in OtrdDiagnoseComponent.json. // entries in OtrdDiagnoseComponent.json.
type otrdDiagnosePCIeEntry struct { type otrdDiagnosePCIeEntry struct {
LocString string `json:"LocString"` LocString string `json:"LocString"`
PcieSlot int `json:"PcieSlot"` PcieSlot int `json:"PcieSlot"`
PresentStatus int `json:"PresentStatus"` PresentStatus int `json:"PresentStatus"`
VendorId int `json:"VendorId"` VendorId int `json:"VendorId"`
DeviceId int `json:"DeviceId"` DeviceId int `json:"DeviceId"`
BusNumber int `json:"BusNumber"` BusNumber int `json:"BusNumber"`
DeviceNumber int `json:"DeviceNumber"` DeviceNumber int `json:"DeviceNumber"`
FunctionNumber int `json:"FunctionNumber"` FunctionNumber int `json:"FunctionNumber"`
CurrentLinkSpeed int `json:"CurrentLinkSpeed"` CurrentLinkSpeed int `json:"CurrentLinkSpeed"`
MaxLinkSpeed int `json:"MaxLinkSpeed"` MaxLinkSpeed int `json:"MaxLinkSpeed"`
NegotiatedLinkWidth int `json:"NegotiatedLinkWidth"` NegotiatedLinkWidth int `json:"NegotiatedLinkWidth"`
MaxLinkWidth int `json:"MaxLinkWidth"` MaxLinkWidth int `json:"MaxLinkWidth"`
SerialNumber *string `json:"SerialNumber"` SerialNumber *string `json:"SerialNumber"`
PartNumber *string `json:"PartNumber"` PartNumber *string `json:"PartNumber"`
} }
type otrdDiagnoseComponent struct { type otrdDiagnoseComponent struct {
+10 -1
View File
@@ -16,7 +16,7 @@ import (
// parserVersion - version of this parser module // parserVersion - version of this parser module
// IMPORTANT: Increment this version when making changes to parser logic! // IMPORTANT: Increment this version when making changes to parser logic!
const parserVersion = "2.5" const parserVersion = "2.6"
func init() { func init() {
parser.Register(&Parser{}) 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 { if f := parser.FindFileByName(files, "component.log"); f != nil {
ParseComponentLog(f.Content, result.Hardware) 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.) // Extract events from component.log (memory errors, etc.)
componentEvents := ParseComponentLogEvents(f.Content) componentEvents := ParseComponentLogEvents(f.Content)
result.Events = append(result.Events, componentEvents...) result.Events = append(result.Events, componentEvents...)
+84 -27
View File
@@ -10,19 +10,32 @@ import (
"git.mchus.pro/mchus/logpile/internal/parser" "git.mchus.pro/mchus/logpile/internal/parser"
) )
// solSmartdDeviceRe matches smartd device info lines from SOLHostCapture.log. // smartd prints one device-info line per drive during startup. Two shapes occur,
// Example: // depending on how the drive is attached:
// //
// Device: /dev/sda [SAT], Micron_5400_MTFDDAK480TGA, S/N:2310400DC7E3, WWN:..., FW:D4CM003, 480 GB // SAT (SATA drive, ATA pass-through):
var solSmartdDeviceRe = regexp.MustCompile( // Device: /dev/sda [SAT], Micron_5400_MTFDDAK480TGA, S/N:2310400DC7E3, WWN:5-00a075-1400dc7e3, FW:D4CM003, 480 GB
`Device: /dev/\S+ \[SAT\], (.+?), S/N:(\S+),.*?FW:(\S+), ([\d.]+) (GB|TB)`, // 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 { type solSmartdDevice struct {
Model string Model string
Serial string Serial string
Firmware string Firmware string
SizeGB int SizeGB int
Interface string // "SATA" or "SAS"
} }
// parseSOLSmartdDevices extracts unique disk entries from SOLHostCapture.log content. // parseSOLSmartdDevices extracts unique disk entries from SOLHostCapture.log content.
@@ -32,31 +45,68 @@ func parseSOLSmartdDevices(content []byte) []solSmartdDevice {
var out []solSmartdDevice var out []solSmartdDevice
for _, line := range strings.Split(string(content), "\n") { for _, line := range strings.Split(string(content), "\n") {
m := solSmartdDeviceRe.FindStringSubmatch(line) dev, ok := parseSOLSmartdLine(line)
if m == nil { if !ok {
continue continue
} }
serial := strings.TrimSpace(m[2]) key := strings.ToLower(dev.Serial)
if serial == "" { if _, dup := seen[key]; dup {
continue
}
key := strings.ToLower(serial)
if _, ok := seen[key]; ok {
continue continue
} }
seen[key] = struct{}{} seen[key] = struct{}{}
out = append(out, dev)
sizeGB := parseSolSizeGB(m[4], m[5])
out = append(out, solSmartdDevice{
Model: strings.TrimSpace(m[1]),
Serial: serial,
Firmware: strings.TrimSpace(m[3]),
SizeGB: sizeGB,
})
} }
return out 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. // 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. // Uses decimal TB (1 TB = 1000 GB) matching disk manufacturer conventions.
func parseSolSizeGB(value, unit string) int { func parseSolSizeGB(value, unit string) int {
@@ -215,7 +265,7 @@ func solEnrichByPlaceholder(hw *models.HardwareConfig, devices []solSmartdDevice
hw.Storage[idx].Manufacturer = extractStorageManufacturer(d.Model) hw.Storage[idx].Manufacturer = extractStorageManufacturer(d.Model)
} }
if hw.Storage[idx].Interface == "" { if hw.Storage[idx].Interface == "" {
hw.Storage[idx].Interface = "SATA" hw.Storage[idx].Interface = solDeviceInterface(d)
} }
} }
return unmatched return unmatched
@@ -229,11 +279,18 @@ func solMakeStorage(d solSmartdDevice) models.Storage {
SizeGB: d.SizeGB, SizeGB: d.SizeGB,
Type: solStorageType(d.Model), Type: solStorageType(d.Model),
Manufacturer: extractStorageManufacturer(d.Model), Manufacturer: extractStorageManufacturer(d.Model),
Interface: "SATA", Interface: solDeviceInterface(d),
Present: true, Present: true,
} }
} }
func solDeviceInterface(d solSmartdDevice) string {
if d.Interface != "" {
return d.Interface
}
return "SATA"
}
// solStorageType infers SSD vs HDD from the model string. // solStorageType infers SSD vs HDD from the model string.
// Micron SSD models start with "MTFDD"; Intel SSDs contain "SSD". // Micron SSD models start with "MTFDD"; Intel SSDs contain "SSD".
func solStorageType(model string) string { func solStorageType(model string) string {
+63
View File
@@ -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) { func TestParseSOLSmartdDevices_SkipsNonInfoLines(t *testing.T) {
content := ` content := `
[ 17.886177] smartd[3321]: Device: /dev/sda [SAT], state written to /var/lib/smartmontools/smartd.foo.ata.state [ 17.886177] smartd[3321]: Device: /dev/sda [SAT], state written to /var/lib/smartmontools/smartd.foo.ata.state