1082 lines
30 KiB
Go
1082 lines
30 KiB
Go
package xfusion
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/logpile/internal/models"
|
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
|
)
|
|
|
|
type xfusionNICCard struct {
|
|
Slot string
|
|
Model string
|
|
ProductName string
|
|
Vendor string
|
|
VendorID int
|
|
DeviceID int
|
|
BDF string
|
|
SerialNumber string
|
|
PartNumber string
|
|
}
|
|
|
|
type xfusionNetcardPort struct {
|
|
BDF string
|
|
MAC string
|
|
ActualMAC string
|
|
}
|
|
|
|
type xfusionNetcardSnapshot struct {
|
|
Timestamp time.Time
|
|
Slot string
|
|
ProductName string
|
|
Manufacturer string
|
|
Firmware string
|
|
Ports []xfusionNetcardPort
|
|
}
|
|
|
|
// ── FRU ──────────────────────────────────────────────────────────────────────
|
|
|
|
// parseFRUInfo parses fruinfo.txt and populates result.FRU and result.Hardware.BoardInfo.
|
|
// The file contains IPMI FRU blocks separated by "FRU Device Description" header lines.
|
|
func parseFRUInfo(content []byte, result *models.AnalysisResult) {
|
|
type fruBlock struct {
|
|
header string
|
|
fields map[string]string
|
|
}
|
|
var blocks []fruBlock
|
|
var current *fruBlock
|
|
|
|
for _, line := range strings.Split(string(content), "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "FRU Device Description") {
|
|
if current != nil {
|
|
blocks = append(blocks, *current)
|
|
}
|
|
current = &fruBlock{header: trimmed, fields: make(map[string]string)}
|
|
continue
|
|
}
|
|
if current == nil {
|
|
continue
|
|
}
|
|
idx := strings.Index(trimmed, " : ")
|
|
if idx < 0 {
|
|
continue
|
|
}
|
|
key := strings.TrimSpace(trimmed[:idx])
|
|
val := strings.TrimSpace(trimmed[idx+3:])
|
|
if key != "" && current.fields[key] == "" {
|
|
current.fields[key] = val
|
|
}
|
|
}
|
|
if current != nil {
|
|
blocks = append(blocks, *current)
|
|
}
|
|
|
|
for _, b := range blocks {
|
|
f := b.fields
|
|
fru := models.FRUInfo{
|
|
Description: extractFRUHeaderDesc(b.header),
|
|
Manufacturer: firstNonEmpty(f["Board Manufacturer"], f["Product Manufacturer"]),
|
|
ProductName: f["Product Name"],
|
|
SerialNumber: firstNonEmpty(f["Product Serial Number"], f["Board Serial Number"]),
|
|
PartNumber: firstNonEmpty(f["Product Part Number"], f["Board Part Number"]),
|
|
MfgDate: f["Board Mfg. Date"],
|
|
}
|
|
if fru.Description != "" || fru.ProductName != "" || fru.SerialNumber != "" {
|
|
result.FRU = append(result.FRU, fru)
|
|
}
|
|
}
|
|
|
|
// Set BoardInfo from the mainboard block (ID 0).
|
|
for _, b := range blocks {
|
|
hdr := strings.ToLower(b.header)
|
|
if strings.Contains(hdr, "id 0") || strings.Contains(hdr, "mainboard") {
|
|
f := b.fields
|
|
result.Hardware.BoardInfo = models.BoardInfo{
|
|
Manufacturer: firstNonEmpty(f["Product Manufacturer"], f["Board Manufacturer"]),
|
|
ProductName: firstNonEmpty(f["Product Name"], f["Board Product Name"]),
|
|
SerialNumber: firstNonEmpty(f["Product Serial Number"], f["Board Serial Number"]),
|
|
PartNumber: firstNonEmpty(f["Product Part Number"], f["Board Part Number"]),
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func extractFRUHeaderDesc(header string) string {
|
|
// "FRU Device Description : Builtin FRU Device (ID 0, Mainboard)"
|
|
idx := strings.Index(header, " : ")
|
|
if idx >= 0 {
|
|
return strings.TrimSpace(header[idx+3:])
|
|
}
|
|
return header
|
|
}
|
|
|
|
func firstNonEmpty(vals ...string) string {
|
|
for _, v := range vals {
|
|
v = strings.TrimSpace(v)
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ── Sensors ───────────────────────────────────────────────────────────────────
|
|
|
|
// parseSensorInfo parses the pipe-delimited IPMI sensor table from sensor_info.txt.
|
|
// Columns: sensor id | sensor name | value | unit | status | thresholds...
|
|
func parseSensorInfo(content []byte) []models.SensorReading {
|
|
var sensors []models.SensorReading
|
|
inTable := false
|
|
|
|
for _, line := range strings.Split(string(content), "\n") {
|
|
if strings.Contains(line, "sensor id") && strings.Contains(line, "sensor name") {
|
|
inTable = true
|
|
continue
|
|
}
|
|
if inTable && strings.HasPrefix(strings.TrimSpace(line), "**") {
|
|
// "*** Detailed Voltage Object Information ***" signals end of main table
|
|
inTable = false
|
|
continue
|
|
}
|
|
if !inTable || !strings.Contains(line, "|") {
|
|
continue
|
|
}
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) < 5 {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(parts[1])
|
|
valueStr := strings.TrimSpace(parts[2])
|
|
unit := strings.TrimSpace(parts[3])
|
|
status := strings.TrimSpace(parts[4])
|
|
|
|
if name == "" || valueStr == "na" || unit == "discrete" || unit == "unspecified" {
|
|
continue
|
|
}
|
|
value, err := strconv.ParseFloat(valueStr, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
sensors = append(sensors, models.SensorReading{
|
|
Name: name,
|
|
Type: sensorType(name, unit),
|
|
Value: value,
|
|
Unit: mapSensorUnit(unit),
|
|
RawValue: valueStr,
|
|
Status: status,
|
|
})
|
|
}
|
|
return sensors
|
|
}
|
|
|
|
func mapSensorUnit(u string) string {
|
|
switch strings.ToLower(strings.TrimSpace(u)) {
|
|
case "degrees c":
|
|
return "C"
|
|
case "volts":
|
|
return "V"
|
|
case "watts":
|
|
return "W"
|
|
case "rpm":
|
|
return "RPM"
|
|
case "amps":
|
|
return "A"
|
|
default:
|
|
return u
|
|
}
|
|
}
|
|
|
|
func sensorType(name, unit string) string {
|
|
u := strings.ToLower(unit)
|
|
n := strings.ToLower(name)
|
|
switch {
|
|
case strings.Contains(u, "degrees"):
|
|
return "temperature"
|
|
case strings.Contains(u, "volts"):
|
|
return "voltage"
|
|
case strings.Contains(u, "watts"):
|
|
return "power"
|
|
case strings.Contains(u, "rpm") || strings.Contains(n, "fan") && strings.Contains(n, "speed"):
|
|
return "fan"
|
|
case strings.Contains(u, "amps"):
|
|
return "current"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// ── CPU ───────────────────────────────────────────────────────────────────────
|
|
|
|
// parseCPUInfo parses the comma-separated cpu_info file.
|
|
// Columns: slot, presence, model, processorID, cores, threads, flags, L1, L2, L3, partNum, devName, location, SN
|
|
func parseCPUInfo(content []byte) []models.CPU {
|
|
var cpus []models.CPU
|
|
lines := strings.Split(string(content), "\n")
|
|
for i, line := range lines {
|
|
if i == 0 { // skip header
|
|
continue
|
|
}
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
parts := strings.Split(line, ",")
|
|
if len(parts) < 6 {
|
|
continue
|
|
}
|
|
slot := strings.TrimSpace(parts[0])
|
|
if !strings.HasPrefix(strings.ToLower(slot), "cpu") {
|
|
continue
|
|
}
|
|
if strings.ToLower(strings.TrimSpace(parts[1])) != "present" {
|
|
continue
|
|
}
|
|
|
|
socketNum := 0
|
|
fmt.Sscanf(strings.ToLower(slot), "cpu%d", &socketNum)
|
|
|
|
model := strings.TrimSpace(parts[2])
|
|
cores := 0
|
|
fmt.Sscanf(strings.TrimSpace(parts[4]), "%d", &cores)
|
|
threads := 0
|
|
fmt.Sscanf(strings.TrimSpace(parts[5]), "%d", &threads)
|
|
|
|
l1, l2, l3 := 0, 0, 0
|
|
if len(parts) >= 10 {
|
|
l1 = parseCacheSizeKB(parts[7])
|
|
l2 = parseCacheSizeKB(parts[8])
|
|
l3 = parseCacheSizeKB(parts[9])
|
|
}
|
|
|
|
sn := ""
|
|
if len(parts) >= 14 {
|
|
sn = strings.TrimSpace(parts[13])
|
|
}
|
|
|
|
cpus = append(cpus, models.CPU{
|
|
Socket: socketNum,
|
|
Model: model,
|
|
Cores: cores,
|
|
Threads: threads,
|
|
L1CacheKB: l1,
|
|
L2CacheKB: l2,
|
|
L3CacheKB: l3,
|
|
SerialNumber: sn,
|
|
Status: "ok",
|
|
})
|
|
}
|
|
return cpus
|
|
}
|
|
|
|
func parseCacheSizeKB(s string) int {
|
|
var n int
|
|
fmt.Sscanf(strings.TrimSpace(s), "%d", &n)
|
|
return n
|
|
}
|
|
|
|
// ── Memory ────────────────────────────────────────────────────────────────────
|
|
|
|
// parseMemInfo parses the comma-separated mem_info file.
|
|
// Columns: slot, location, dimmName, manufacturer, size, maxSpeed, curSpeed, type, SN, voltage, rank, bitWidth, tech, bom, partNum, ..., health
|
|
func parseMemInfo(content []byte) []models.MemoryDIMM {
|
|
var dimms []models.MemoryDIMM
|
|
lines := strings.Split(string(content), "\n")
|
|
for i, line := range lines {
|
|
if i == 0 {
|
|
continue
|
|
}
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
parts := strings.Split(line, ",")
|
|
if len(parts) < 9 {
|
|
continue
|
|
}
|
|
|
|
sn := strings.TrimSpace(parts[8])
|
|
if strings.ToLower(sn) == "no dimm" || sn == "" {
|
|
continue
|
|
}
|
|
|
|
slot := strings.TrimSpace(parts[0])
|
|
location := strings.TrimSpace(parts[1])
|
|
manufacturer := strings.TrimSpace(parts[3])
|
|
if strings.ToLower(manufacturer) == "unknown" {
|
|
manufacturer = ""
|
|
}
|
|
|
|
sizeMB := 0
|
|
fmt.Sscanf(strings.TrimSpace(parts[4]), "%d MB", &sizeMB)
|
|
|
|
maxSpeedMHz := 0
|
|
fmt.Sscanf(strings.TrimSpace(parts[5]), "%d MT/s", &maxSpeedMHz)
|
|
|
|
curSpeedMHz := 0
|
|
fmt.Sscanf(strings.TrimSpace(parts[6]), "%d MT/s", &curSpeedMHz)
|
|
|
|
memType := strings.TrimSpace(parts[7])
|
|
if strings.ToLower(memType) == "unknown" {
|
|
memType = ""
|
|
}
|
|
|
|
ranks := 0
|
|
if len(parts) >= 11 {
|
|
fmt.Sscanf(strings.TrimSpace(parts[10]), "%d rank", &ranks)
|
|
}
|
|
|
|
partNum := ""
|
|
if len(parts) >= 15 {
|
|
v := strings.TrimSpace(parts[14])
|
|
if strings.ToLower(v) != "no dimm" && strings.ToLower(v) != "unknown" {
|
|
partNum = v
|
|
}
|
|
}
|
|
|
|
status := "ok"
|
|
if len(parts) >= 22 {
|
|
if s := strings.TrimSpace(parts[21]); strings.ToLower(s) != "ok" && s != "" {
|
|
status = strings.ToLower(s)
|
|
}
|
|
}
|
|
|
|
dimms = append(dimms, models.MemoryDIMM{
|
|
Slot: slot,
|
|
Location: location,
|
|
Present: true,
|
|
SizeMB: sizeMB,
|
|
Type: memType,
|
|
MaxSpeedMHz: maxSpeedMHz,
|
|
CurrentSpeedMHz: curSpeedMHz,
|
|
Manufacturer: manufacturer,
|
|
SerialNumber: sn,
|
|
PartNumber: partNum,
|
|
Ranks: ranks,
|
|
Status: status,
|
|
})
|
|
}
|
|
return dimms
|
|
}
|
|
|
|
// ── Card Info (GPU + NIC) ─────────────────────────────────────────────────────
|
|
|
|
// parseCardInfo parses card_info file, extracting GPU and OCP NIC card inventory.
|
|
// The file has named sections ("GPU Card Info", "OCP Card Info", etc.) each with a pipe-table.
|
|
func parseCardInfo(content []byte) (gpus []models.GPU, nicCards []xfusionNICCard) {
|
|
sections := splitPipeSections(content)
|
|
|
|
// Build BDF and VendorID/DeviceID map from PCIe Card Info: slot → info
|
|
type pcieEntry struct {
|
|
bdf string
|
|
vendorID int
|
|
deviceID int
|
|
desc string
|
|
}
|
|
slotPCIe := make(map[string]pcieEntry)
|
|
for _, row := range sections["pcie card info"] {
|
|
slot := strings.TrimSpace(row["slot"])
|
|
seg := parseHexInt(row["segment number"])
|
|
bus := parseHexInt(row["bus number"])
|
|
dev := parseHexInt(row["device number"])
|
|
fn := parseHexInt(row["function number"])
|
|
slotPCIe[slot] = pcieEntry{
|
|
bdf: fmt.Sprintf("%04x:%02x:%02x.%d", seg, bus, dev, fn),
|
|
vendorID: parseHexInt(row["vender id"]),
|
|
deviceID: parseHexInt(row["device id"]),
|
|
desc: strings.TrimSpace(row["card desc"]),
|
|
}
|
|
}
|
|
|
|
// GPU Card Info: slot, name, manufacturer, serialNum, firmVer, SBE/DBE counts
|
|
for _, row := range sections["gpu card info"] {
|
|
slot := strings.TrimSpace(row["slot"])
|
|
name := strings.TrimSpace(row["name"])
|
|
manufacturer := strings.TrimSpace(row["manufacturer"])
|
|
serial := strings.TrimSpace(row["serialnum"])
|
|
firmware := strings.TrimSpace(row["firmver"])
|
|
|
|
sbeCount, dbeCount := 0, 0
|
|
fmt.Sscanf(strings.TrimSpace(row["sbe"]), "%d", &sbeCount)
|
|
fmt.Sscanf(strings.TrimSpace(row["dbe"]), "%d", &dbeCount)
|
|
|
|
pcie := slotPCIe[slot]
|
|
gpu := models.GPU{
|
|
Slot: slot,
|
|
Model: name,
|
|
Manufacturer: manufacturer,
|
|
SerialNumber: serial,
|
|
Firmware: firmware,
|
|
BDF: pcie.bdf,
|
|
VendorID: pcie.vendorID,
|
|
DeviceID: pcie.deviceID,
|
|
Status: "ok",
|
|
}
|
|
if dbeCount > 0 {
|
|
gpu.Status = "warning"
|
|
}
|
|
gpus = append(gpus, gpu)
|
|
}
|
|
|
|
// OCP Card Info: NIC cards
|
|
for _, row := range sections["ocp card info"] {
|
|
slot := strings.TrimSpace(row["slot"])
|
|
pcie := slotPCIe[slot]
|
|
nicCards = append(nicCards, xfusionNICCard{
|
|
Slot: slot,
|
|
Model: strings.TrimSpace(row["card desc"]),
|
|
ProductName: strings.TrimSpace(row["card desc"]),
|
|
VendorID: parseHexInt(row["vender id"]),
|
|
DeviceID: parseHexInt(row["device id"]),
|
|
BDF: pcie.bdf,
|
|
SerialNumber: strings.TrimSpace(row["serialnumber"]),
|
|
PartNumber: strings.TrimSpace(row["partnum"]),
|
|
})
|
|
}
|
|
|
|
return gpus, nicCards
|
|
}
|
|
|
|
// splitPipeSections parses a multi-section file where each section starts with a
|
|
// plain header line (no "|") ending in "Info" or "info", followed by a pipe-table.
|
|
// Returns a map from lowercased section name → rows (each row is a map of lowercase header → value).
|
|
func splitPipeSections(content []byte) map[string][]map[string]string {
|
|
result := make(map[string][]map[string]string)
|
|
var sectionName string
|
|
var headers []string
|
|
|
|
for _, line := range strings.Split(string(content), "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
if !strings.Contains(trimmed, "|") {
|
|
if strings.HasSuffix(strings.ToLower(trimmed), "info") {
|
|
sectionName = strings.ToLower(trimmed)
|
|
headers = nil
|
|
}
|
|
continue
|
|
}
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) < 2 {
|
|
continue
|
|
}
|
|
cols := make([]string, len(parts))
|
|
for i, p := range parts {
|
|
cols[i] = strings.TrimSpace(p)
|
|
}
|
|
if headers == nil {
|
|
headers = make([]string, len(cols))
|
|
for i, h := range cols {
|
|
headers[i] = strings.ToLower(h)
|
|
}
|
|
continue
|
|
}
|
|
row := make(map[string]string, len(headers))
|
|
for i, h := range headers {
|
|
if i < len(cols) {
|
|
row[h] = cols[i]
|
|
}
|
|
}
|
|
result[sectionName] = append(result[sectionName], row)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func parseHexInt(s string) int {
|
|
s = strings.TrimSpace(s)
|
|
s = strings.TrimPrefix(strings.ToLower(s), "0x")
|
|
n, _ := strconv.ParseInt(s, 16, 64)
|
|
return int(n)
|
|
}
|
|
|
|
func parseNetcardInfo(content []byte) []xfusionNetcardSnapshot {
|
|
if len(content) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var snapshots []xfusionNetcardSnapshot
|
|
var current *xfusionNetcardSnapshot
|
|
var currentPort *xfusionNetcardPort
|
|
|
|
flushPort := func() {
|
|
if current == nil || currentPort == nil {
|
|
return
|
|
}
|
|
current.Ports = append(current.Ports, *currentPort)
|
|
currentPort = nil
|
|
}
|
|
flushSnapshot := func() {
|
|
if current == nil || !current.hasData() {
|
|
return
|
|
}
|
|
flushPort()
|
|
snapshots = append(snapshots, *current)
|
|
current = nil
|
|
}
|
|
|
|
for _, rawLine := range strings.Split(string(content), "\n") {
|
|
line := strings.TrimSpace(rawLine)
|
|
if line == "" {
|
|
flushPort()
|
|
continue
|
|
}
|
|
if ts, ok := parseXFusionUTCTimestamp(line); ok {
|
|
if current == nil {
|
|
current = &xfusionNetcardSnapshot{Timestamp: ts}
|
|
continue
|
|
}
|
|
if current.hasData() {
|
|
flushSnapshot()
|
|
current = &xfusionNetcardSnapshot{Timestamp: ts}
|
|
continue
|
|
}
|
|
current.Timestamp = ts
|
|
continue
|
|
}
|
|
if current == nil {
|
|
current = &xfusionNetcardSnapshot{}
|
|
}
|
|
if port := parseNetcardPortHeader(line); port != nil {
|
|
flushPort()
|
|
currentPort = port
|
|
continue
|
|
}
|
|
if currentPort != nil {
|
|
if value, ok := parseSimpleKV(line, "MacAddr"); ok {
|
|
currentPort.MAC = value
|
|
continue
|
|
}
|
|
if value, ok := parseSimpleKV(line, "ActualMac"); ok {
|
|
currentPort.ActualMAC = value
|
|
continue
|
|
}
|
|
}
|
|
if value, ok := parseSimpleKV(line, "ProductName"); ok {
|
|
current.ProductName = value
|
|
continue
|
|
}
|
|
if value, ok := parseSimpleKV(line, "Manufacture"); ok {
|
|
current.Manufacturer = value
|
|
continue
|
|
}
|
|
if value, ok := parseSimpleKV(line, "FirmwareVersion"); ok {
|
|
current.Firmware = value
|
|
continue
|
|
}
|
|
if value, ok := parseSimpleKV(line, "SlotId"); ok {
|
|
current.Slot = value
|
|
}
|
|
}
|
|
flushSnapshot()
|
|
|
|
bestIndexBySlot := make(map[string]int)
|
|
for i, snapshot := range snapshots {
|
|
slot := strings.TrimSpace(snapshot.Slot)
|
|
if slot == "" {
|
|
continue
|
|
}
|
|
prevIdx, exists := bestIndexBySlot[slot]
|
|
if !exists || snapshot.isBetterThan(snapshots[prevIdx]) {
|
|
bestIndexBySlot[slot] = i
|
|
}
|
|
}
|
|
|
|
ordered := make([]xfusionNetcardSnapshot, 0, len(bestIndexBySlot))
|
|
for i, snapshot := range snapshots {
|
|
slot := strings.TrimSpace(snapshot.Slot)
|
|
bestIdx, ok := bestIndexBySlot[slot]
|
|
if !ok || bestIdx != i {
|
|
continue
|
|
}
|
|
ordered = append(ordered, snapshot)
|
|
delete(bestIndexBySlot, slot)
|
|
}
|
|
return ordered
|
|
}
|
|
|
|
func mergeNetworkAdapters(cards []xfusionNICCard, snapshots []xfusionNetcardSnapshot) ([]models.NetworkAdapter, []models.NIC) {
|
|
bySlotCard := make(map[string]xfusionNICCard, len(cards))
|
|
bySlotSnapshot := make(map[string]xfusionNetcardSnapshot, len(snapshots))
|
|
orderedSlots := make([]string, 0, len(cards)+len(snapshots))
|
|
seenSlots := make(map[string]struct{}, len(cards)+len(snapshots))
|
|
|
|
for _, card := range cards {
|
|
slot := strings.TrimSpace(card.Slot)
|
|
if slot == "" {
|
|
continue
|
|
}
|
|
bySlotCard[slot] = card
|
|
if _, seen := seenSlots[slot]; !seen {
|
|
orderedSlots = append(orderedSlots, slot)
|
|
seenSlots[slot] = struct{}{}
|
|
}
|
|
}
|
|
for _, snapshot := range snapshots {
|
|
slot := strings.TrimSpace(snapshot.Slot)
|
|
if slot == "" {
|
|
continue
|
|
}
|
|
bySlotSnapshot[slot] = snapshot
|
|
if _, seen := seenSlots[slot]; !seen {
|
|
orderedSlots = append(orderedSlots, slot)
|
|
seenSlots[slot] = struct{}{}
|
|
}
|
|
}
|
|
|
|
adapters := make([]models.NetworkAdapter, 0, len(orderedSlots))
|
|
legacyNICs := make([]models.NIC, 0, len(orderedSlots))
|
|
for _, slot := range orderedSlots {
|
|
card := bySlotCard[slot]
|
|
snapshot := bySlotSnapshot[slot]
|
|
|
|
model := firstNonEmpty(card.Model, snapshot.ProductName)
|
|
description := ""
|
|
if !strings.EqualFold(strings.TrimSpace(model), strings.TrimSpace(snapshot.ProductName)) {
|
|
description = strings.TrimSpace(snapshot.ProductName)
|
|
}
|
|
macs := snapshot.macAddresses()
|
|
bdf := firstNonEmpty(snapshot.primaryBDF(), card.BDF)
|
|
firmware := normalizeXFusionValue(snapshot.Firmware)
|
|
manufacturer := firstNonEmpty(snapshot.Manufacturer, card.Vendor)
|
|
portCount := len(snapshot.Ports)
|
|
if portCount == 0 && len(macs) > 0 {
|
|
portCount = len(macs)
|
|
}
|
|
if portCount == 0 {
|
|
portCount = 1
|
|
}
|
|
|
|
adapters = append(adapters, models.NetworkAdapter{
|
|
Slot: slot,
|
|
Location: "OCP",
|
|
Present: true,
|
|
BDF: bdf,
|
|
Model: model,
|
|
Description: description,
|
|
Vendor: manufacturer,
|
|
VendorID: card.VendorID,
|
|
DeviceID: card.DeviceID,
|
|
SerialNumber: card.SerialNumber,
|
|
PartNumber: card.PartNumber,
|
|
Firmware: firmware,
|
|
PortCount: portCount,
|
|
PortType: "ethernet",
|
|
MACAddresses: macs,
|
|
Status: "ok",
|
|
})
|
|
legacyNICs = append(legacyNICs, models.NIC{
|
|
Name: fmt.Sprintf("OCP%s", slot),
|
|
Model: model,
|
|
Description: description,
|
|
MACAddress: firstNonEmpty(macs...),
|
|
SerialNumber: card.SerialNumber,
|
|
})
|
|
}
|
|
|
|
return adapters, legacyNICs
|
|
}
|
|
|
|
func parseXFusionUTCTimestamp(line string) (time.Time, bool) {
|
|
ts, err := time.Parse("2006-01-02 15:04:05 MST", strings.TrimSpace(line))
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
return ts, true
|
|
}
|
|
|
|
func parseNetcardPortHeader(line string) *xfusionNetcardPort {
|
|
fields := strings.Fields(strings.TrimSpace(line))
|
|
if len(fields) < 2 || !strings.HasPrefix(strings.ToLower(fields[0]), "port") {
|
|
return nil
|
|
}
|
|
joined := strings.Join(fields[1:], " ")
|
|
if !strings.HasPrefix(strings.ToLower(joined), "bdf:") {
|
|
return nil
|
|
}
|
|
return &xfusionNetcardPort{BDF: strings.TrimSpace(joined[len("BDF:"):])}
|
|
}
|
|
|
|
func parseSimpleKV(line, key string) (string, bool) {
|
|
idx := strings.Index(line, ":")
|
|
if idx < 0 {
|
|
return "", false
|
|
}
|
|
gotKey := strings.TrimSpace(line[:idx])
|
|
if !strings.EqualFold(gotKey, key) {
|
|
return "", false
|
|
}
|
|
return strings.TrimSpace(line[idx+1:]), true
|
|
}
|
|
|
|
func normalizeXFusionValue(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
switch strings.ToUpper(value) {
|
|
case "", "N/A", "NA", "UNKNOWN":
|
|
return ""
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
func (s xfusionNetcardSnapshot) hasData() bool {
|
|
return strings.TrimSpace(s.Slot) != "" ||
|
|
strings.TrimSpace(s.ProductName) != "" ||
|
|
strings.TrimSpace(s.Manufacturer) != "" ||
|
|
strings.TrimSpace(s.Firmware) != "" ||
|
|
len(s.Ports) > 0
|
|
}
|
|
|
|
func (s xfusionNetcardSnapshot) score() int {
|
|
score := len(s.Ports)
|
|
if normalizeXFusionValue(s.Firmware) != "" {
|
|
score += 10
|
|
}
|
|
score += len(s.macAddresses()) * 2
|
|
return score
|
|
}
|
|
|
|
func (s xfusionNetcardSnapshot) isBetterThan(other xfusionNetcardSnapshot) bool {
|
|
if s.score() != other.score() {
|
|
return s.score() > other.score()
|
|
}
|
|
if !s.Timestamp.Equal(other.Timestamp) {
|
|
return s.Timestamp.After(other.Timestamp)
|
|
}
|
|
return len(s.Ports) > len(other.Ports)
|
|
}
|
|
|
|
func (s xfusionNetcardSnapshot) primaryBDF() string {
|
|
for _, port := range s.Ports {
|
|
if bdf := strings.TrimSpace(port.BDF); bdf != "" {
|
|
return bdf
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s xfusionNetcardSnapshot) macAddresses() []string {
|
|
out := make([]string, 0, len(s.Ports))
|
|
seen := make(map[string]struct{}, len(s.Ports))
|
|
for _, port := range s.Ports {
|
|
for _, candidate := range []string{port.ActualMAC, port.MAC} {
|
|
mac := normalizeMAC(candidate)
|
|
if mac == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[mac]; exists {
|
|
continue
|
|
}
|
|
seen[mac] = struct{}{}
|
|
out = append(out, mac)
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeMAC(value string) string {
|
|
value = strings.ToUpper(strings.TrimSpace(value))
|
|
switch value {
|
|
case "", "N/A", "NA", "UNKNOWN", "00:00:00:00:00:00":
|
|
return ""
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
// ── PSU ───────────────────────────────────────────────────────────────────────
|
|
|
|
// parsePSUInfo parses the pipe-delimited psu_info.txt.
|
|
// Columns: Slot | presence | Manufacturer | Type | SN | Version | Rated Power | InputMode | PartNum | DeviceName | Vin | ...
|
|
func parsePSUInfo(content []byte) []models.PSU {
|
|
var psus []models.PSU
|
|
var headers []string
|
|
|
|
for _, line := range strings.Split(string(content), "\n") {
|
|
if !strings.Contains(line, "|") {
|
|
continue
|
|
}
|
|
parts := strings.Split(line, "|")
|
|
cols := make([]string, len(parts))
|
|
for i, p := range parts {
|
|
cols[i] = strings.TrimSpace(p)
|
|
}
|
|
if headers == nil {
|
|
headers = make([]string, len(cols))
|
|
for i, h := range cols {
|
|
headers[i] = strings.ToLower(h)
|
|
}
|
|
continue
|
|
}
|
|
row := make(map[string]string, len(headers))
|
|
for i, h := range headers {
|
|
if i < len(cols) {
|
|
row[h] = cols[i]
|
|
}
|
|
}
|
|
if strings.ToLower(row["presence"]) != "present" {
|
|
continue
|
|
}
|
|
|
|
wattage := 0
|
|
fmt.Sscanf(row["rated power"], "%d", &wattage)
|
|
|
|
inputVoltage := 0.0
|
|
fmt.Sscanf(row["vin"], "%f", &inputVoltage)
|
|
|
|
psus = append(psus, models.PSU{
|
|
Slot: row["slot"],
|
|
Present: true,
|
|
Model: row["type"],
|
|
Vendor: row["manufacturer"],
|
|
SerialNumber: row["sn"],
|
|
PartNumber: row["partnum"],
|
|
Firmware: row["version"],
|
|
WattageW: wattage,
|
|
InputType: row["inputmode"],
|
|
InputVoltage: inputVoltage,
|
|
Status: "ok",
|
|
})
|
|
}
|
|
return psus
|
|
}
|
|
|
|
// ── Storage ───────────────────────────────────────────────────────────────────
|
|
|
|
// parseStorageControllerInfo parses RAID_Controller_Info.txt and adds firmware entries.
|
|
func parseStorageControllerInfo(content []byte, result *models.AnalysisResult) {
|
|
// File may contain multiple controller blocks; parse key:value pairs from each.
|
|
// We only look at the first occurrence of each key (first controller).
|
|
seen := make(map[string]struct{}, len(result.Hardware.Firmware))
|
|
for _, fw := range result.Hardware.Firmware {
|
|
key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description))
|
|
seen[key] = struct{}{}
|
|
}
|
|
text := string(content)
|
|
blocks := strings.Split(text, "RAID Controller #")
|
|
for _, block := range blocks[1:] { // skip pre-block preamble
|
|
fields := parseKeyValueBlock([]byte(block))
|
|
name := firstNonEmpty(fields["Component Name"], fields["Controller Name"], fields["Controller Type"])
|
|
firmware := fields["Firmware Version"]
|
|
if name != "" && firmware != "" {
|
|
appendXFusionFirmware(result, seen, models.FirmwareInfo{
|
|
DeviceName: name,
|
|
Description: fields["Controller Name"],
|
|
Version: firmware,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func parseAppRevision(content []byte, result *models.AnalysisResult) {
|
|
type firmwareLine struct {
|
|
deviceName string
|
|
description string
|
|
buildKey string
|
|
}
|
|
|
|
known := map[string]firmwareLine{
|
|
"Active iBMC Version": {deviceName: "iBMC", description: "active iBMC", buildKey: "Active iBMC Built"},
|
|
"Active BIOS Version": {deviceName: "BIOS", description: "active BIOS", buildKey: "Active BIOS Built"},
|
|
"CPLD Version": {deviceName: "CPLD", description: "mainboard CPLD"},
|
|
"SDK Version": {deviceName: "SDK", description: "iBMC SDK", buildKey: "SDK Built"},
|
|
"Active Uboot Version": {deviceName: "U-Boot", description: "active U-Boot"},
|
|
"Active Secure Bootloader Version": {deviceName: "Secure Bootloader", description: "active secure bootloader"},
|
|
"Active Secure Firmware Version": {deviceName: "Secure Firmware", description: "active secure firmware"},
|
|
}
|
|
|
|
values := parseAlignedKeyValues(content)
|
|
if result.Hardware.BoardInfo.ProductName == "" {
|
|
if productName := values["Product Name"]; productName != "" {
|
|
result.Hardware.BoardInfo.ProductName = productName
|
|
}
|
|
}
|
|
|
|
seen := make(map[string]struct{}, len(result.Hardware.Firmware))
|
|
for _, fw := range result.Hardware.Firmware {
|
|
key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description))
|
|
seen[key] = struct{}{}
|
|
}
|
|
|
|
for key, meta := range known {
|
|
version := normalizeXFusionValue(values[key])
|
|
if version == "" {
|
|
continue
|
|
}
|
|
appendXFusionFirmware(result, seen, models.FirmwareInfo{
|
|
DeviceName: meta.deviceName,
|
|
Description: meta.description,
|
|
Version: version,
|
|
BuildTime: normalizeXFusionValue(values[meta.buildKey]),
|
|
})
|
|
}
|
|
}
|
|
|
|
func parseAlignedKeyValues(content []byte) map[string]string {
|
|
values := make(map[string]string)
|
|
for _, rawLine := range strings.Split(string(content), "\n") {
|
|
line := strings.TrimRight(rawLine, "\r")
|
|
if !strings.Contains(line, ":") {
|
|
continue
|
|
}
|
|
idx := strings.Index(line, ":")
|
|
if idx < 0 {
|
|
continue
|
|
}
|
|
key := strings.TrimRight(line[:idx], " \t")
|
|
value := strings.TrimSpace(line[idx+1:])
|
|
if key == "" || value == "" || values[key] != "" {
|
|
continue
|
|
}
|
|
values[key] = value
|
|
}
|
|
return values
|
|
}
|
|
|
|
func appendXFusionFirmware(result *models.AnalysisResult, seen map[string]struct{}, fw models.FirmwareInfo) {
|
|
if result == nil || result.Hardware == nil {
|
|
return
|
|
}
|
|
key := strings.ToLower(strings.TrimSpace(fw.DeviceName + "\x00" + fw.Version + "\x00" + fw.Description))
|
|
if key == "" {
|
|
return
|
|
}
|
|
if _, exists := seen[key]; exists {
|
|
return
|
|
}
|
|
seen[key] = struct{}{}
|
|
result.Hardware.Firmware = append(result.Hardware.Firmware, fw)
|
|
}
|
|
|
|
// parseDiskInfo parses a single PhysicalDrivesInfo/DiskN/disk_info file.
|
|
func parseDiskInfo(content []byte) *models.Storage {
|
|
fields := parseKeyValueBlock(content)
|
|
|
|
model := fields["Model"]
|
|
sn := fields["Serial Number"]
|
|
if model == "" && sn == "" {
|
|
return nil
|
|
}
|
|
|
|
sizeGB := 0
|
|
var capFloat float64
|
|
if _, err := fmt.Sscanf(fields["Capacity"], "%f GB", &capFloat); err == nil {
|
|
sizeGB = int(capFloat)
|
|
}
|
|
|
|
var wearPct *int
|
|
if wearStr := fields["Remnant Media Wearout"]; wearStr != "" {
|
|
var pct int
|
|
if _, err := fmt.Sscanf(wearStr, "%d%%", &pct); err == nil {
|
|
wearPct = &pct
|
|
}
|
|
}
|
|
|
|
status := "ok"
|
|
if h := strings.ToLower(fields["Health Status"]); h != "" && h != "normal" {
|
|
status = h
|
|
}
|
|
|
|
return &models.Storage{
|
|
Slot: firstNonEmpty(fields["Device Name"], fields["ID"]),
|
|
Type: fields["Media Type"],
|
|
Model: model,
|
|
SizeGB: sizeGB,
|
|
SerialNumber: sn,
|
|
Manufacturer: fields["Manufacturer"],
|
|
Firmware: fields["Firmware Version"],
|
|
Interface: fields["Interface Type"],
|
|
Present: true,
|
|
RemainingEndurancePct: wearPct,
|
|
Status: status,
|
|
}
|
|
}
|
|
|
|
// parseKeyValueBlock parses "Key (spaces) : Value" lines from a text block.
|
|
func parseKeyValueBlock(content []byte) map[string]string {
|
|
result := make(map[string]string)
|
|
for _, line := range strings.Split(string(content), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" || strings.HasPrefix(line, "=") || strings.HasPrefix(line, "-") {
|
|
continue
|
|
}
|
|
idx := strings.Index(line, " : ")
|
|
if idx < 0 {
|
|
continue
|
|
}
|
|
key := strings.TrimSpace(line[:idx])
|
|
val := strings.TrimSpace(line[idx+3:])
|
|
if key != "" && result[key] == "" {
|
|
result[key] = val
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ── Events ────────────────────────────────────────────────────────────────────
|
|
|
|
// parseMaintenanceLog parses the iBMC maintenance_log file.
|
|
// Line format: "YYYY-MM-DD HH:MM:SS LEVEL : CODE,description"
|
|
func parseMaintenanceLog(content []byte) []models.Event {
|
|
var events []models.Event
|
|
for _, line := range strings.Split(string(content), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if len(line) < 20 {
|
|
continue
|
|
}
|
|
ts, err := time.Parse("2006-01-02 15:04:05", line[:19])
|
|
if err != nil || ts.Year() <= 1970 {
|
|
continue // skip epoch-0 boot artifacts
|
|
}
|
|
|
|
rest := strings.TrimSpace(line[19:])
|
|
sepIdx := strings.Index(rest, " : ")
|
|
if sepIdx < 0 {
|
|
sepIdx = strings.Index(rest, ": ")
|
|
if sepIdx < 0 {
|
|
continue
|
|
}
|
|
} else {
|
|
sepIdx++ // skip leading space for " : "
|
|
}
|
|
|
|
levelStr := strings.TrimSpace(rest[:sepIdx-1])
|
|
body := strings.TrimSpace(rest[sepIdx+2:])
|
|
|
|
code := body
|
|
description := ""
|
|
if ci := strings.Index(body, ","); ci >= 0 {
|
|
code = body[:ci]
|
|
description = strings.TrimSpace(body[ci+1:])
|
|
}
|
|
|
|
var severity models.Severity
|
|
switch strings.ToUpper(levelStr) {
|
|
case "WARN", "WARNING":
|
|
severity = models.SeverityWarning
|
|
case "ERROR", "ERR", "CRIT", "CRITICAL":
|
|
severity = models.SeverityCritical
|
|
default:
|
|
severity = models.SeverityInfo
|
|
}
|
|
|
|
events = append(events, models.Event{
|
|
Timestamp: ts,
|
|
Source: "ibmc",
|
|
EventType: code,
|
|
Severity: severity,
|
|
Description: description,
|
|
RawData: line,
|
|
})
|
|
}
|
|
return events
|
|
}
|
|
|
|
// ── unused import guard ───────────────────────────────────────────────────────
|
|
var _ = parser.ExtractedFile{}
|