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
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 `<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`.
+217 -38
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -504,17 +506,25 @@ 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 {
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"`
@@ -529,35 +539,26 @@ type NetworkAdapterRESTInfo struct {
ID int `json:"id"`
MacAddr string `json:"mac_addr"`
} `json:"ports"`
} `json:"sys_adapters"`
}
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",
})
}
+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) {
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:
[
+10 -1
View File
@@ -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...)
+77 -20
View File
@@ -10,12 +10,24 @@ 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 {
@@ -23,6 +35,7 @@ type solSmartdDevice struct {
Serial string
Firmware string
SizeGB int
Interface string // "SATA" or "SAS"
}
// parseSOLSmartdDevices extracts unique disk entries from SOLHostCapture.log content.
@@ -32,29 +45,66 @@ 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{}{}
out = append(out, dev)
}
return out
}
sizeGB := parseSolSizeGB(m[4], m[5])
out = append(out, solSmartdDevice{
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: sizeGB,
})
SizeGB: parseSolSizeGB(m[4], m[5]),
Interface: "SATA",
}, true
}
return out
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.
@@ -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 {
+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) {
content := `
[ 17.886177] smartd[3321]: Device: /dev/sda [SAT], state written to /var/lib/smartmontools/smartd.foo.ata.state