Files
logpile/internal/collector/redfish_replay.go
2026-03-15 21:38:28 +03:00

1625 lines
50 KiB
Go

package collector
import (
"encoding/json"
"fmt"
"log"
"sort"
"strings"
"time"
"git.mchus.pro/mchus/logpile/internal/models"
)
// ReplayRedfishFromRawPayloads rebuilds AnalysisResult from saved Redfish raw payloads.
// It expects rawPayloads["redfish_tree"] to contain a map[path]document snapshot.
func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (*models.AnalysisResult, error) {
if len(rawPayloads) == 0 {
return nil, fmt.Errorf("missing raw_payloads")
}
treeAny, ok := rawPayloads["redfish_tree"]
if !ok {
return nil, fmt.Errorf("raw_payloads.redfish_tree is missing")
}
tree, ok := treeAny.(map[string]interface{})
if !ok || len(tree) == 0 {
return nil, fmt.Errorf("raw_payloads.redfish_tree has invalid format")
}
r := redfishSnapshotReader{tree: tree}
if emit != nil {
emit(Progress{Status: "running", Progress: 10, Message: "Redfish snapshot: replay service root..."})
}
if _, err := r.getJSON("/redfish/v1"); err != nil {
log.Printf("redfish replay: service root /redfish/v1 missing from snapshot, continuing with defaults: %v", err)
}
systemPaths := r.discoverMemberPaths("/redfish/v1/Systems", "/redfish/v1/Systems/1")
chassisPaths := r.discoverMemberPaths("/redfish/v1/Chassis", "/redfish/v1/Chassis/1")
managerPaths := r.discoverMemberPaths("/redfish/v1/Managers", "/redfish/v1/Managers/1")
primarySystem := firstPathOrDefault(systemPaths, "/redfish/v1/Systems/1")
primaryChassis := firstPathOrDefault(chassisPaths, "/redfish/v1/Chassis/1")
primaryManager := firstPathOrDefault(managerPaths, "/redfish/v1/Managers/1")
if emit != nil {
emit(Progress{Status: "running", Progress: 30, Message: "Redfish snapshot: replay system..."})
}
systemDoc, err := r.getJSON(primarySystem)
if err != nil {
return nil, fmt.Errorf("system info: %w", err)
}
chassisDoc, _ := r.getJSON(primaryChassis)
biosDoc, _ := r.getJSON(joinPath(primarySystem, "/Bios"))
secureBootDoc, _ := r.getJSON(joinPath(primarySystem, "/SecureBoot"))
systemFRUDoc, _ := r.getJSON(joinPath(primarySystem, "/Oem/Public/FRU"))
chassisFRUDoc, _ := r.getJSON(joinPath(primaryChassis, "/Oem/Public/FRU"))
fruDoc := systemFRUDoc
if len(fruDoc) == 0 {
fruDoc = chassisFRUDoc
}
boardFallbackDocs := r.collectBoardFallbackDocs(systemPaths, chassisPaths)
if emit != nil {
emit(Progress{Status: "running", Progress: 55, Message: "Redfish snapshot: replay CPU/RAM/Storage..."})
}
processors := r.collectProcessors(primarySystem)
memory := r.collectMemory(primarySystem)
storageDevices := r.collectStorage(primarySystem)
storageVolumes := r.collectStorageVolumes(primarySystem)
if emit != nil {
emit(Progress{Status: "running", Progress: 80, Message: "Redfish snapshot: replay network/BMC..."})
}
psus := r.collectPSUs(chassisPaths)
pcieDevices := r.collectPCIeDevices(systemPaths, chassisPaths)
gpus := r.collectGPUs(systemPaths, chassisPaths)
gpus = r.collectGPUsFromProcessors(systemPaths, chassisPaths, gpus)
nics := r.collectNICs(chassisPaths)
r.enrichNICsFromNetworkInterfaces(&nics, systemPaths)
thresholdSensors := r.collectThresholdSensors(chassisPaths)
thermalSensors := r.collectThermalSensors(chassisPaths)
powerSensors := r.collectPowerSensors(chassisPaths)
discreteEvents := r.collectDiscreteSensorEvents(chassisPaths)
healthEvents := r.collectHealthSummaryEvents(chassisPaths)
driveFetchWarningEvents := buildDriveFetchWarningEvents(rawPayloads)
managerDoc, _ := r.getJSON(primaryManager)
networkProtocolDoc, _ := r.getJSON(joinPath(primaryManager, "/NetworkProtocol"))
firmware := parseFirmware(systemDoc, biosDoc, managerDoc, secureBootDoc, networkProtocolDoc)
firmware = dedupeFirmwareInfo(append(firmware, r.collectFirmwareInventory()...))
boardInfo := parseBoardInfoWithFallback(systemDoc, chassisDoc, fruDoc)
applyBoardInfoFallbackFromDocs(&boardInfo, boardFallbackDocs)
boardInfo.BMCMACAddress = r.collectBMCMAC(managerPaths)
assemblyFRU := r.collectAssemblyFRU(chassisPaths)
collectedAt, sourceTimezone := inferRedfishCollectionTime(managerDoc, rawPayloads)
result := &models.AnalysisResult{
CollectedAt: collectedAt,
SourceTimezone: sourceTimezone,
Events: append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...),
FRU: assemblyFRU,
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
RawPayloads: cloneRawPayloads(rawPayloads),
Hardware: &models.HardwareConfig{
BoardInfo: boardInfo,
CPUs: processors,
Memory: memory,
Storage: storageDevices,
Volumes: storageVolumes,
PCIeDevices: pcieDevices,
GPUs: gpus,
PowerSupply: psus,
NetworkAdapters: nics,
Firmware: firmware,
},
}
if strings.TrimSpace(sourceTimezone) != "" {
if result.RawPayloads == nil {
result.RawPayloads = map[string]any{}
}
result.RawPayloads["source_timezone"] = sourceTimezone
}
appendMissingServerModelWarning(result, systemDoc, joinPath(primarySystem, "/Oem/Public/FRU"), joinPath(primaryChassis, "/Oem/Public/FRU"))
return result, nil
}
func inferRedfishCollectionTime(managerDoc map[string]interface{}, rawPayloads map[string]any) (time.Time, string) {
dateTime := strings.TrimSpace(asString(managerDoc["DateTime"]))
offset := strings.TrimSpace(asString(managerDoc["DateTimeLocalOffset"]))
if dateTime != "" {
if ts, err := time.Parse(time.RFC3339Nano, dateTime); err == nil {
if offset == "" {
offset = ts.Format("-07:00")
}
return ts.UTC(), offset
}
if ts, err := time.Parse(time.RFC3339, dateTime); err == nil {
if offset == "" {
offset = ts.Format("-07:00")
}
return ts.UTC(), offset
}
}
if offset == "" && len(rawPayloads) > 0 {
if tz, ok := rawPayloads["source_timezone"].(string); ok {
offset = strings.TrimSpace(tz)
}
}
return time.Time{}, offset
}
func appendMissingServerModelWarning(result *models.AnalysisResult, systemDoc map[string]interface{}, systemFRUPath, chassisFRUPath string) {
if result == nil || result.Hardware == nil {
return
}
if strings.TrimSpace(result.Hardware.BoardInfo.ProductName) != "" {
return
}
reasons := make([]string, 0, 3)
systemModelRaw := strings.TrimSpace(asString(systemDoc["Model"]))
if systemModelRaw != "" && normalizeRedfishIdentityField(systemModelRaw) == "" {
reasons = append(reasons, fmt.Sprintf("system model is placeholder: %q", systemModelRaw))
}
errs := redfishFetchErrorsFromRawPayloads(result.RawPayloads)
if msg := errs[normalizeRedfishPath(systemFRUPath)]; strings.TrimSpace(msg) != "" {
reasons = append(reasons, fmt.Sprintf("%s unavailable: %s", systemFRUPath, msg))
}
if msg := errs[normalizeRedfishPath(chassisFRUPath)]; strings.TrimSpace(msg) != "" {
reasons = append(reasons, fmt.Sprintf("%s unavailable: %s", chassisFRUPath, msg))
}
if len(reasons) == 0 {
reasons = append(reasons, "no non-placeholder ProductName/Model found in collected Redfish documents")
}
result.Events = append(result.Events, models.Event{
Timestamp: time.Now(),
Source: "Redfish",
EventType: "Collection Warning",
Severity: models.SeverityWarning,
Description: "Server model is missing in collected Redfish data",
RawData: strings.Join(reasons, "; "),
})
}
func redfishFetchErrorsFromRawPayloads(rawPayloads map[string]any) map[string]string {
out := make(map[string]string)
if len(rawPayloads) == 0 {
return out
}
raw, ok := rawPayloads["redfish_fetch_errors"]
if !ok {
return out
}
switch list := raw.(type) {
case []map[string]interface{}:
return redfishFetchErrorListToMap(list)
case []interface{}:
normalized := make([]map[string]interface{}, 0, len(list))
for _, item := range list {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
normalized = append(normalized, m)
}
return redfishFetchErrorListToMap(normalized)
default:
return out
}
}
func buildDriveFetchWarningEvents(rawPayloads map[string]any) []models.Event {
errs := redfishFetchErrorsFromRawPayloads(rawPayloads)
if len(errs) == 0 {
return nil
}
paths := make([]string, 0, len(errs))
timeoutCount := 0
for path, msg := range errs {
normalizedPath := normalizeRedfishPath(path)
if !strings.Contains(strings.ToLower(normalizedPath), "/drives/") {
continue
}
paths = append(paths, normalizedPath)
low := strings.ToLower(msg)
if strings.Contains(low, "timeout") || strings.Contains(low, "deadline exceeded") {
timeoutCount++
}
}
if len(paths) == 0 {
return nil
}
sort.Strings(paths)
preview := paths
const maxPreview = 8
if len(preview) > maxPreview {
preview = preview[:maxPreview]
}
rawData := strings.Join(preview, ", ")
if len(paths) > len(preview) {
rawData = fmt.Sprintf("%s (+%d more)", rawData, len(paths)-len(preview))
}
if timeoutCount > 0 {
rawData = fmt.Sprintf("timeouts=%d; paths=%s", timeoutCount, rawData)
}
return []models.Event{
{
Timestamp: time.Now(),
Source: "Redfish",
EventType: "Collection Warning",
Severity: models.SeverityWarning,
Description: fmt.Sprintf("%d drive documents were unavailable; storage details may be incomplete", len(paths)),
RawData: rawData,
},
}
}
func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo {
docs, err := r.getCollectionMembers("/redfish/v1/UpdateService/FirmwareInventory")
if err != nil || len(docs) == 0 {
return nil
}
out := make([]models.FirmwareInfo, 0, len(docs))
for _, doc := range docs {
version := firstNonEmpty(
asString(doc["Version"]),
asString(doc["FirmwareVersion"]),
asString(doc["SoftwareVersion"]),
)
if strings.TrimSpace(version) == "" {
continue
}
name := firstNonEmpty(
asString(doc["DeviceName"]),
asString(doc["Name"]),
asString(doc["Id"]),
)
name = strings.TrimSpace(name)
if name == "" {
continue
}
// BMCImageN entries are redundant backup slot labels; skip them.
if strings.HasPrefix(strings.ToLower(name), "bmcimage") {
continue
}
out = append(out, models.FirmwareInfo{DeviceName: name, Version: version})
}
return out
}
func dedupeFirmwareInfo(items []models.FirmwareInfo) []models.FirmwareInfo {
seen := make(map[string]struct{}, len(items))
out := make([]models.FirmwareInfo, 0, len(items))
for _, fw := range items {
name := strings.TrimSpace(fw.DeviceName)
ver := strings.TrimSpace(fw.Version)
if name == "" || ver == "" {
continue
}
key := strings.ToLower(name + "|" + ver)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, models.FirmwareInfo{DeviceName: name, Version: ver})
}
return out
}
func (r redfishSnapshotReader) collectThresholdSensors(chassisPaths []string) []models.SensorReading {
out := make([]models.SensorReading, 0)
seen := make(map[string]struct{})
for _, chassisPath := range chassisPaths {
thresholdPath := joinPath(chassisPath, "/ThresholdSensors")
docs, _ := r.getCollectionMembers(thresholdPath)
if len(docs) == 0 {
if thresholdDoc, err := r.getJSON(thresholdPath); err == nil {
docs = append(docs, redfishInlineSensors(thresholdDoc)...)
}
}
for _, doc := range docs {
sensor, ok := parseThresholdSensor(doc)
if !ok {
continue
}
key := strings.ToLower(strings.TrimSpace(sensor.Name))
if key == "" {
key = strings.ToLower(strings.TrimSpace(sensor.Type) + "|" + strings.TrimSpace(sensor.RawValue))
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
out = append(out, sensor)
}
}
return out
}
func parseThresholdSensor(doc map[string]interface{}) (models.SensorReading, bool) {
if len(doc) == 0 {
return models.SensorReading{}, false
}
name := firstNonEmpty(asString(doc["Name"]), asString(doc["Id"]))
status := mapStatus(doc["Status"])
if status == "" {
status = firstNonEmpty(asString(doc["Health"]), asString(doc["State"]))
}
reading := 0.0
unit := ""
rawValue := ""
switch {
case asString(doc["ReadingCelsius"]) != "":
reading = asFloat(doc["ReadingCelsius"])
unit = "C"
rawValue = asString(doc["ReadingCelsius"])
case asString(doc["ReadingVolts"]) != "":
reading = asFloat(doc["ReadingVolts"])
unit = "V"
rawValue = asString(doc["ReadingVolts"])
case asString(doc["ReadingAmps"]) != "":
reading = asFloat(doc["ReadingAmps"])
unit = "A"
rawValue = asString(doc["ReadingAmps"])
case asString(doc["ReadingWatts"]) != "":
reading = asFloat(doc["ReadingWatts"])
unit = "W"
rawValue = asString(doc["ReadingWatts"])
case asString(doc["Reading"]) != "":
reading = asFloat(doc["Reading"])
unit = asString(doc["ReadingUnits"])
rawValue = asString(doc["Reading"])
}
if name == "" && rawValue == "" && status == "" {
return models.SensorReading{}, false
}
return models.SensorReading{
Name: firstNonEmpty(name, "threshold-sensor"),
Type: firstNonEmpty(asString(doc["ReadingType"]), asString(doc["SensorType"]), "threshold"),
Value: reading,
Unit: unit,
RawValue: rawValue,
Status: status,
}, true
}
func (r redfishSnapshotReader) collectDiscreteSensorEvents(chassisPaths []string) []models.Event {
out := make([]models.Event, 0)
for _, chassisPath := range chassisPaths {
discretePath := joinPath(chassisPath, "/DiscreteSensors")
docs, _ := r.getCollectionMembers(discretePath)
if len(docs) == 0 {
if discreteDoc, err := r.getJSON(discretePath); err == nil {
docs = append(docs, redfishInlineSensors(discreteDoc)...)
}
}
for _, doc := range docs {
ev, ok := parseDiscreteSensorEvent(doc)
if !ok {
continue
}
out = append(out, ev)
}
}
return out
}
func parseDiscreteSensorEvent(doc map[string]interface{}) (models.Event, bool) {
name := firstNonEmpty(asString(doc["Name"]), asString(doc["Id"]))
status := mapStatus(doc["Status"])
if status == "" {
status = firstNonEmpty(asString(doc["Health"]), asString(doc["State"]))
}
if name == "" || status == "" {
return models.Event{}, false
}
normalized := strings.ToLower(strings.TrimSpace(status))
if normalized == "ok" || normalized == "enabled" || normalized == "normal" || normalized == "present" {
return models.Event{}, false
}
return models.Event{
Timestamp: time.Now(),
Source: "Redfish",
SensorName: name,
EventType: "Discrete Sensor Status",
Severity: models.SeverityWarning,
Description: fmt.Sprintf("%s reports %s", name, status),
RawData: firstNonEmpty(asString(doc["Description"]), status),
}, true
}
func (r redfishSnapshotReader) collectThermalSensors(chassisPaths []string) []models.SensorReading {
out := make([]models.SensorReading, 0)
for _, chassisPath := range chassisPaths {
doc, err := r.getJSON(joinPath(chassisPath, "/Thermal"))
if err != nil || len(doc) == 0 {
continue
}
for _, fanDoc := range redfishArrayObjects(doc["Fans"]) {
out = append(out, parseThermalFanSensor(fanDoc))
}
for _, tempDoc := range redfishArrayObjects(doc["Temperatures"]) {
out = append(out, parseThermalTemperatureSensor(tempDoc))
}
}
return out
}
func (r redfishSnapshotReader) collectPowerSensors(chassisPaths []string) []models.SensorReading {
out := make([]models.SensorReading, 0)
for _, chassisPath := range chassisPaths {
doc, err := r.getJSON(joinPath(chassisPath, "/Power"))
if err != nil || len(doc) == 0 {
continue
}
out = append(out, parsePowerOemPublicSensors(doc)...)
for _, controlDoc := range redfishArrayObjects(doc["PowerControl"]) {
if sensor, ok := parsePowerControlSensor(controlDoc); ok {
out = append(out, sensor)
}
}
for _, psuDoc := range redfishArrayObjects(doc["PowerSupplies"]) {
out = append(out, parsePowerSupplySensors(psuDoc)...)
}
}
return out
}
func parseThermalFanSensor(doc map[string]interface{}) models.SensorReading {
name := firstNonEmpty(asString(doc["Name"]), asString(doc["MemberId"]), "Fan")
unit := firstNonEmpty(asString(doc["ReadingUnits"]), "RPM")
value := asFloat(doc["Reading"])
raw := firstNonEmpty(asString(doc["Reading"]), asString(doc["Name"]))
status := firstNonEmpty(mapStatus(doc["Status"]), asString(doc["State"]), asString(doc["Health"]), "unknown")
return models.SensorReading{
Name: name,
Type: "fan_speed",
Value: value,
Unit: unit,
RawValue: raw,
Status: status,
}
}
func parseThermalTemperatureSensor(doc map[string]interface{}) models.SensorReading {
name := firstNonEmpty(asString(doc["Name"]), asString(doc["MemberId"]), "Temperature")
reading := asFloat(doc["ReadingCelsius"])
raw := asString(doc["ReadingCelsius"])
if raw == "" {
reading = asFloat(doc["Reading"])
raw = asString(doc["Reading"])
}
status := firstNonEmpty(mapStatus(doc["Status"]), asString(doc["State"]), asString(doc["Health"]), "unknown")
return models.SensorReading{
Name: name,
Type: "temperature",
Value: reading,
Unit: "C",
RawValue: raw,
Status: status,
}
}
func parsePowerOemPublicSensors(doc map[string]interface{}) []models.SensorReading {
oem, ok := doc["Oem"].(map[string]interface{})
if !ok {
return nil
}
public, ok := oem["Public"].(map[string]interface{})
if !ok {
return nil
}
var out []models.SensorReading
add := func(name, key string) {
raw := asString(public[key])
if strings.TrimSpace(raw) == "" {
return
}
out = append(out, models.SensorReading{
Name: name,
Type: "power",
Value: asFloat(public[key]),
Unit: "W",
RawValue: raw,
Status: "OK",
})
}
add("Total_Power", "TotalPower")
add("CPU_Power", "CurrentCPUPowerWatts")
add("Memory_Power", "CurrentMemoryPowerWatts")
add("Fan_Power", "CurrentFANPowerWatts")
return out
}
func parsePowerControlSensor(doc map[string]interface{}) (models.SensorReading, bool) {
raw := asString(doc["PowerConsumedWatts"])
if strings.TrimSpace(raw) == "" {
return models.SensorReading{}, false
}
name := firstNonEmpty(asString(doc["Name"]), asString(doc["MemberId"]), "PowerControl")
status := firstNonEmpty(mapStatus(doc["Status"]), asString(doc["State"]), asString(doc["Health"]), "unknown")
return models.SensorReading{
Name: name + "_Consumed",
Type: "power",
Value: asFloat(doc["PowerConsumedWatts"]),
Unit: "W",
RawValue: raw,
Status: status,
}, true
}
func parsePowerSupplySensors(doc map[string]interface{}) []models.SensorReading {
name := firstNonEmpty(asString(doc["Name"]), "PSU")
status := firstNonEmpty(mapStatus(doc["Status"]), asString(doc["State"]), asString(doc["Health"]), "unknown")
var out []models.SensorReading
add := func(suffix, key, unit string) {
raw := asString(doc[key])
if strings.TrimSpace(raw) == "" {
return
}
out = append(out, models.SensorReading{
Name: fmt.Sprintf("%s_%s", name, suffix),
Type: strings.ToLower(suffix),
Value: asFloat(doc[key]),
Unit: unit,
RawValue: raw,
Status: status,
})
}
add("InputPower", "PowerInputWatts", "W")
add("OutputPower", "LastPowerOutputWatts", "W")
add("InputVoltage", "LineInputVoltage", "V")
return out
}
func redfishArrayObjects(v any) []map[string]interface{} {
list, ok := v.([]interface{})
if !ok || len(list) == 0 {
return nil
}
out := make([]map[string]interface{}, 0, len(list))
for _, item := range list {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
out = append(out, m)
}
return out
}
func redfishInlineSensors(doc map[string]interface{}) []map[string]interface{} {
return redfishArrayObjects(doc["Sensors"])
}
func dedupeSensorReadings(items []models.SensorReading) []models.SensorReading {
if len(items) <= 1 {
return items
}
out := make([]models.SensorReading, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, s := range items {
key := strings.ToLower(strings.TrimSpace(s.Name) + "|" + strings.TrimSpace(s.Type))
if strings.TrimSpace(key) == "|" {
key = strings.ToLower(strings.TrimSpace(s.RawValue))
}
if strings.TrimSpace(key) == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, s)
}
return out
}
func (r redfishSnapshotReader) collectHealthSummaryEvents(chassisPaths []string) []models.Event {
out := make([]models.Event, 0)
for _, chassisPath := range chassisPaths {
doc, err := r.getJSON(joinPath(chassisPath, "/HealthSummary"))
if err != nil || len(doc) == 0 {
continue
}
health := firstNonEmpty(
mapStatus(doc["Status"]),
asString(doc["Health"]),
asString(doc["HealthRollup"]),
findFirstNormalizedStringByKeys(doc, "Health", "HealthRollup", "OverallHealth"),
)
if health == "" {
continue
}
if strings.EqualFold(health, "OK") || strings.EqualFold(health, "Normal") {
continue
}
raw, _ := json.Marshal(doc)
out = append(out, models.Event{
Timestamp: time.Now(),
Source: "Redfish",
EventType: "Health Summary",
Severity: models.SeverityWarning,
Description: fmt.Sprintf("Chassis health summary reports %s", health),
RawData: string(raw),
})
}
return out
}
func (r redfishSnapshotReader) enrichNICsFromNetworkInterfaces(nics *[]models.NetworkAdapter, systemPaths []string) {
if nics == nil {
return
}
bySlot := make(map[string]int, len(*nics))
for i, nic := range *nics {
bySlot[strings.ToLower(strings.TrimSpace(nic.Slot))] = i
}
for _, systemPath := range systemPaths {
ifaces, err := r.getCollectionMembers(joinPath(systemPath, "/NetworkInterfaces"))
if err != nil || len(ifaces) == 0 {
continue
}
for _, iface := range ifaces {
slot := firstNonEmpty(asString(iface["Id"]), asString(iface["Name"]))
if strings.TrimSpace(slot) == "" {
continue
}
idx, ok := bySlot[strings.ToLower(strings.TrimSpace(slot))]
if !ok {
*nics = append(*nics, models.NetworkAdapter{
Slot: slot,
Present: true,
Model: firstNonEmpty(asString(iface["Model"]), asString(iface["Name"])),
Status: mapStatus(iface["Status"]),
})
idx = len(*nics) - 1
bySlot[strings.ToLower(strings.TrimSpace(slot))] = idx
}
portsPath := redfishLinkedPath(iface, "NetworkPorts")
if portsPath == "" {
continue
}
portDocs, err := r.getCollectionMembers(portsPath)
if err != nil || len(portDocs) == 0 {
continue
}
macs := append([]string{}, (*nics)[idx].MACAddresses...)
for _, p := range portDocs {
macs = append(macs, collectNetworkPortMACs(p)...)
}
(*nics)[idx].MACAddresses = dedupeStrings(macs)
if sanitizeNetworkPortCount((*nics)[idx].PortCount) == 0 {
(*nics)[idx].PortCount = len(portDocs)
}
}
}
}
func collectNetworkPortMACs(doc map[string]interface{}) []string {
if len(doc) == 0 {
return nil
}
out := make([]string, 0, 4)
if list, ok := doc["AssociatedNetworkAddresses"].([]interface{}); ok {
for _, item := range list {
if s := strings.TrimSpace(asString(item)); s != "" {
out = append(out, s)
}
}
}
for _, key := range []string{"MACAddress", "PermanentMACAddress", "CurrentMACAddress"} {
if s := strings.TrimSpace(asString(doc[key])); s != "" {
out = append(out, s)
}
}
return out
}
func dedupeStrings(items []string) []string {
seen := make(map[string]struct{}, len(items))
out := make([]string, 0, len(items))
for _, item := range items {
s := strings.TrimSpace(item)
if s == "" {
continue
}
key := strings.ToLower(s)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, s)
}
return out
}
func (r redfishSnapshotReader) collectBoardFallbackDocs(systemPaths, chassisPaths []string) []map[string]interface{} {
out := make([]map[string]interface{}, 0)
for _, chassisPath := range chassisPaths {
for _, suffix := range []string{"/Boards", "/Backplanes"} {
path := joinPath(chassisPath, suffix)
if docs, err := r.getCollectionMembers(path); err == nil && len(docs) > 0 {
out = append(out, docs...)
continue
}
if doc, err := r.getJSON(path); err == nil && len(doc) > 0 {
out = append(out, doc)
}
}
}
for _, path := range append(append([]string{}, systemPaths...), chassisPaths...) {
for _, suffix := range []string{"/Oem/Public", "/Oem/Public/ThermalConfig", "/ThermalConfig"} {
docPath := joinPath(path, suffix)
if doc, err := r.getJSON(docPath); err == nil && len(doc) > 0 {
out = append(out, doc)
}
}
}
return out
}
func applyBoardInfoFallbackFromDocs(board *models.BoardInfo, docs []map[string]interface{}) {
if board == nil || len(docs) == 0 {
return
}
for _, doc := range docs {
candidate := parseBoardInfoFromFRUDoc(doc)
if !isLikelyServerProductName(candidate.ProductName) {
continue
}
if board.Manufacturer == "" {
board.Manufacturer = candidate.Manufacturer
}
if board.ProductName == "" {
board.ProductName = candidate.ProductName
}
if board.SerialNumber == "" {
board.SerialNumber = candidate.SerialNumber
}
if board.PartNumber == "" {
board.PartNumber = candidate.PartNumber
}
if board.Manufacturer != "" && board.ProductName != "" && board.SerialNumber != "" && board.PartNumber != "" {
return
}
}
}
func isLikelyServerProductName(v string) bool {
v = strings.TrimSpace(v)
if v == "" {
return false
}
n := strings.ToUpper(v)
if strings.Contains(n, "NULL") {
return false
}
componentTokens := []string{
"DIMM", "DDR", "NVME", "SSD", "HDD", "GPU", "NIC", "RAID",
"PSU", "FAN", "BACKPLANE", "FRU",
}
for _, token := range componentTokens {
if strings.Contains(n, strings.ToUpper(token)) {
return false
}
}
return true
}
type redfishSnapshotReader struct {
tree map[string]interface{}
}
func (r redfishSnapshotReader) getJSON(requestPath string) (map[string]interface{}, error) {
p := normalizeRedfishPath(requestPath)
if doc, ok := r.tree[p]; ok {
if m, ok := doc.(map[string]interface{}); ok {
return m, nil
}
}
if p != "/" {
if doc, ok := r.tree[stringsTrimTrailingSlash(p)]; ok {
if m, ok := doc.(map[string]interface{}); ok {
return m, nil
}
}
if doc, ok := r.tree[p+"/"]; ok {
if m, ok := doc.(map[string]interface{}); ok {
return m, nil
}
}
}
return nil, fmt.Errorf("snapshot path not found: %s", requestPath)
}
func (r redfishSnapshotReader) getCollectionMembers(collectionPath string) ([]map[string]interface{}, error) {
collection, err := r.getJSON(collectionPath)
if err != nil {
return r.fallbackCollectionMembers(collectionPath, err)
}
memberPaths := redfishCollectionMemberRefs(collection)
if len(memberPaths) == 0 {
return r.fallbackCollectionMembers(collectionPath, nil)
}
out := make([]map[string]interface{}, 0, len(memberPaths))
for _, memberPath := range memberPaths {
doc, err := r.getJSON(memberPath)
if err != nil {
continue
}
if strings.TrimSpace(asString(doc["@odata.id"])) == "" {
doc["@odata.id"] = normalizeRedfishPath(memberPath)
}
out = append(out, doc)
}
if len(out) == 0 {
return r.fallbackCollectionMembers(collectionPath, nil)
}
return out, nil
}
func (r redfishSnapshotReader) fallbackCollectionMembers(collectionPath string, originalErr error) ([]map[string]interface{}, error) {
prefix := strings.TrimSuffix(normalizeRedfishPath(collectionPath), "/") + "/"
if prefix == "/" {
if originalErr != nil {
return nil, originalErr
}
return []map[string]interface{}{}, nil
}
paths := make([]string, 0)
for key := range r.tree {
p := normalizeRedfishPath(key)
if !strings.HasPrefix(p, prefix) {
continue
}
rest := strings.TrimPrefix(p, prefix)
if rest == "" || strings.Contains(rest, "/") {
continue
}
paths = append(paths, p)
}
if len(paths) == 0 {
if originalErr != nil {
return nil, originalErr
}
return []map[string]interface{}{}, nil
}
sort.Strings(paths)
out := make([]map[string]interface{}, 0, len(paths))
for _, p := range paths {
doc, err := r.getJSON(p)
if err != nil {
continue
}
if strings.TrimSpace(asString(doc["@odata.id"])) == "" {
doc["@odata.id"] = normalizeRedfishPath(p)
}
out = append(out, doc)
}
return out, nil
}
func cloneRawPayloads(src map[string]any) map[string]any {
if len(src) == 0 {
return nil
}
dst := make(map[string]any, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func (r redfishSnapshotReader) discoverMemberPaths(collectionPath, fallbackPath string) []string {
collection, err := r.getJSON(collectionPath)
if err == nil {
if refs, ok := collection["Members"].([]interface{}); ok && len(refs) > 0 {
paths := make([]string, 0, len(refs))
for _, refAny := range refs {
ref, ok := refAny.(map[string]interface{})
if !ok {
continue
}
memberPath := asString(ref["@odata.id"])
if memberPath != "" {
paths = append(paths, memberPath)
}
}
if len(paths) > 0 {
return paths
}
}
}
if fallbackPath != "" {
return []string{fallbackPath}
}
return nil
}
func (r redfishSnapshotReader) getLinkedPCIeFunctions(doc map[string]interface{}) []map[string]interface{} {
if links, ok := doc["Links"].(map[string]interface{}); ok {
if refs, ok := links["PCIeFunctions"].([]interface{}); ok && len(refs) > 0 {
out := make([]map[string]interface{}, 0, len(refs))
for _, refAny := range refs {
ref, ok := refAny.(map[string]interface{})
if !ok {
continue
}
memberPath := asString(ref["@odata.id"])
if memberPath == "" {
continue
}
memberDoc, err := r.getJSON(memberPath)
if err != nil {
continue
}
out = append(out, memberDoc)
}
return out
}
}
if pcieFunctions, ok := doc["PCIeFunctions"].(map[string]interface{}); ok {
if collectionPath := asString(pcieFunctions["@odata.id"]); collectionPath != "" {
memberDocs, err := r.getCollectionMembers(collectionPath)
if err == nil {
return memberDocs
}
}
}
return nil
}
func (r redfishSnapshotReader) getLinkedSupplementalDocs(doc map[string]interface{}, keys ...string) []map[string]interface{} {
if len(doc) == 0 || len(keys) == 0 {
return nil
}
var out []map[string]interface{}
seen := make(map[string]struct{})
for _, key := range keys {
path := normalizeRedfishPath(redfishLinkedPath(doc, key))
if path == "" {
continue
}
if _, ok := seen[path]; ok {
continue
}
supplementalDoc, err := r.getJSON(path)
if err != nil || len(supplementalDoc) == 0 {
continue
}
seen[path] = struct{}{}
out = append(out, supplementalDoc)
}
return out
}
func (r redfishSnapshotReader) collectProcessors(systemPath string) []models.CPU {
memberDocs, err := r.getCollectionMembers(joinPath(systemPath, "/Processors"))
if err != nil || len(memberDocs) == 0 {
return nil
}
out := make([]models.CPU, 0, len(memberDocs))
socketIdx := 0
for _, doc := range memberDocs {
if pt := strings.TrimSpace(asString(doc["ProcessorType"])); pt != "" &&
!strings.EqualFold(pt, "CPU") && !strings.EqualFold(pt, "General") {
continue
}
cpu := parseCPUs([]map[string]interface{}{doc})[0]
if cpu.Socket == 0 && socketIdx > 0 && strings.TrimSpace(asString(doc["Socket"])) == "" {
cpu.Socket = socketIdx
if cpu.Details == nil {
cpu.Details = map[string]any{}
}
cpu.Details["socket"] = cpu.Socket
}
supplementalDocs := r.getLinkedSupplementalDocs(doc, "ProcessorMetrics", "EnvironmentMetrics", "Metrics")
if len(supplementalDocs) > 0 {
cpu.Details = mergeGenericDetails(cpu.Details, redfishCPUDetailsAcrossDocs(doc, supplementalDocs...))
}
out = append(out, cpu)
socketIdx++
}
return out
}
func (r redfishSnapshotReader) collectMemory(systemPath string) []models.MemoryDIMM {
memberDocs, err := r.getCollectionMembers(joinPath(systemPath, "/Memory"))
if err != nil || len(memberDocs) == 0 {
return nil
}
out := make([]models.MemoryDIMM, 0, len(memberDocs))
for _, doc := range memberDocs {
dimm := parseMemory([]map[string]interface{}{doc})[0]
supplementalDocs := r.getLinkedSupplementalDocs(doc, "MemoryMetrics", "EnvironmentMetrics", "Metrics")
if len(supplementalDocs) > 0 {
dimm.Details = mergeGenericDetails(dimm.Details, redfishMemoryDetailsAcrossDocs(doc, supplementalDocs...))
}
out = append(out, dimm)
}
return out
}
func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storage {
var out []models.Storage
storageMembers, _ := r.getCollectionMembers(joinPath(systemPath, "/Storage"))
for _, member := range storageMembers {
if driveCollection, ok := member["Drives"].(map[string]interface{}); ok {
if driveCollectionPath := asString(driveCollection["@odata.id"]); driveCollectionPath != "" {
driveDocs, err := r.getCollectionMembers(driveCollectionPath)
if err == nil {
for _, driveDoc := range driveDocs {
if !isVirtualStorageDrive(driveDoc) {
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
if len(driveDocs) == 0 {
for _, driveDoc := range r.probeDirectDiskBayChildren(driveCollectionPath) {
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
}
continue
}
}
if drives, ok := member["Drives"].([]interface{}); ok {
for _, driveAny := range drives {
driveRef, ok := driveAny.(map[string]interface{})
if !ok {
continue
}
odata := asString(driveRef["@odata.id"])
if odata == "" {
continue
}
driveDoc, err := r.getJSON(odata)
if err != nil {
continue
}
if !isVirtualStorageDrive(driveDoc) {
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
continue
}
if looksLikeDrive(member) {
supplementalDocs := r.getLinkedSupplementalDocs(member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
}
for _, enclosurePath := range redfishLinkRefs(member, "Links", "Enclosures") {
driveDocs, err := r.getCollectionMembers(joinPath(enclosurePath, "/Drives"))
if err == nil {
for _, driveDoc := range driveDocs {
if looksLikeDrive(driveDoc) {
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
if len(driveDocs) == 0 {
for _, driveDoc := range r.probeDirectDiskBayChildren(joinPath(enclosurePath, "/Drives")) {
out = append(out, parseDrive(driveDoc))
}
}
}
}
}
for _, driveDoc := range r.collectKnownStorageMembers(systemPath, []string{
"/Storage/IntelVROC/Drives",
"/Storage/IntelVROC/Controllers/1/Drives",
}) {
if looksLikeDrive(driveDoc) {
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
simpleStorageMembers, _ := r.getCollectionMembers(joinPath(systemPath, "/SimpleStorage"))
for _, member := range simpleStorageMembers {
devices, ok := member["Devices"].([]interface{})
if !ok {
continue
}
for _, devAny := range devices {
devDoc, ok := devAny.(map[string]interface{})
if !ok || !looksLikeDrive(devDoc) {
continue
}
out = append(out, parseDrive(devDoc))
}
}
chassisPaths := r.discoverMemberPaths("/redfish/v1/Chassis", "/redfish/v1/Chassis/1")
for _, chassisPath := range chassisPaths {
driveDocs, err := r.getCollectionMembers(joinPath(chassisPath, "/Drives"))
if err != nil {
continue
}
for _, driveDoc := range driveDocs {
if !looksLikeDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
}
}
for _, chassisPath := range chassisPaths {
if !isSupermicroNVMeBackplanePath(chassisPath) {
continue
}
for _, driveDoc := range r.probeSupermicroNVMeDiskBays(chassisPath) {
if !looksLikeDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
}
}
return dedupeStorage(out)
}
func (r redfishSnapshotReader) collectStorageVolumes(systemPath string) []models.StorageVolume {
var out []models.StorageVolume
storageMembers, _ := r.getCollectionMembers(joinPath(systemPath, "/Storage"))
for _, member := range storageMembers {
controller := firstNonEmpty(asString(member["Id"]), asString(member["Name"]))
volumeCollectionPath := redfishLinkedPath(member, "Volumes")
if volumeCollectionPath == "" {
continue
}
volumeDocs, err := r.getCollectionMembers(volumeCollectionPath)
if err != nil {
continue
}
for _, volDoc := range volumeDocs {
if looksLikeVolume(volDoc) {
out = append(out, parseStorageVolume(volDoc, controller))
}
}
}
for _, volDoc := range r.collectKnownStorageMembers(systemPath, []string{
"/Storage/IntelVROC/Volumes",
"/Storage/HA-RAID/Volumes",
"/Storage/MRVL.HA-RAID/Volumes",
}) {
if looksLikeVolume(volDoc) {
out = append(out, parseStorageVolume(volDoc, storageControllerFromPath(asString(volDoc["@odata.id"]))))
}
}
return dedupeStorageVolumes(out)
}
func (r redfishSnapshotReader) collectKnownStorageMembers(systemPath string, relativeCollections []string) []map[string]interface{} {
var out []map[string]interface{}
for _, rel := range relativeCollections {
docs, err := r.getCollectionMembers(joinPath(systemPath, rel))
if err != nil || len(docs) == 0 {
continue
}
out = append(out, docs...)
}
return out
}
func (r redfishSnapshotReader) probeSupermicroNVMeDiskBays(backplanePath string) []map[string]interface{} {
return r.probeDirectDiskBayChildren(joinPath(backplanePath, "/Drives"))
}
func (r redfishSnapshotReader) probeDirectDiskBayChildren(drivesCollectionPath string) []map[string]interface{} {
var out []map[string]interface{}
for _, path := range directDiskBayCandidates(drivesCollectionPath) {
doc, err := r.getJSON(path)
if err != nil || !looksLikeDrive(doc) {
continue
}
out = append(out, doc)
}
return out
}
func (r redfishSnapshotReader) collectNICs(chassisPaths []string) []models.NetworkAdapter {
var nics []models.NetworkAdapter
for _, chassisPath := range chassisPaths {
adapterDocs, err := r.getCollectionMembers(joinPath(chassisPath, "/NetworkAdapters"))
if err != nil {
continue
}
for _, doc := range adapterDocs {
nic := parseNIC(doc)
for _, pciePath := range networkAdapterPCIeDevicePaths(doc) {
pcieDoc, err := r.getJSON(pciePath)
if err != nil {
continue
}
functionDocs := r.getLinkedPCIeFunctions(pcieDoc)
supplementalDocs := r.getLinkedSupplementalDocs(pcieDoc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
}
enrichNICFromPCIe(&nic, pcieDoc, functionDocs, supplementalDocs)
}
// Collect MACs from NetworkDeviceFunctions when not found via PCIe path.
if len(nic.MACAddresses) == 0 {
r.enrichNICMACsFromNetworkDeviceFunctions(&nic, doc)
}
nics = append(nics, nic)
}
}
return dedupeNetworkAdapters(nics)
}
func (r redfishSnapshotReader) collectPSUs(chassisPaths []string) []models.PSU {
var out []models.PSU
seen := make(map[string]int)
idx := 1
for _, chassisPath := range chassisPaths {
if memberDocs, err := r.getCollectionMembers(joinPath(chassisPath, "/PowerSubsystem/PowerSupplies")); err == nil && len(memberDocs) > 0 {
for _, doc := range memberDocs {
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx)
}
continue
}
if powerDoc, err := r.getJSON(joinPath(chassisPath, "/Power")); err == nil {
if members, ok := powerDoc["PowerSupplies"].([]interface{}); ok && len(members) > 0 {
for _, item := range members {
doc, ok := item.(map[string]interface{})
if !ok {
continue
}
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx)
}
}
}
}
return out
}
func (r redfishSnapshotReader) collectGPUs(systemPaths, chassisPaths []string) []models.GPU {
collections := make([]string, 0, len(systemPaths)*3+len(chassisPaths)*2)
for _, systemPath := range systemPaths {
collections = append(collections, joinPath(systemPath, "/PCIeDevices"))
collections = append(collections, joinPath(systemPath, "/Accelerators"))
collections = append(collections, joinPath(systemPath, "/GraphicsControllers"))
}
for _, chassisPath := range chassisPaths {
collections = append(collections, joinPath(chassisPath, "/PCIeDevices"))
collections = append(collections, joinPath(chassisPath, "/Accelerators"))
}
var out []models.GPU
seen := make(map[string]struct{})
idx := 1
for _, collectionPath := range collections {
memberDocs, err := r.getCollectionMembers(collectionPath)
if err != nil || len(memberDocs) == 0 {
continue
}
for _, doc := range memberDocs {
functionDocs := r.getLinkedPCIeFunctions(doc)
if !looksLikeGPU(doc, functionDocs) {
continue
}
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
}
gpu := parseGPUWithSupplementalDocs(doc, functionDocs, supplementalDocs, idx)
idx++
if shouldSkipGenericGPUDuplicate(out, gpu) {
continue
}
key := gpuDocDedupKey(doc, gpu)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, gpu)
}
}
return dropModelOnlyGPUPlaceholders(out)
}
func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []string) []models.PCIeDevice {
collections := make([]string, 0, len(systemPaths)+len(chassisPaths))
for _, systemPath := range systemPaths {
collections = append(collections, joinPath(systemPath, "/PCIeDevices"))
}
for _, chassisPath := range chassisPaths {
collections = append(collections, joinPath(chassisPath, "/PCIeDevices"))
}
var out []models.PCIeDevice
for _, collectionPath := range collections {
memberDocs, err := r.getCollectionMembers(collectionPath)
if err != nil || len(memberDocs) == 0 {
continue
}
for _, doc := range memberDocs {
functionDocs := r.getLinkedPCIeFunctions(doc)
if looksLikeGPU(doc, functionDocs) {
continue
}
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
}
dev := parsePCIeDeviceWithSupplementalDocs(doc, functionDocs, supplementalDocs)
if isUnidentifiablePCIeDevice(dev) {
continue
}
out = append(out, dev)
}
}
for _, systemPath := range systemPaths {
functionDocs, err := r.getCollectionMembers(joinPath(systemPath, "/PCIeFunctions"))
if err != nil || len(functionDocs) == 0 {
continue
}
for idx, fn := range functionDocs {
supplementalDocs := r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")
dev := parsePCIeFunctionWithSupplementalDocs(fn, supplementalDocs, idx+1)
out = append(out, dev)
}
}
return dedupePCIeDevices(out)
}
func stringsTrimTrailingSlash(s string) string {
for len(s) > 1 && s[len(s)-1] == '/' {
s = s[:len(s)-1]
}
return s
}
// collectBMCMAC returns the MAC address of the first active BMC management
// interface found in Managers/*/EthernetInterfaces. Returns empty string if
// no MAC is available.
func (r redfishSnapshotReader) collectBMCMAC(managerPaths []string) string {
for _, managerPath := range managerPaths {
members, err := r.getCollectionMembers(joinPath(managerPath, "/EthernetInterfaces"))
if err != nil || len(members) == 0 {
continue
}
for _, doc := range members {
mac := strings.TrimSpace(firstNonEmpty(
asString(doc["PermanentMACAddress"]),
asString(doc["MACAddress"]),
))
if mac == "" || strings.EqualFold(mac, "00:00:00:00:00:00") {
continue
}
return strings.ToUpper(mac)
}
}
return ""
}
// collectAssemblyFRU reads Chassis/*/Assembly documents and returns FRU entries
// for subcomponents (backplanes, PSUs, DIMMs, etc.) that carry meaningful
// serial or part numbers. Entries already present in dedicated collections
// (PSUs, DIMMs) are included here as well so that all FRU data is available
// in one place; deduplication by serial is performed.
func (r redfishSnapshotReader) collectAssemblyFRU(chassisPaths []string) []models.FRUInfo {
seen := make(map[string]struct{})
var out []models.FRUInfo
add := func(fru models.FRUInfo) {
key := strings.ToUpper(strings.TrimSpace(fru.SerialNumber))
if key == "" {
key = strings.ToUpper(strings.TrimSpace(fru.Description + "|" + fru.PartNumber))
}
if key == "" || key == "|" {
return
}
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
out = append(out, fru)
}
for _, chassisPath := range chassisPaths {
doc, err := r.getJSON(joinPath(chassisPath, "/Assembly"))
if err != nil || len(doc) == 0 {
continue
}
assemblies, _ := doc["Assemblies"].([]interface{})
for _, aAny := range assemblies {
a, ok := aAny.(map[string]interface{})
if !ok {
continue
}
name := strings.TrimSpace(firstNonEmpty(asString(a["Name"]), asString(a["Description"])))
model := strings.TrimSpace(asString(a["Model"]))
partNumber := strings.TrimSpace(asString(a["PartNumber"]))
serial := extractAssemblySerial(a)
if serial == "" && partNumber == "" {
continue
}
add(models.FRUInfo{
Description: name,
ProductName: model,
SerialNumber: serial,
PartNumber: partNumber,
})
}
}
return out
}
// extractAssemblySerial tries to find a serial number in an Assembly entry.
// Standard Redfish Assembly has no top-level SerialNumber; vendors put it in Oem.
func extractAssemblySerial(a map[string]interface{}) string {
// Some implementations expose it at top level.
if s := strings.TrimSpace(asString(a["SerialNumber"])); s != "" {
return s
}
// Dig into Oem for vendor-specific structures (e.g. Huawei COMMONb).
oem, _ := a["Oem"].(map[string]interface{})
for _, v := range oem {
subtree, ok := v.(map[string]interface{})
if !ok {
continue
}
for _, v2 := range subtree {
node, ok := v2.(map[string]interface{})
if !ok {
continue
}
if s := strings.TrimSpace(asString(node["SerialNumber"])); s != "" {
return s
}
}
}
return ""
}
// enrichNICMACsFromNetworkDeviceFunctions reads the NetworkDeviceFunctions
// collection linked from a NetworkAdapter document and populates the NIC's
// MACAddresses from each function's Ethernet.PermanentMACAddress / MACAddress.
// Called when PCIe-path enrichment does not produce any MACs.
func (r redfishSnapshotReader) enrichNICMACsFromNetworkDeviceFunctions(nic *models.NetworkAdapter, adapterDoc map[string]interface{}) {
ndfCol, ok := adapterDoc["NetworkDeviceFunctions"].(map[string]interface{})
if !ok {
return
}
colPath := asString(ndfCol["@odata.id"])
if colPath == "" {
return
}
funcDocs, err := r.getCollectionMembers(colPath)
if err != nil || len(funcDocs) == 0 {
return
}
for _, fn := range funcDocs {
eth, _ := fn["Ethernet"].(map[string]interface{})
if eth == nil {
continue
}
mac := strings.TrimSpace(firstNonEmpty(
asString(eth["PermanentMACAddress"]),
asString(eth["MACAddress"]),
))
if mac == "" {
continue
}
nic.MACAddresses = dedupeStrings(append(nic.MACAddresses, strings.ToUpper(mac)))
}
if len(funcDocs) > 0 && nic.PortCount == 0 {
nic.PortCount = sanitizeNetworkPortCount(len(funcDocs))
}
}
// collectGPUsFromProcessors finds GPUs that some BMCs (e.g. MSI) expose as
// Processor entries with ProcessorType=GPU rather than as PCIe devices.
// It supplements the existing gpus slice (already found via PCIe path),
// skipping entries already present by UUID or SerialNumber.
// Serial numbers are looked up from Chassis members named after each GPU Id.
func (r redfishSnapshotReader) collectGPUsFromProcessors(systemPaths, chassisPaths []string, existing []models.GPU) []models.GPU {
// Build a lookup: chassis member ID → chassis doc (for serial numbers).
chassisByID := make(map[string]map[string]interface{})
for _, cp := range chassisPaths {
doc, err := r.getJSON(cp)
if err != nil || len(doc) == 0 {
continue
}
id := strings.TrimSpace(asString(doc["Id"]))
if id != "" {
chassisByID[strings.ToUpper(id)] = doc
}
}
// Build dedup sets from existing GPUs.
seenUUID := make(map[string]struct{})
seenSerial := make(map[string]struct{})
for _, g := range existing {
if u := strings.ToUpper(strings.TrimSpace(g.UUID)); u != "" {
seenUUID[u] = struct{}{}
}
if s := strings.ToUpper(strings.TrimSpace(g.SerialNumber)); s != "" {
seenSerial[s] = struct{}{}
}
}
out := append([]models.GPU{}, existing...)
idx := len(existing) + 1
for _, systemPath := range systemPaths {
procDocs, err := r.getCollectionMembers(joinPath(systemPath, "/Processors"))
if err != nil {
continue
}
for _, doc := range procDocs {
if !strings.EqualFold(strings.TrimSpace(asString(doc["ProcessorType"])), "GPU") {
continue
}
// Resolve serial: prefer the processor doc itself (e.g. Supermicro
// HGX_Baseboard_0/Processors/GPU_SXM_N carries SerialNumber directly),
// then fall back to a matching chassis doc keyed by processor Id
// (e.g. MSI: Chassis/GPU_SXM_1/SerialNumber).
gpuID := strings.TrimSpace(asString(doc["Id"]))
serial := findFirstNormalizedStringByKeys(doc, "SerialNumber")
if chassisDoc, ok := chassisByID[strings.ToUpper(gpuID)]; ok {
if cs := strings.TrimSpace(asString(chassisDoc["SerialNumber"])); cs != "" {
serial = cs
}
}
uuid := strings.TrimSpace(asString(doc["UUID"]))
uuidKey := strings.ToUpper(uuid)
serialKey := strings.ToUpper(serial)
if uuidKey != "" {
if _, dup := seenUUID[uuidKey]; dup {
continue
}
seenUUID[uuidKey] = struct{}{}
}
if serialKey != "" {
if _, dup := seenSerial[serialKey]; dup {
continue
}
seenSerial[serialKey] = struct{}{}
}
slotLabel := firstNonEmpty(
redfishLocationLabel(doc["Location"]),
redfishLocationLabel(doc["PhysicalLocation"]),
)
if slotLabel == "" && gpuID != "" {
slotLabel = gpuID
}
if slotLabel == "" {
slotLabel = fmt.Sprintf("GPU%d", idx)
}
out = append(out, models.GPU{
Slot: slotLabel,
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
Manufacturer: asString(doc["Manufacturer"]),
PartNumber: asString(doc["PartNumber"]),
SerialNumber: serial,
UUID: uuid,
Status: mapStatus(doc["Status"]),
})
idx++
}
}
return out
}