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:
co-authored by
Claude Sonnet 5
parent
ab8636da04
commit
2fa0f78f94
@@ -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`.
|
||||||
|
|||||||
+217
-38
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -504,17 +506,25 @@ 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sysAdapter struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Location string `json:"Location"`
|
Location string `json:"Location"`
|
||||||
Present int `json:"present"`
|
Present int `json:"present"`
|
||||||
Slot int `json:"slot"`
|
Slot int `json:"slot"`
|
||||||
|
PcieBus int `json:"pcie_bus"`
|
||||||
|
PcieDev int `json:"pcie_dev"`
|
||||||
|
PcieFunc int `json:"pcie_func"`
|
||||||
VendorID int `json:"vendor_id"`
|
VendorID int `json:"vendor_id"`
|
||||||
DeviceID int `json:"device_id"`
|
DeviceID int `json:"device_id"`
|
||||||
Vendor string `json:"vendor"`
|
Vendor string `json:"vendor"`
|
||||||
@@ -529,35 +539,26 @@ type NetworkAdapterRESTInfo struct {
|
|||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
MacAddr string `json:"mac_addr"`
|
MacAddr string `json:"mac_addr"`
|
||||||
} `json:"ports"`
|
} `json:"ports"`
|
||||||
} `json:"sys_adapters"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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
@@ -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:
|
||||||
[
|
[
|
||||||
|
|||||||
+10
-1
@@ -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...)
|
||||||
|
|||||||
+77
-20
@@ -10,12 +10,24 @@ 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 {
|
||||||
@@ -23,6 +35,7 @@ type solSmartdDevice struct {
|
|||||||
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,29 +45,66 @@ 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)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
sizeGB := parseSolSizeGB(m[4], m[5])
|
func parseSOLSmartdLine(line string) (solSmartdDevice, bool) {
|
||||||
out = append(out, solSmartdDevice{
|
if m := solSmartdSATDeviceRe.FindStringSubmatch(line); m != nil {
|
||||||
|
serial := strings.TrimSpace(m[2])
|
||||||
|
if serial == "" {
|
||||||
|
return solSmartdDevice{}, false
|
||||||
|
}
|
||||||
|
return solSmartdDevice{
|
||||||
Model: strings.TrimSpace(m[1]),
|
Model: strings.TrimSpace(m[1]),
|
||||||
Serial: serial,
|
Serial: serial,
|
||||||
Firmware: strings.TrimSpace(m[3]),
|
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.
|
// 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)
|
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user