1707 lines
46 KiB
Go
1707 lines
46 KiB
Go
package hpe_ilo_ahs
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/logpile/internal/models"
|
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
|
)
|
|
|
|
const (
|
|
parserVersion = "1.0"
|
|
ahsHeaderSize = 116
|
|
maxGzipSize = 50 * 1024 * 1024
|
|
)
|
|
|
|
var (
|
|
partNumberPattern = regexp.MustCompile(`(?i)^[a-z0-9]{1,4}\d{4,6}-[a-z0-9]{2,4}$`)
|
|
serverSerialRE = regexp.MustCompile(`(?i)(?:^|[_-])([a-z0-9]{10})(?:[_-]|\.)`)
|
|
dimmSlotRE = regexp.MustCompile(`^PROC\s+(\d+)\s+DIMM\s+(\d+)$`)
|
|
procSlotRE = regexp.MustCompile(`^Proc\s+(\d+)$`)
|
|
psuSlotRE = regexp.MustCompile(`^Power Supply\s+(\d+)$`)
|
|
eventTimeRE = regexp.MustCompile(`^\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}$`)
|
|
psuXMLRE = regexp.MustCompile(`(?s)<PowerSupplySlot id="(\d+)">(.*?)</PowerSupplySlot>`)
|
|
firmwareLockdownRE = regexp.MustCompile(`(?s)<FirmwareLockdown>(.*?)</FirmwareLockdown>`)
|
|
xmlFieldRE = regexp.MustCompile(`(?s)<([A-Za-z0-9_-]+)>([^<]*)</[A-Za-z0-9_-]+>`)
|
|
psuLogRE = regexp.MustCompile(`Update bay (\d+) (SPN|Serial Number|Model Number|fw ver\.), value = ([A-Za-z0-9._-]+)`)
|
|
versionFragmentRE = regexp.MustCompile(`\d+(?:\.\d+)+`)
|
|
)
|
|
|
|
func init() {
|
|
parser.Register(&Parser{})
|
|
}
|
|
|
|
type Parser struct{}
|
|
|
|
func (p *Parser) Name() string { return "HPE iLO AHS Parser" }
|
|
func (p *Parser) Vendor() string { return "hpe_ilo_ahs" }
|
|
func (p *Parser) Version() string { return parserVersion }
|
|
|
|
func (p *Parser) Detect(files []parser.ExtractedFile) int {
|
|
if len(files) != 1 {
|
|
return 0
|
|
}
|
|
|
|
file := files[0]
|
|
if len(file.Content) < ahsHeaderSize || !bytes.HasPrefix(file.Content, []byte("ABJR")) {
|
|
return 0
|
|
}
|
|
|
|
score := 55
|
|
name := strings.ToLower(file.Path)
|
|
if strings.HasSuffix(name, ".ahs") {
|
|
score += 30
|
|
}
|
|
if bytes.Contains(file.Content, []byte("CUST_INFO.DAT")) {
|
|
score += 10
|
|
}
|
|
if bytes.Contains(file.Content, []byte(".zbb")) || bytes.Contains(file.Content, []byte("ilo_boot_support.zbb")) {
|
|
score += 10
|
|
}
|
|
if score > 100 {
|
|
score = 100
|
|
}
|
|
return score
|
|
}
|
|
|
|
func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) {
|
|
if len(files) == 0 {
|
|
return emptyResult(), nil
|
|
}
|
|
|
|
entries, err := parseAHSContainer(files[0].Content)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse ahs container: %w", err)
|
|
}
|
|
|
|
result := emptyResult()
|
|
result.SourceType = models.SourceTypeArchive
|
|
|
|
tokens := make([]string, 0, 2048)
|
|
redfishDocs := make(map[string]map[string]any)
|
|
rawMetadata := make([]map[string]any, 0, len(entries))
|
|
|
|
for _, entry := range entries {
|
|
rawMetadata = append(rawMetadata, map[string]any{
|
|
"name": entry.Name,
|
|
"compressed": entry.Compressed,
|
|
"compressed_size": len(entry.Payload),
|
|
"uncompressed_size": len(entry.Content),
|
|
"flag": entry.Flag,
|
|
})
|
|
if len(entry.Content) == 0 {
|
|
continue
|
|
}
|
|
tokens = append(tokens, printableTokens(entry.Content, 3)...)
|
|
for path, doc := range extractEmbeddedRedfishDocs(entry.Content) {
|
|
redfishDocs[path] = doc
|
|
}
|
|
}
|
|
|
|
if len(rawMetadata) > 0 {
|
|
result.RawPayloads = map[string]any{
|
|
"hpe_ahs_entries": rawMetadata,
|
|
}
|
|
}
|
|
|
|
board := parseBoardInfo(tokens, files[0].Path)
|
|
result.Hardware.BoardInfo = board
|
|
if board.ProductName != "" || board.SerialNumber != "" || board.PartNumber != "" {
|
|
result.FRU = append(result.FRU, models.FRUInfo{
|
|
Description: "System",
|
|
Manufacturer: board.Manufacturer,
|
|
ProductName: board.ProductName,
|
|
SerialNumber: board.SerialNumber,
|
|
PartNumber: board.PartNumber,
|
|
Version: board.Version,
|
|
})
|
|
}
|
|
|
|
result.Hardware.CPUs = dedupeCPUs(parseCPUs(tokens))
|
|
result.Hardware.Memory = dedupeMemory(parseDIMMs(tokens))
|
|
result.Hardware.PowerSupply = dedupePSUs(parsePSUs(tokens))
|
|
result.Hardware.NetworkAdapters = dedupeNetworkAdapters(parseNetworkAdapters(tokens))
|
|
result.Hardware.Firmware = dedupeFirmware(parseFirmware(tokens))
|
|
|
|
psuSupplements := parsePSUSupplements(entries)
|
|
result.Hardware.PowerSupply = dedupePSUs(mergePSUs(result.Hardware.PowerSupply, psuSupplements))
|
|
|
|
lockdownFW, nicFirmwareByVendor := parseBCertFirmware(entries)
|
|
result.Hardware.NetworkAdapters = dedupeNetworkAdapters(enrichNetworkAdapters(result.Hardware.NetworkAdapters, nicFirmwareByVendor))
|
|
result.Hardware.Firmware = dedupeFirmware(append(result.Hardware.Firmware, lockdownFW...))
|
|
|
|
storage, volumes, controllerDevices, controllerFW := parseRedfishStorage(redfishDocs)
|
|
result.Hardware.Storage = dedupeStorage(storage)
|
|
result.Hardware.Volumes = volumes
|
|
result.Hardware.Firmware = dedupeFirmware(append(result.Hardware.Firmware, controllerFW...))
|
|
|
|
result.Events = dedupeEvents(parseEvents(tokens))
|
|
if result.CollectedAt.IsZero() {
|
|
for _, ev := range result.Events {
|
|
if ev.Timestamp.After(result.CollectedAt) {
|
|
result.CollectedAt = ev.Timestamp.UTC()
|
|
}
|
|
}
|
|
}
|
|
|
|
result.Hardware.Devices = buildDevices(
|
|
result.Hardware.BoardInfo,
|
|
result.Hardware.CPUs,
|
|
result.Hardware.Memory,
|
|
result.Hardware.Storage,
|
|
result.Hardware.NetworkAdapters,
|
|
result.Hardware.PowerSupply,
|
|
controllerDevices,
|
|
)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
type ahsEntry struct {
|
|
Name string
|
|
Flag uint32
|
|
Payload []byte
|
|
Content []byte
|
|
Compressed bool
|
|
}
|
|
|
|
func emptyResult() *models.AnalysisResult {
|
|
return &models.AnalysisResult{
|
|
Events: make([]models.Event, 0),
|
|
FRU: make([]models.FRUInfo, 0),
|
|
Sensors: make([]models.SensorReading, 0),
|
|
Hardware: &models.HardwareConfig{
|
|
Firmware: make([]models.FirmwareInfo, 0),
|
|
Devices: make([]models.HardwareDevice, 0),
|
|
CPUs: make([]models.CPU, 0),
|
|
Memory: make([]models.MemoryDIMM, 0),
|
|
Storage: make([]models.Storage, 0),
|
|
Volumes: make([]models.StorageVolume, 0),
|
|
PCIeDevices: make([]models.PCIeDevice, 0),
|
|
GPUs: make([]models.GPU, 0),
|
|
NetworkCards: make([]models.NIC, 0),
|
|
NetworkAdapters: make([]models.NetworkAdapter, 0),
|
|
PowerSupply: make([]models.PSU, 0),
|
|
},
|
|
}
|
|
}
|
|
|
|
func parseAHSContainer(data []byte) ([]ahsEntry, error) {
|
|
entries := make([]ahsEntry, 0, 8)
|
|
offset := 0
|
|
|
|
for offset < len(data) {
|
|
if offset+ahsHeaderSize > len(data) {
|
|
return nil, fmt.Errorf("truncated header at offset %d", offset)
|
|
}
|
|
if !bytes.Equal(data[offset:offset+4], []byte("ABJR")) {
|
|
return nil, fmt.Errorf("invalid magic at offset %d", offset)
|
|
}
|
|
|
|
size := int(binary.LittleEndian.Uint32(data[offset+8 : offset+12]))
|
|
flag := binary.LittleEndian.Uint32(data[offset+16 : offset+20])
|
|
name := strings.TrimRight(string(data[offset+20:offset+52]), "\x00")
|
|
start := offset + ahsHeaderSize
|
|
end := start + size
|
|
if size < 0 || end > len(data) {
|
|
return nil, fmt.Errorf("invalid payload size for %q", name)
|
|
}
|
|
|
|
payload := append([]byte(nil), data[start:end]...)
|
|
content := payload
|
|
compressed := len(payload) >= 2 && payload[0] == 0x1f && payload[1] == 0x8b
|
|
if compressed {
|
|
decoded, err := gunzipLimited(payload)
|
|
if err == nil {
|
|
content = decoded
|
|
}
|
|
}
|
|
|
|
entries = append(entries, ahsEntry{
|
|
Name: name,
|
|
Flag: flag,
|
|
Payload: payload,
|
|
Content: content,
|
|
Compressed: compressed,
|
|
})
|
|
offset = end
|
|
}
|
|
|
|
return entries, nil
|
|
}
|
|
|
|
func gunzipLimited(payload []byte) ([]byte, error) {
|
|
gr, err := gzip.NewReader(bytes.NewReader(payload))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer gr.Close()
|
|
|
|
buf, err := io.ReadAll(io.LimitReader(gr, maxGzipSize+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(buf) > maxGzipSize {
|
|
return nil, fmt.Errorf("gzip payload exceeded %d bytes", maxGzipSize)
|
|
}
|
|
return buf, nil
|
|
}
|
|
|
|
func printableTokens(data []byte, minLen int) []string {
|
|
out := make([]string, 0, 256)
|
|
start := -1
|
|
for i, b := range data {
|
|
if b >= 32 && b <= 126 {
|
|
if start == -1 {
|
|
start = i
|
|
}
|
|
continue
|
|
}
|
|
if start != -1 && i-start >= minLen {
|
|
token := strings.TrimSpace(string(data[start:i]))
|
|
if token != "" {
|
|
out = append(out, token)
|
|
}
|
|
}
|
|
start = -1
|
|
}
|
|
if start != -1 && len(data)-start >= minLen {
|
|
token := strings.TrimSpace(string(data[start:]))
|
|
if token != "" {
|
|
out = append(out, token)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func extractEmbeddedRedfishDocs(data []byte) map[string]map[string]any {
|
|
out := make(map[string]map[string]any)
|
|
marker := []byte(`{"@odata`)
|
|
for offset := 0; offset < len(data); {
|
|
idx := bytes.Index(data[offset:], marker)
|
|
if idx < 0 {
|
|
break
|
|
}
|
|
start := offset + idx
|
|
end, ok := findBalancedJSONObject(data, start)
|
|
if !ok {
|
|
offset = start + 1
|
|
continue
|
|
}
|
|
|
|
var doc map[string]any
|
|
if err := json.Unmarshal(data[start:end], &doc); err == nil {
|
|
path := strings.TrimSpace(asString(doc["@odata.id"]))
|
|
if strings.HasPrefix(path, "/redfish/") {
|
|
out[path] = doc
|
|
}
|
|
}
|
|
offset = end
|
|
}
|
|
return out
|
|
}
|
|
|
|
func findBalancedJSONObject(data []byte, start int) (int, bool) {
|
|
if start >= len(data) || data[start] != '{' {
|
|
return 0, false
|
|
}
|
|
depth := 0
|
|
inString := false
|
|
escaped := false
|
|
|
|
for i := start; i < len(data); i++ {
|
|
c := data[i]
|
|
if inString {
|
|
switch {
|
|
case escaped:
|
|
escaped = false
|
|
case c == '\\':
|
|
escaped = true
|
|
case c == '"':
|
|
inString = false
|
|
}
|
|
continue
|
|
}
|
|
|
|
switch c {
|
|
case '"':
|
|
inString = true
|
|
case '{':
|
|
depth++
|
|
case '}':
|
|
depth--
|
|
if depth == 0 {
|
|
return i + 1, true
|
|
}
|
|
}
|
|
}
|
|
|
|
return 0, false
|
|
}
|
|
|
|
func parseBoardInfo(tokens []string, path string) models.BoardInfo {
|
|
var board models.BoardInfo
|
|
|
|
for i := 0; i+3 < len(tokens); i++ {
|
|
manufacturer := strings.TrimSpace(tokens[i])
|
|
model := sanitizeModel(tokens[i+1])
|
|
if !isHPEManufacturer(manufacturer) || !looksLikeServerModel(model) {
|
|
continue
|
|
}
|
|
board.Manufacturer = "HPE"
|
|
board.ProductName = model
|
|
if isLikelySerial(tokens[i+2]) {
|
|
board.SerialNumber = tokens[i+2]
|
|
}
|
|
if looksLikePartNumber(tokens[i+3]) {
|
|
board.PartNumber = tokens[i+3]
|
|
}
|
|
break
|
|
}
|
|
|
|
if board.Manufacturer == "" && strings.Contains(strings.ToUpper(filepath.Base(path)), "HPE") {
|
|
board.Manufacturer = "HPE"
|
|
}
|
|
if board.SerialNumber == "" {
|
|
if match := serverSerialRE.FindStringSubmatch(strings.ToUpper(filepath.Base(path))); len(match) == 2 {
|
|
board.SerialNumber = match[1]
|
|
}
|
|
}
|
|
if board.ProductName == "" {
|
|
for _, token := range tokens {
|
|
if looksLikeServerModel(token) {
|
|
board.ProductName = sanitizeModel(token)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return board
|
|
}
|
|
|
|
func parseCPUs(tokens []string) []models.CPU {
|
|
out := make([]models.CPU, 0, 2)
|
|
for i := 0; i+2 < len(tokens); i++ {
|
|
match := procSlotRE.FindStringSubmatch(tokens[i])
|
|
if len(match) != 2 {
|
|
continue
|
|
}
|
|
socket, _ := strconv.Atoi(match[1])
|
|
model := ""
|
|
manufacturer := ""
|
|
for j := i + 1; j < len(tokens) && j <= i+5; j++ {
|
|
if strings.HasPrefix(tokens[j], "PROC ") || procSlotRE.MatchString(tokens[j]) {
|
|
break
|
|
}
|
|
if manufacturer == "" && looksLikeCPUVendor(tokens[j]) {
|
|
manufacturer = tokens[j]
|
|
continue
|
|
}
|
|
if looksLikeCPUModel(tokens[j]) {
|
|
model = tokens[j]
|
|
break
|
|
}
|
|
}
|
|
if model == "" {
|
|
continue
|
|
}
|
|
cpu := models.CPU{
|
|
Socket: socket,
|
|
Model: model,
|
|
Description: manufacturer,
|
|
Status: "ok",
|
|
}
|
|
out = append(out, cpu)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseDIMMs(tokens []string) []models.MemoryDIMM {
|
|
out := make([]models.MemoryDIMM, 0, 16)
|
|
for i := 0; i+3 < len(tokens); i++ {
|
|
match := dimmSlotRE.FindStringSubmatch(tokens[i])
|
|
if len(match) != 3 {
|
|
continue
|
|
}
|
|
slot := tokens[i]
|
|
manufacturer := tokens[i+1]
|
|
partNumber := tokens[i+2]
|
|
serial := tokens[i+3]
|
|
if isUnavailable(partNumber) || isUnavailable(serial) {
|
|
continue
|
|
}
|
|
if isUnavailable(manufacturer) || strings.EqualFold(manufacturer, "unknown") {
|
|
manufacturer = ""
|
|
}
|
|
out = append(out, models.MemoryDIMM{
|
|
Slot: slot,
|
|
Location: slot,
|
|
Present: true,
|
|
Manufacturer: manufacturer,
|
|
PartNumber: partNumber,
|
|
SerialNumber: serial,
|
|
Status: "ok",
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parsePSUs(tokens []string) []models.PSU {
|
|
out := make([]models.PSU, 0, 4)
|
|
for i := 0; i < len(tokens); i++ {
|
|
match := psuSlotRE.FindStringSubmatch(tokens[i])
|
|
if len(match) != 2 {
|
|
continue
|
|
}
|
|
slot := "PSU " + match[1]
|
|
vendor := ""
|
|
serial := ""
|
|
partNumber := ""
|
|
for j := i + 1; j < len(tokens) && j <= i+5; j++ {
|
|
field := strings.TrimSpace(tokens[j])
|
|
if strings.HasPrefix(field, "PciRoot(") || psuSlotRE.MatchString(field) || dimmSlotRE.MatchString(field) || procSlotRE.MatchString(field) || eventTimeRE.MatchString(field) {
|
|
break
|
|
}
|
|
switch {
|
|
case vendor == "" && looksLikePSUVendor(field):
|
|
vendor = field
|
|
case partNumber == "" && looksLikePartNumber(field):
|
|
partNumber = field
|
|
case serial == "" && isLikelySerial(field):
|
|
serial = field
|
|
}
|
|
}
|
|
if serial == "" && partNumber == "" {
|
|
continue
|
|
}
|
|
psu := models.PSU{
|
|
Slot: slot,
|
|
Present: true,
|
|
Model: valueOr(partNumber, "Power Supply"),
|
|
Vendor: valueOr(cleanUnavailable(vendor), "HPE"),
|
|
SerialNumber: cleanUnavailable(serial),
|
|
PartNumber: cleanUnavailable(partNumber),
|
|
Status: "ok",
|
|
}
|
|
out = append(out, psu)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parsePSUSupplements(entries []ahsEntry) []models.PSU {
|
|
bySlot := make(map[string]models.PSU)
|
|
|
|
for _, entry := range entries {
|
|
text := string(entry.Content)
|
|
if text == "" {
|
|
continue
|
|
}
|
|
|
|
if strings.EqualFold(entry.Name, "bcert.pkg") {
|
|
for _, match := range psuXMLRE.FindAllStringSubmatch(text, -1) {
|
|
slotNum, _ := strconv.Atoi(match[1])
|
|
slot := fmt.Sprintf("PSU %d", slotNum+1)
|
|
fields := parseXMLFields(match[2])
|
|
item := bySlot[slot]
|
|
item.Slot = slot
|
|
item.Present = strings.EqualFold(fields["Present"], "Yes") || item.Present
|
|
if serial := strings.TrimSpace(fields["SerialNumber"]); serial != "" {
|
|
item.SerialNumber = serial
|
|
}
|
|
if fw := strings.TrimSpace(fields["FirmwareVersion"]); fw != "" {
|
|
item.Firmware = fw
|
|
}
|
|
if spare := strings.TrimSpace(fields["SparePartNumber"]); spare != "" {
|
|
if item.Details == nil {
|
|
item.Details = make(map[string]any)
|
|
}
|
|
item.Details["spare_part_number"] = spare
|
|
}
|
|
bySlot[slot] = item
|
|
}
|
|
}
|
|
|
|
for _, match := range psuLogRE.FindAllStringSubmatch(text, -1) {
|
|
slotNum, _ := strconv.Atoi(match[1])
|
|
slot := fmt.Sprintf("PSU %d", slotNum+1)
|
|
item := bySlot[slot]
|
|
item.Slot = slot
|
|
item.Present = true
|
|
value := strings.TrimSpace(match[3])
|
|
switch match[2] {
|
|
case "SPN":
|
|
if item.Details == nil {
|
|
item.Details = make(map[string]any)
|
|
}
|
|
item.Details["spare_part_number"] = value
|
|
case "Serial Number":
|
|
item.SerialNumber = value
|
|
case "Model Number":
|
|
item.Model = value
|
|
item.PartNumber = value
|
|
case "fw ver.":
|
|
item.Firmware = normalizeLooseVersion(value)
|
|
}
|
|
bySlot[slot] = item
|
|
}
|
|
}
|
|
|
|
out := make([]models.PSU, 0, len(bySlot))
|
|
for _, item := range bySlot {
|
|
if item.Slot == "" {
|
|
continue
|
|
}
|
|
item.Vendor = valueOr(item.Vendor, "HPE")
|
|
item.Status = valueOr(item.Status, "ok")
|
|
if item.Model == "" {
|
|
item.Model = valueOr(item.PartNumber, "Power Supply")
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Slot < out[j].Slot })
|
|
return out
|
|
}
|
|
|
|
type pcieSequence struct {
|
|
UEFIPath string
|
|
Code string
|
|
Fields []string
|
|
}
|
|
|
|
func parseNetworkAdapters(tokens []string) []models.NetworkAdapter {
|
|
sequences := collectPCIeSequences(tokens)
|
|
out := make([]models.NetworkAdapter, 0, 4)
|
|
|
|
for _, seq := range sequences {
|
|
if strings.Contains(seq.Code, "DriveBay") {
|
|
continue
|
|
}
|
|
if len(seq.Fields) == 0 {
|
|
continue
|
|
}
|
|
|
|
title := seq.Fields[0]
|
|
if strings.Contains(strings.ToLower(title), "empty") {
|
|
continue
|
|
}
|
|
|
|
if !looksLikeNetworkTitle(seq.Code, title, seq.Fields) {
|
|
continue
|
|
}
|
|
|
|
location := ""
|
|
model := title
|
|
description := ""
|
|
partNumber := ""
|
|
serial := ""
|
|
firmware := ""
|
|
|
|
for _, field := range seq.Fields[1:] {
|
|
switch {
|
|
case location == "" && looksLikeLocation(field):
|
|
location = field
|
|
case partNumber == "" && looksLikePartNumber(field):
|
|
partNumber = field
|
|
case serial == "" && isLikelySerial(field):
|
|
serial = field
|
|
case firmware == "" && looksLikeVersion(field):
|
|
firmware = field
|
|
case model == title && looksLikeConcreteModel(field):
|
|
model = field
|
|
case description == "" && field != model:
|
|
description = field
|
|
}
|
|
}
|
|
|
|
if model == "Network Controller" && description != "" {
|
|
model, description = description, title
|
|
}
|
|
|
|
out = append(out, models.NetworkAdapter{
|
|
Slot: slotLabelFromCode(seq.Code),
|
|
Location: valueOr(location, slotLabelFromCode(seq.Code)),
|
|
Present: true,
|
|
Model: model,
|
|
Description: description,
|
|
Vendor: inferVendor(model),
|
|
SerialNumber: serial,
|
|
PartNumber: partNumber,
|
|
Firmware: firmware,
|
|
Status: "ok",
|
|
Details: map[string]any{
|
|
"uefi_path": seq.UEFIPath,
|
|
"source": "smbios_slot_inventory",
|
|
},
|
|
})
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func collectPCIeSequences(tokens []string) []pcieSequence {
|
|
out := make([]pcieSequence, 0, 16)
|
|
for i := 0; i < len(tokens); i++ {
|
|
if !strings.HasPrefix(tokens[i], "PciRoot(") {
|
|
continue
|
|
}
|
|
if i+1 >= len(tokens) {
|
|
continue
|
|
}
|
|
seq := pcieSequence{
|
|
UEFIPath: tokens[i],
|
|
Code: tokens[i+1],
|
|
Fields: make([]string, 0, 6),
|
|
}
|
|
for j := i + 2; j < len(tokens) && len(seq.Fields) < 6; j++ {
|
|
if strings.HasPrefix(tokens[j], "PciRoot(") || dimmSlotRE.MatchString(tokens[j]) || procSlotRE.MatchString(tokens[j]) || psuSlotRE.MatchString(tokens[j]) {
|
|
break
|
|
}
|
|
seq.Fields = append(seq.Fields, tokens[j])
|
|
}
|
|
out = append(out, seq)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseFirmware(tokens []string) []models.FirmwareInfo {
|
|
out := make([]models.FirmwareInfo, 0, 8)
|
|
seen := make(map[string]bool)
|
|
|
|
for _, token := range tokens {
|
|
if strings.HasPrefix(token, "iLO ") && strings.Contains(token, " built on ") {
|
|
version := token
|
|
build := ""
|
|
if idx := strings.Index(token, " built on "); idx > 0 {
|
|
version = strings.TrimSpace(token[:idx])
|
|
build = strings.TrimSpace(token[idx+10:])
|
|
}
|
|
name := version
|
|
if fields := strings.Fields(version); len(fields) >= 2 {
|
|
name = strings.Join(fields[:2], " ")
|
|
version = strings.TrimSpace(strings.TrimPrefix(version, name))
|
|
}
|
|
appendFirmware(&out, seen, models.FirmwareInfo{
|
|
DeviceName: name,
|
|
Version: strings.TrimSpace(version),
|
|
BuildTime: build,
|
|
})
|
|
}
|
|
}
|
|
|
|
for i := 0; i+1 < len(tokens); i++ {
|
|
name := tokens[i]
|
|
version := tokens[i+1]
|
|
if !isTopLevelFirmwareLabel(name) || !looksLikeVersion(version) {
|
|
continue
|
|
}
|
|
appendFirmware(&out, seen, models.FirmwareInfo{
|
|
DeviceName: name,
|
|
Version: version,
|
|
})
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func parseRedfishStorage(docs map[string]map[string]any) ([]models.Storage, []models.StorageVolume, []models.HardwareDevice, []models.FirmwareInfo) {
|
|
paths := make([]string, 0, len(docs))
|
|
for path := range docs {
|
|
paths = append(paths, path)
|
|
}
|
|
sort.Strings(paths)
|
|
|
|
storage := make([]models.Storage, 0, 8)
|
|
volumes := make([]models.StorageVolume, 0, 4)
|
|
devices := make([]models.HardwareDevice, 0, 6)
|
|
firmware := make([]models.FirmwareInfo, 0, 8)
|
|
fabricNames := make(map[string]string)
|
|
fabricTypes := make(map[string]string)
|
|
|
|
for _, path := range paths {
|
|
doc := docs[path]
|
|
docType := asString(doc["@odata.type"])
|
|
switch {
|
|
case strings.Contains(docType, "#Fabric."):
|
|
fabricID := redfishID(path)
|
|
fabricNames[fabricID] = strings.TrimSpace(asString(doc["Name"]))
|
|
fabricTypes[fabricID] = strings.TrimSpace(asString(doc["FabricType"]))
|
|
|
|
case strings.Contains(docType, "#Switch."):
|
|
fabricID := fabricIDFromPath(path)
|
|
name := valueOr(fabricNames[fabricID], strings.TrimSpace(asString(doc["Name"])))
|
|
model := strings.TrimSpace(asString(doc["Model"]))
|
|
fw := strings.TrimSpace(asString(doc["FirmwareVersion"]))
|
|
device := models.HardwareDevice{
|
|
ID: "hpe-fabric-" + redfishID(path),
|
|
Kind: models.DeviceKindStorage,
|
|
Source: "redfish",
|
|
Slot: valueOr(fabricID, redfishID(path)),
|
|
DeviceClass: "storage_backplane",
|
|
Model: valueOr(name, model),
|
|
PartNumber: model,
|
|
Firmware: fw,
|
|
Status: redfishStatus(doc["Status"]),
|
|
Details: map[string]any{
|
|
"odata_id": path,
|
|
"fabric_type": valueOr(fabricTypes[fabricID], strings.TrimSpace(asString(doc["FabricType"]))),
|
|
"switch_type": strings.TrimSpace(asString(doc["SwitchType"])),
|
|
"supported_protocols": stringSlice(doc["SupportedProtocols"]),
|
|
"domain_id": asInt64(doc["DomainID"]),
|
|
"fabric_name": fabricNames[fabricID],
|
|
"connected_chassis_id": asString(nested(doc, "Links", "Chassis", "@odata.id")),
|
|
},
|
|
}
|
|
devices = append(devices, device)
|
|
if fw != "" {
|
|
firmware = append(firmware, models.FirmwareInfo{
|
|
DeviceName: valueOr(name, model),
|
|
Version: fw,
|
|
})
|
|
}
|
|
|
|
case strings.Contains(docType, "#StorageController."):
|
|
slot := redfishServiceLabel(doc, "Location", "PartLocation", "ServiceLabel")
|
|
model := valueOr(asString(doc["Model"]), asString(doc["Name"]))
|
|
partNumber := strings.TrimSpace(asString(doc["PartNumber"]))
|
|
sku := strings.TrimSpace(asString(doc["SKU"]))
|
|
serial := strings.TrimSpace(asString(doc["SerialNumber"]))
|
|
fw := strings.TrimSpace(asString(doc["FirmwareVersion"]))
|
|
device := models.HardwareDevice{
|
|
ID: "hpe-ctrl-" + redfishID(path),
|
|
Kind: models.DeviceKindStorage,
|
|
Source: "redfish",
|
|
Slot: slot,
|
|
Location: slot,
|
|
DeviceClass: "storage_controller",
|
|
Model: model,
|
|
PartNumber: valueOr(partNumber, sku),
|
|
Manufacturer: strings.TrimSpace(asString(doc["Manufacturer"])),
|
|
SerialNumber: serial,
|
|
Firmware: fw,
|
|
Status: redfishStatus(doc["Status"]),
|
|
Details: map[string]any{
|
|
"odata_id": path,
|
|
"part_number": partNumber,
|
|
"sku": sku,
|
|
"speed_gbps": asFloat64(doc["SpeedGbps"]),
|
|
"supported_controller_protocols": stringSlice(doc["SupportedControllerProtocols"]),
|
|
"supported_device_protocols": stringSlice(doc["SupportedDeviceProtocols"]),
|
|
"supported_raid_types": stringSlice(doc["SupportedRAIDTypes"]),
|
|
"cache_total_mib": asInt64(nested(doc, "CacheSummary", "TotalCacheSizeMiB")),
|
|
"persistent_cache_mib": asInt64(nested(doc, "CacheSummary", "PersistentCacheSizeMiB")),
|
|
"durable_name": firstDurableName(doc),
|
|
},
|
|
}
|
|
if width := asInt(doc, "PCIeInterface", "LanesInUse"); width > 0 {
|
|
device.LinkWidth = width
|
|
}
|
|
if speed := strings.TrimSpace(asString(nested(doc, "PCIeInterface", "PCIeType"))); speed != "" {
|
|
device.LinkSpeed = speed
|
|
}
|
|
devices = append(devices, device)
|
|
if fw != "" {
|
|
firmware = append(firmware, models.FirmwareInfo{
|
|
DeviceName: model,
|
|
Description: slot,
|
|
Version: fw,
|
|
})
|
|
}
|
|
|
|
case strings.Contains(docType, "#Drive."):
|
|
if strings.EqualFold(redfishStatus(doc["Status"]), "absent") {
|
|
continue
|
|
}
|
|
capacity := asInt64(doc["CapacityBytes"])
|
|
slot := redfishServiceLabel(doc, "PhysicalLocation", "PartLocation", "ServiceLabel")
|
|
if slot == "" {
|
|
slot = redfishServiceLabel(doc, "Location", "PartLocation", "ServiceLabel")
|
|
}
|
|
endurance := asOptionalInt(doc["PredictedMediaLifeLeftPercent"])
|
|
entry := models.Storage{
|
|
Slot: slot,
|
|
Type: valueOr(asString(doc["MediaType"]), "Drive"),
|
|
Model: valueOr(asString(doc["Model"]), asString(doc["Name"])),
|
|
Description: strings.TrimSpace(asString(doc["Name"])),
|
|
SizeGB: bytesToDecimalGB(capacity),
|
|
SerialNumber: strings.TrimSpace(asString(doc["SerialNumber"])),
|
|
Firmware: strings.TrimSpace(asString(doc["Revision"])),
|
|
Interface: valueOr(asString(doc["Protocol"]), asString(doc["MediaType"])),
|
|
Present: true,
|
|
RemainingEndurancePct: endurance,
|
|
Status: redfishStatus(doc["Status"]),
|
|
Details: map[string]any{
|
|
"odata_id": path,
|
|
"capacity_bytes": capacity,
|
|
"failure_predicted": asBool(doc["FailurePredicted"]),
|
|
"negotiated_speed_gbps": asFloat64(doc["NegotiatedSpeedGbs"]),
|
|
"capable_speed_gbps": asFloat64(doc["CapableSpeedGbs"]),
|
|
"location_indicator_active": asBool(doc["LocationIndicatorActive"]),
|
|
},
|
|
}
|
|
storage = append(storage, entry)
|
|
|
|
case strings.Contains(docType, "#Volume.") && !strings.HasSuffix(path, "/Capabilities"):
|
|
volumes = append(volumes, models.StorageVolume{
|
|
ID: strings.TrimSpace(asString(doc["Id"])),
|
|
Name: strings.TrimSpace(asString(doc["Name"])),
|
|
RAIDLevel: strings.TrimSpace(asString(doc["RAIDType"])),
|
|
CapacityBytes: asInt64(doc["CapacityBytes"]),
|
|
SizeGB: bytesToDecimalGB(asInt64(doc["CapacityBytes"])),
|
|
Status: redfishStatus(doc["Status"]),
|
|
})
|
|
}
|
|
}
|
|
|
|
return storage, dedupeVolumes(volumes), dedupeDevices(devices), dedupeFirmware(firmware)
|
|
}
|
|
|
|
func buildDevices(board models.BoardInfo, cpus []models.CPU, memory []models.MemoryDIMM, storage []models.Storage, adapters []models.NetworkAdapter, psus []models.PSU, extras []models.HardwareDevice) []models.HardwareDevice {
|
|
devices := make([]models.HardwareDevice, 0, 1+len(cpus)+len(memory)+len(storage)+len(adapters)+len(psus)+len(extras))
|
|
|
|
if board.ProductName != "" || board.SerialNumber != "" {
|
|
devices = append(devices, models.HardwareDevice{
|
|
ID: "hpe-board",
|
|
Kind: models.DeviceKindBoard,
|
|
Source: "smbios",
|
|
Model: board.ProductName,
|
|
Manufacturer: board.Manufacturer,
|
|
SerialNumber: board.SerialNumber,
|
|
PartNumber: board.PartNumber,
|
|
Status: "ok",
|
|
})
|
|
}
|
|
|
|
for _, cpu := range cpus {
|
|
devices = append(devices, models.HardwareDevice{
|
|
ID: fmt.Sprintf("hpe-cpu-%d", cpu.Socket),
|
|
Kind: models.DeviceKindCPU,
|
|
Source: "smbios",
|
|
Slot: fmt.Sprintf("CPU %d", cpu.Socket),
|
|
Model: cpu.Model,
|
|
Manufacturer: strings.TrimSpace(cpu.Description),
|
|
Cores: cpu.Cores,
|
|
Threads: cpu.Threads,
|
|
FrequencyMHz: cpu.FrequencyMHz,
|
|
MaxFreqMHz: cpu.MaxFreqMHz,
|
|
Status: cpu.Status,
|
|
})
|
|
}
|
|
|
|
for _, dimm := range memory {
|
|
devices = append(devices, models.HardwareDevice{
|
|
ID: "hpe-mem-" + sanitizeID(dimm.Slot),
|
|
Kind: models.DeviceKindMemory,
|
|
Source: "smbios",
|
|
Slot: dimm.Slot,
|
|
Location: dimm.Location,
|
|
Model: dimm.PartNumber,
|
|
Manufacturer: dimm.Manufacturer,
|
|
SerialNumber: dimm.SerialNumber,
|
|
PartNumber: dimm.PartNumber,
|
|
Present: boolPtr(dimm.Present),
|
|
Status: dimm.Status,
|
|
})
|
|
}
|
|
|
|
for _, disk := range storage {
|
|
devices = append(devices, models.HardwareDevice{
|
|
ID: "hpe-disk-" + sanitizeID(valueOr(disk.SerialNumber, disk.Slot)),
|
|
Kind: models.DeviceKindStorage,
|
|
Source: "redfish",
|
|
Slot: disk.Slot,
|
|
Location: disk.Location,
|
|
Model: disk.Model,
|
|
Manufacturer: disk.Manufacturer,
|
|
SerialNumber: disk.SerialNumber,
|
|
Firmware: disk.Firmware,
|
|
Type: disk.Type,
|
|
Interface: disk.Interface,
|
|
Present: boolPtr(disk.Present),
|
|
SizeGB: disk.SizeGB,
|
|
Status: disk.Status,
|
|
RemainingEndurancePct: disk.RemainingEndurancePct,
|
|
})
|
|
}
|
|
|
|
for _, nic := range adapters {
|
|
devices = append(devices, models.HardwareDevice{
|
|
ID: "hpe-net-" + sanitizeID(valueOr(nic.SerialNumber, nic.Slot+"-"+nic.Model)),
|
|
Kind: models.DeviceKindNetwork,
|
|
Source: "smbios",
|
|
Slot: nic.Slot,
|
|
Location: nic.Location,
|
|
Model: nic.Model,
|
|
Manufacturer: nic.Vendor,
|
|
SerialNumber: nic.SerialNumber,
|
|
PartNumber: nic.PartNumber,
|
|
Firmware: nic.Firmware,
|
|
PortCount: nic.PortCount,
|
|
PortType: nic.PortType,
|
|
MACAddresses: append([]string(nil), nic.MACAddresses...),
|
|
Present: boolPtr(nic.Present),
|
|
Status: nic.Status,
|
|
})
|
|
}
|
|
|
|
for _, psu := range psus {
|
|
devices = append(devices, models.HardwareDevice{
|
|
ID: "hpe-psu-" + sanitizeID(valueOr(psu.SerialNumber, psu.Slot)),
|
|
Kind: models.DeviceKindPSU,
|
|
Source: "smbios",
|
|
Slot: psu.Slot,
|
|
Model: psu.Model,
|
|
Manufacturer: psu.Vendor,
|
|
SerialNumber: psu.SerialNumber,
|
|
PartNumber: psu.PartNumber,
|
|
Firmware: psu.Firmware,
|
|
WattageW: psu.WattageW,
|
|
InputType: psu.InputType,
|
|
Present: boolPtr(psu.Present),
|
|
Status: psu.Status,
|
|
})
|
|
}
|
|
|
|
devices = append(devices, extras...)
|
|
return dedupeDevices(devices)
|
|
}
|
|
|
|
func parseEvents(tokens []string) []models.Event {
|
|
out := make([]models.Event, 0, 16)
|
|
for i := 0; i+1 < len(tokens); i++ {
|
|
if !eventTimeRE.MatchString(tokens[i]) {
|
|
continue
|
|
}
|
|
ts, err := time.ParseInLocation("01/02/2006 15:04:05", tokens[i], time.UTC)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
message := ""
|
|
for j := i + 1; j < len(tokens) && j <= i+4; j++ {
|
|
if eventTimeRE.MatchString(tokens[j]) {
|
|
break
|
|
}
|
|
if looksLikeEventMessage(tokens[j]) {
|
|
message = tokens[j]
|
|
break
|
|
}
|
|
}
|
|
if message == "" {
|
|
continue
|
|
}
|
|
|
|
out = append(out, models.Event{
|
|
Timestamp: ts.UTC(),
|
|
Source: "HPE iLO",
|
|
EventType: inferEventType(message),
|
|
Severity: inferSeverity(message),
|
|
Description: message,
|
|
RawData: message,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func appendFirmware(dst *[]models.FirmwareInfo, seen map[string]bool, item models.FirmwareInfo) {
|
|
item.DeviceName = strings.TrimSpace(item.DeviceName)
|
|
item.Version = strings.TrimSpace(item.Version)
|
|
if item.DeviceName == "" || item.Version == "" {
|
|
return
|
|
}
|
|
key := item.DeviceName + "|" + item.Version + "|" + item.Description
|
|
if seen[key] {
|
|
return
|
|
}
|
|
seen[key] = true
|
|
*dst = append(*dst, item)
|
|
}
|
|
|
|
func dedupeCPUs(items []models.CPU) []models.CPU {
|
|
seen := make(map[string]bool)
|
|
out := make([]models.CPU, 0, len(items))
|
|
for _, item := range items {
|
|
key := fmt.Sprintf("%d|%s", item.Socket, item.Model)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func dedupeMemory(items []models.MemoryDIMM) []models.MemoryDIMM {
|
|
seen := make(map[string]bool)
|
|
out := make([]models.MemoryDIMM, 0, len(items))
|
|
for _, item := range items {
|
|
key := valueOr(item.SerialNumber, item.Slot+"|"+item.PartNumber)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func dedupePSUs(items []models.PSU) []models.PSU {
|
|
seen := make(map[string]bool)
|
|
out := make([]models.PSU, 0, len(items))
|
|
for _, item := range items {
|
|
key := valueOr(item.SerialNumber, item.Slot+"|"+item.PartNumber)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func dedupeNetworkAdapters(items []models.NetworkAdapter) []models.NetworkAdapter {
|
|
seen := make(map[string]bool)
|
|
out := make([]models.NetworkAdapter, 0, len(items))
|
|
for _, item := range items {
|
|
key := valueOr(item.SerialNumber, item.Slot+"|"+item.Model)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func dedupeStorage(items []models.Storage) []models.Storage {
|
|
seen := make(map[string]bool)
|
|
out := make([]models.Storage, 0, len(items))
|
|
for _, item := range items {
|
|
key := valueOr(item.SerialNumber, item.Slot+"|"+item.Model)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func dedupeFirmware(items []models.FirmwareInfo) []models.FirmwareInfo {
|
|
seen := make(map[string]bool)
|
|
out := make([]models.FirmwareInfo, 0, len(items))
|
|
for _, item := range items {
|
|
key := item.DeviceName + "|" + item.Version + "|" + item.Description
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func dedupeVolumes(items []models.StorageVolume) []models.StorageVolume {
|
|
seen := make(map[string]bool)
|
|
out := make([]models.StorageVolume, 0, len(items))
|
|
for _, item := range items {
|
|
key := valueOr(item.ID, item.Name+"|"+item.Controller)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func dedupeDevices(items []models.HardwareDevice) []models.HardwareDevice {
|
|
seen := make(map[string]bool)
|
|
out := make([]models.HardwareDevice, 0, len(items))
|
|
for _, item := range items {
|
|
key := valueOr(item.SerialNumber, item.Kind+"|"+item.Slot+"|"+item.Model)
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func dedupeEvents(items []models.Event) []models.Event {
|
|
seen := make(map[string]bool)
|
|
out := make([]models.Event, 0, len(items))
|
|
for _, item := range items {
|
|
key := item.Timestamp.Format(time.RFC3339) + "|" + item.Description
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isHPEManufacturer(v string) bool {
|
|
v = strings.TrimSpace(strings.ToUpper(v))
|
|
return v == "HPE" || v == "HP"
|
|
}
|
|
|
|
func looksLikePSUVendor(v string) bool {
|
|
v = strings.TrimSpace(strings.ToUpper(v))
|
|
switch v {
|
|
case "HPE", "HP", "DELTA", "LITEON", "LTEON":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func looksLikeServerModel(v string) bool {
|
|
v = sanitizeModel(v)
|
|
if v == "" {
|
|
return false
|
|
}
|
|
lower := strings.ToLower(v)
|
|
return strings.Contains(lower, "proliant") || strings.Contains(lower, "apollo") || strings.Contains(lower, "synergy") || strings.Contains(lower, "edgeline")
|
|
}
|
|
|
|
func looksLikeCPUVendor(v string) bool {
|
|
return strings.Contains(v, "Intel") || strings.Contains(v, "AMD")
|
|
}
|
|
|
|
func looksLikeCPUModel(v string) bool {
|
|
return strings.Contains(v, "Xeon") || strings.Contains(v, "EPYC") || strings.Contains(v, "Opteron")
|
|
}
|
|
|
|
func isUnavailable(v string) bool {
|
|
v = strings.TrimSpace(strings.ToUpper(v))
|
|
return v == "" || v == "NOT AVAILABLE" || v == "UNKNOWN" || v == "N/A"
|
|
}
|
|
|
|
func cleanUnavailable(v string) string {
|
|
if isUnavailable(v) {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(v)
|
|
}
|
|
|
|
func looksLikePartNumber(v string) bool {
|
|
return partNumberPattern.MatchString(strings.TrimSpace(v))
|
|
}
|
|
|
|
func isLikelySerial(v string) bool {
|
|
v = strings.TrimSpace(v)
|
|
if len(v) < 6 || len(v) > 24 || strings.Contains(v, "-") || isUnavailable(v) {
|
|
return false
|
|
}
|
|
for _, r := range v {
|
|
if (r < '0' || r > '9') && (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func looksLikeLocation(v string) bool {
|
|
lower := strings.ToLower(strings.TrimSpace(v))
|
|
return strings.HasPrefix(lower, "slot ") || strings.HasPrefix(lower, "ocp slot") || strings.HasPrefix(lower, "pci-e slot") || strings.HasPrefix(lower, "pci-e") || strings.HasPrefix(lower, "nvme drive")
|
|
}
|
|
|
|
func looksLikeVersion(v string) bool {
|
|
v = strings.TrimSpace(v)
|
|
if len(v) < 3 || len(v) > 48 || isUnavailable(v) {
|
|
return false
|
|
}
|
|
if strings.HasPrefix(v, "v") && len(v) > 1 && v[1] >= '0' && v[1] <= '9' {
|
|
return true
|
|
}
|
|
digit := false
|
|
for _, r := range v {
|
|
if r >= '0' && r <= '9' {
|
|
digit = true
|
|
break
|
|
}
|
|
}
|
|
if !digit {
|
|
return false
|
|
}
|
|
return strings.Contains(v, ".") || strings.Contains(strings.ToLower(v), "build")
|
|
}
|
|
|
|
func looksLikeConcreteModel(v string) bool {
|
|
if isUnavailable(v) || looksLikeVersion(v) || looksLikePartNumber(v) || isLikelySerial(v) {
|
|
return false
|
|
}
|
|
if looksLikeLocation(v) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func looksLikeNetworkTitle(code, title string, fields []string) bool {
|
|
lower := strings.ToLower(code + " " + title + " " + strings.Join(fields, " "))
|
|
return strings.Contains(lower, "nic.") || strings.Contains(lower, "network controller") || strings.Contains(lower, "ethernet") || strings.Contains(lower, "broadcom") || strings.Contains(lower, "connectx") || strings.Contains(lower, "mellanox") || strings.Contains(lower, "ocp.slot.15")
|
|
}
|
|
|
|
func isTopLevelFirmwareLabel(v string) bool {
|
|
switch strings.TrimSpace(v) {
|
|
case "System ROM", "Redundant System ROM", "Server Platform Services (SPS) Firmware", "Intelligent Platform Abstraction Data":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func inferVendor(model string) string {
|
|
lower := strings.ToLower(model)
|
|
switch {
|
|
case strings.Contains(lower, "broadcom"):
|
|
return "Broadcom"
|
|
case strings.Contains(lower, "mellanox"), strings.Contains(lower, "connectx"), strings.Contains(lower, "mcx"):
|
|
return "NVIDIA"
|
|
case strings.Contains(lower, "hpe"):
|
|
return "HPE"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func mergePSUs(base, extra []models.PSU) []models.PSU {
|
|
merged := make(map[string]models.PSU)
|
|
order := make([]string, 0, len(base)+len(extra))
|
|
mergeOne := func(item models.PSU) {
|
|
key := strings.ToLower(strings.TrimSpace(item.Slot))
|
|
if key == "" {
|
|
key = strings.ToLower(strings.TrimSpace(valueOr(item.SerialNumber, item.Model+"|"+item.PartNumber)))
|
|
}
|
|
if key == "" {
|
|
return
|
|
}
|
|
current, exists := merged[key]
|
|
if !exists {
|
|
merged[key] = item
|
|
order = append(order, key)
|
|
return
|
|
}
|
|
if current.Slot == "" {
|
|
current.Slot = item.Slot
|
|
}
|
|
current.Present = current.Present || item.Present
|
|
current.Model = valueOr(current.Model, item.Model)
|
|
current.Description = valueOr(current.Description, item.Description)
|
|
current.Vendor = valueOr(current.Vendor, item.Vendor)
|
|
if current.WattageW == 0 {
|
|
current.WattageW = item.WattageW
|
|
}
|
|
current.SerialNumber = valueOr(current.SerialNumber, item.SerialNumber)
|
|
current.PartNumber = valueOr(current.PartNumber, item.PartNumber)
|
|
current.Firmware = valueOr(current.Firmware, item.Firmware)
|
|
current.Status = valueOr(current.Status, item.Status)
|
|
current.InputType = valueOr(current.InputType, item.InputType)
|
|
if current.InputPowerW == 0 {
|
|
current.InputPowerW = item.InputPowerW
|
|
}
|
|
if current.OutputPowerW == 0 {
|
|
current.OutputPowerW = item.OutputPowerW
|
|
}
|
|
if current.InputVoltage == 0 {
|
|
current.InputVoltage = item.InputVoltage
|
|
}
|
|
if current.OutputVoltage == 0 {
|
|
current.OutputVoltage = item.OutputVoltage
|
|
}
|
|
if current.TemperatureC == 0 {
|
|
current.TemperatureC = item.TemperatureC
|
|
}
|
|
current.Details = mergeDetailMaps(current.Details, item.Details)
|
|
merged[key] = current
|
|
}
|
|
for _, item := range base {
|
|
mergeOne(item)
|
|
}
|
|
for _, item := range extra {
|
|
mergeOne(item)
|
|
}
|
|
out := make([]models.PSU, 0, len(order))
|
|
for _, key := range order {
|
|
out = append(out, merged[key])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func enrichNetworkAdapters(items []models.NetworkAdapter, firmwareByVendor map[string]string) []models.NetworkAdapter {
|
|
out := make([]models.NetworkAdapter, 0, len(items))
|
|
for _, item := range items {
|
|
if item.Firmware == "" {
|
|
if fw := firmwareByVendor[strings.ToLower(strings.TrimSpace(item.Vendor))]; fw != "" {
|
|
item.Firmware = fw
|
|
}
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func parseBCertFirmware(entries []ahsEntry) ([]models.FirmwareInfo, map[string]string) {
|
|
out := make([]models.FirmwareInfo, 0, 8)
|
|
nicFirmwareByVendor := make(map[string]string)
|
|
seen := make(map[string]bool)
|
|
|
|
tagNames := map[string]string{
|
|
"SystemProgrammableLogicDevice": "System Programmable Logic Device",
|
|
"ServerPlatformServicesSPSFirmware": "Server Platform Services (SPS) Firmware",
|
|
"STMicroGen11TPM": "TPM Firmware",
|
|
"PrimaryR012U3x16slotsriserx8-x16-x8": "PCIe Riser 1 Programmable Logic Device",
|
|
"HPEMR408i-oGen11": "HPE MR408i-o Gen11",
|
|
"UBM3": "8 SFF 24G x1NVMe/SAS UBM3 BC BP",
|
|
"BCM57191Gb4pBASE-T": "BCM 5719 1Gb 4p BASE-T OCP Adptr",
|
|
"BCM57191Gb4pBASE-TOCP3": "BCM 5719 1Gb 4p BASE-T OCP Adptr",
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !strings.EqualFold(entry.Name, "bcert.pkg") {
|
|
continue
|
|
}
|
|
text := string(entry.Content)
|
|
for _, match := range firmwareLockdownRE.FindAllStringSubmatch(text, -1) {
|
|
fields := parseXMLFields(match[1])
|
|
for tag, value := range fields {
|
|
name := tagNames[tag]
|
|
if name == "" {
|
|
continue
|
|
}
|
|
version := normalizeBCertVersion(tag, value)
|
|
if version == "" {
|
|
continue
|
|
}
|
|
appendFirmware(&out, seen, models.FirmwareInfo{
|
|
DeviceName: name,
|
|
Version: version,
|
|
})
|
|
if strings.Contains(name, "BCM 5719") {
|
|
nicFirmwareByVendor["broadcom"] = version
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return out, nicFirmwareByVendor
|
|
}
|
|
|
|
func parseXMLFields(block string) map[string]string {
|
|
out := make(map[string]string)
|
|
for _, match := range xmlFieldRE.FindAllStringSubmatch(block, -1) {
|
|
out[match[1]] = strings.TrimSpace(match[2])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeBCertVersion(tag, value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || strings.EqualFold(value, "NA") {
|
|
return ""
|
|
}
|
|
switch tag {
|
|
case "UBM3":
|
|
if idx := strings.LastIndex(value, "/"); idx >= 0 && idx+1 < len(value) {
|
|
return strings.TrimSpace(value[idx+1:])
|
|
}
|
|
case "IntegratedLights-OutVI":
|
|
if idx := strings.Index(value, " - "); idx > 0 {
|
|
return strings.TrimSpace(value[:idx])
|
|
}
|
|
case "U54":
|
|
return value
|
|
}
|
|
return value
|
|
}
|
|
|
|
func normalizeLooseVersion(value string) string {
|
|
if match := versionFragmentRE.FindString(strings.TrimSpace(value)); match != "" {
|
|
return match
|
|
}
|
|
return strings.TrimSpace(value)
|
|
}
|
|
|
|
func slotLabelFromCode(code string) string {
|
|
parts := strings.Split(code, ".")
|
|
if len(parts) < 3 {
|
|
return code
|
|
}
|
|
switch parts[0] {
|
|
case "NIC":
|
|
return "Slot " + parts[2]
|
|
case "OCP":
|
|
return "OCP Slot " + parts[2]
|
|
case "PCI":
|
|
return "PCI-E Slot " + parts[2]
|
|
default:
|
|
return code
|
|
}
|
|
}
|
|
|
|
func fabricIDFromPath(path string) string {
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
for i := 0; i+1 < len(parts); i++ {
|
|
if parts[i] == "Fabrics" {
|
|
return parts[i+1]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func inferSeverity(message string) models.Severity {
|
|
lower := strings.ToLower(message)
|
|
switch {
|
|
case strings.Contains(lower, " down"), strings.Contains(lower, "warning"), strings.Contains(lower, "fail"), strings.Contains(lower, "error"):
|
|
return models.SeverityWarning
|
|
default:
|
|
return models.SeverityInfo
|
|
}
|
|
}
|
|
|
|
func inferEventType(message string) string {
|
|
lower := strings.ToLower(message)
|
|
switch {
|
|
case strings.Contains(lower, "login"):
|
|
return "Login"
|
|
case strings.Contains(lower, "logout"):
|
|
return "Logout"
|
|
case strings.Contains(lower, "network"):
|
|
return "Network"
|
|
case strings.Contains(lower, "license"):
|
|
return "License"
|
|
default:
|
|
return "Event"
|
|
}
|
|
}
|
|
|
|
func looksLikeEventMessage(v string) bool {
|
|
if len(v) < 8 || strings.HasPrefix(v, "src/") || strings.HasPrefix(v, "PciRoot(") {
|
|
return false
|
|
}
|
|
lower := strings.ToLower(v)
|
|
return strings.Contains(lower, "login") || strings.Contains(lower, "logout") || strings.Contains(lower, "link") || strings.Contains(lower, "license") || strings.Contains(lower, "security state")
|
|
}
|
|
|
|
func sanitizeModel(v string) string {
|
|
return strings.TrimSuffix(strings.TrimSpace(v), ":")
|
|
}
|
|
|
|
func sanitizeID(v string) string {
|
|
v = strings.ToLower(strings.TrimSpace(v))
|
|
v = strings.ReplaceAll(v, " ", "-")
|
|
v = strings.ReplaceAll(v, "/", "-")
|
|
v = strings.ReplaceAll(v, ".", "-")
|
|
return v
|
|
}
|
|
|
|
func bytesToDecimalGB(size int64) int {
|
|
if size <= 0 {
|
|
return 0
|
|
}
|
|
return int((size + 500_000_000) / 1_000_000_000)
|
|
}
|
|
|
|
func redfishServiceLabel(doc map[string]any, path ...string) string {
|
|
return strings.TrimSpace(asString(nested(doc, path...)))
|
|
}
|
|
|
|
func redfishStatus(v any) string {
|
|
status, _ := v.(map[string]any)
|
|
state := strings.TrimSpace(asString(status["State"]))
|
|
health := strings.TrimSpace(asString(status["Health"]))
|
|
if strings.EqualFold(state, "Absent") {
|
|
return "absent"
|
|
}
|
|
if strings.EqualFold(health, "Warning") || strings.EqualFold(health, "Critical") {
|
|
return strings.ToLower(health)
|
|
}
|
|
if state != "" {
|
|
return strings.ToLower(state)
|
|
}
|
|
if health != "" {
|
|
return strings.ToLower(health)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func redfishID(path string) string {
|
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(parts) == 0 {
|
|
return "unknown"
|
|
}
|
|
return sanitizeID(parts[len(parts)-1])
|
|
}
|
|
|
|
func nested(v any, path ...string) any {
|
|
cur := v
|
|
for _, key := range path {
|
|
m, ok := cur.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
cur = m[key]
|
|
}
|
|
return cur
|
|
}
|
|
|
|
func asString(v any) string {
|
|
switch value := v.(type) {
|
|
case string:
|
|
return value
|
|
case fmt.Stringer:
|
|
return value.String()
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func asInt(doc map[string]any, path ...string) int {
|
|
return int(asInt64(nested(doc, path...)))
|
|
}
|
|
|
|
func asInt64(v any) int64 {
|
|
switch value := v.(type) {
|
|
case float64:
|
|
return int64(value)
|
|
case float32:
|
|
return int64(value)
|
|
case int:
|
|
return int64(value)
|
|
case int64:
|
|
return value
|
|
case json.Number:
|
|
n, _ := value.Int64()
|
|
return n
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func asFloat64(v any) float64 {
|
|
switch t := v.(type) {
|
|
case float64:
|
|
return t
|
|
case float32:
|
|
return float64(t)
|
|
case int:
|
|
return float64(t)
|
|
case int64:
|
|
return float64(t)
|
|
case json.Number:
|
|
f, _ := t.Float64()
|
|
return f
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func asOptionalInt(v any) *int {
|
|
switch value := v.(type) {
|
|
case float64:
|
|
out := int(value)
|
|
return &out
|
|
case int:
|
|
out := value
|
|
return &out
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func asBool(v any) bool {
|
|
b, ok := v.(bool)
|
|
return ok && b
|
|
}
|
|
|
|
func valueOr(v, fallback string) string {
|
|
if strings.TrimSpace(v) != "" {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
return strings.TrimSpace(fallback)
|
|
}
|
|
|
|
func stringSlice(v any) []string {
|
|
items, ok := v.([]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(items))
|
|
for _, item := range items {
|
|
value := strings.TrimSpace(asString(item))
|
|
if value == "" {
|
|
continue
|
|
}
|
|
out = append(out, value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func firstDurableName(doc map[string]any) string {
|
|
items, ok := doc["Identifiers"].([]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
for _, item := range items {
|
|
entry, ok := item.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if value := strings.TrimSpace(asString(entry["DurableName"])); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func mergeDetailMaps(base, extra map[string]any) map[string]any {
|
|
if len(extra) == 0 {
|
|
return base
|
|
}
|
|
if base == nil {
|
|
base = make(map[string]any, len(extra))
|
|
}
|
|
for key, value := range extra {
|
|
if _, exists := base[key]; !exists || isZeroValue(base[key]) {
|
|
base[key] = value
|
|
}
|
|
}
|
|
return base
|
|
}
|
|
|
|
func isZeroValue(v any) bool {
|
|
switch t := v.(type) {
|
|
case nil:
|
|
return true
|
|
case string:
|
|
return strings.TrimSpace(t) == ""
|
|
case int:
|
|
return t == 0
|
|
case int64:
|
|
return t == 0
|
|
case float64:
|
|
return t == 0
|
|
case bool:
|
|
return !t
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func boolPtr(v bool) *bool {
|
|
out := v
|
|
return &out
|
|
}
|