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
|
||||
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
@@ -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
@@ -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) {
|
||||
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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user