1410 lines
44 KiB
Go
1410 lines
44 KiB
Go
package collector
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/logpile/internal/collector/redfishprofile"
|
|
"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)
|
|
managerDoc, _ := r.getJSON(primaryManager)
|
|
biosDoc, _ := r.getJSON(joinPath(primarySystem, "/Bios"))
|
|
|
|
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)
|
|
profileSignals := redfishprofile.CollectSignalsFromTree(tree)
|
|
profileMatch := redfishprofile.MatchProfiles(profileSignals)
|
|
analysisPlan := redfishprofile.ResolveAnalysisPlan(profileMatch, tree, redfishprofile.DiscoveredResources{
|
|
SystemPaths: systemPaths,
|
|
ChassisPaths: chassisPaths,
|
|
ManagerPaths: managerPaths,
|
|
}, profileSignals)
|
|
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, analysisPlan)
|
|
storageVolumes := r.collectStorageVolumes(primarySystem, analysisPlan)
|
|
|
|
if emit != nil {
|
|
emit(Progress{Status: "running", Progress: 80, Message: "Redfish snapshot: replay network/BMC..."})
|
|
}
|
|
psus := r.collectPSUs(chassisPaths)
|
|
pcieDevices := r.collectPCIeDevices(systemPaths, chassisPaths)
|
|
boardInfo := parseBoardInfoWithFallback(systemDoc, chassisDoc, fruDoc)
|
|
applyBoardInfoFallbackFromDocs(&boardInfo, boardFallbackDocs)
|
|
|
|
gpus := r.collectGPUs(systemPaths, chassisPaths, analysisPlan)
|
|
gpus = r.collectGPUsFromProcessors(systemPaths, chassisPaths, gpus, analysisPlan)
|
|
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)
|
|
networkProtocolDoc, _ := r.getJSON(joinPath(primaryManager, "/NetworkProtocol"))
|
|
firmware := parseFirmware(systemDoc, biosDoc, managerDoc, networkProtocolDoc)
|
|
firmware = dedupeFirmwareInfo(append(firmware, r.collectFirmwareInventory()...))
|
|
firmware = filterStorageDriveFirmware(firmware, storageDevices)
|
|
bmcManagementSummary := r.collectBMCManagementSummary(managerPaths)
|
|
boardInfo.BMCMACAddress = strings.TrimSpace(firstNonEmpty(
|
|
asString(bmcManagementSummary["mac_address"]),
|
|
r.collectBMCMAC(managerPaths),
|
|
))
|
|
assemblyFRU := r.collectAssemblyFRU(chassisPaths)
|
|
collectedAt, sourceTimezone := inferRedfishCollectionTime(managerDoc, rawPayloads)
|
|
inventoryLastModifiedAt := inferInventoryLastModifiedTime(r.tree)
|
|
logEntryEvents := parseRedfishLogEntries(rawPayloads, collectedAt)
|
|
sensorHintSummary, sensorHintEvents := r.collectSensorsListHints(chassisPaths, collectedAt)
|
|
bmcManagementEvent := buildBMCManagementSummaryEvent(bmcManagementSummary, collectedAt)
|
|
|
|
result := &models.AnalysisResult{
|
|
CollectedAt: collectedAt,
|
|
InventoryLastModifiedAt: inventoryLastModifiedAt,
|
|
SourceTimezone: sourceTimezone,
|
|
Events: append(append(append(append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+len(logEntryEvents)+len(sensorHintEvents)+2), healthEvents...), discreteEvents...), driveFetchWarningEvents...), logEntryEvents...), sensorHintEvents...), bmcManagementEvent...),
|
|
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,
|
|
},
|
|
}
|
|
match := profileMatch
|
|
for _, profile := range match.Profiles {
|
|
profile.PostAnalyze(result, tree, profileSignals)
|
|
}
|
|
if result.RawPayloads == nil {
|
|
result.RawPayloads = map[string]any{}
|
|
}
|
|
appliedProfiles := make([]string, 0, len(match.Profiles))
|
|
for _, profile := range match.Profiles {
|
|
appliedProfiles = append(appliedProfiles, profile.Name())
|
|
}
|
|
result.RawPayloads["redfish_analysis_profiles"] = map[string]any{
|
|
"mode": match.Mode,
|
|
"profiles": appliedProfiles,
|
|
}
|
|
result.RawPayloads["redfish_analysis_plan"] = map[string]any{
|
|
"mode": analysisPlan.Match.Mode,
|
|
"profiles": appliedProfiles,
|
|
"notes": analysisPlan.Notes,
|
|
"directives": map[string]any{
|
|
"processor_gpu_fallback": analysisPlan.Directives.EnableProcessorGPUFallback,
|
|
"supermicro_nvme_backplane": analysisPlan.Directives.EnableSupermicroNVMeBackplane,
|
|
"processor_gpu_chassis_alias": analysisPlan.Directives.EnableProcessorGPUChassisAlias,
|
|
"generic_graphics_controller_dedup": analysisPlan.Directives.EnableGenericGraphicsControllerDedup,
|
|
"msi_processor_gpu_chassis_lookup": analysisPlan.Directives.EnableMSIProcessorGPUChassisLookup,
|
|
"storage_enclosure_recovery": analysisPlan.Directives.EnableStorageEnclosureRecovery,
|
|
"known_storage_controller_recovery": analysisPlan.Directives.EnableKnownStorageControllerRecovery,
|
|
},
|
|
}
|
|
if strings.TrimSpace(sourceTimezone) != "" {
|
|
result.RawPayloads["source_timezone"] = sourceTimezone
|
|
}
|
|
if len(sensorHintSummary) > 0 {
|
|
result.RawPayloads["redfish_sensor_hints"] = sensorHintSummary
|
|
}
|
|
if len(bmcManagementSummary) > 0 {
|
|
result.RawPayloads["redfish_bmc_network_summary"] = bmcManagementSummary
|
|
}
|
|
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
|
|
}
|
|
|
|
// inferInventoryLastModifiedTime reads InventoryData/Status.InventoryData.LastModifiedTime
|
|
// from the Redfish snapshot. Returns zero time if not present or unparseable.
|
|
func inferInventoryLastModifiedTime(snapshot map[string]interface{}) time.Time {
|
|
docAny, ok := snapshot["/redfish/v1/Oem/Ami/InventoryData/Status"]
|
|
if !ok {
|
|
return time.Time{}
|
|
}
|
|
doc, ok := docAny.(map[string]interface{})
|
|
if !ok {
|
|
return time.Time{}
|
|
}
|
|
invData, ok := doc["InventoryData"].(map[string]interface{})
|
|
if !ok {
|
|
return time.Time{}
|
|
}
|
|
raw := strings.TrimSpace(asString(invData["LastModifiedTime"]))
|
|
if raw == "" {
|
|
return time.Time{}
|
|
}
|
|
for _, layout := range []string{time.RFC3339, time.RFC3339Nano} {
|
|
if ts, err := time.Parse(layout, raw); err == nil {
|
|
t := ts.UTC()
|
|
log.Printf("redfish replay: inventory last modified at %s (InventoryData/Status.LastModifiedTime)", t.Format(time.RFC3339))
|
|
return t
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
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) collectSensorsListHints(chassisPaths []string, collectedAt time.Time) (map[string]any, []models.Event) {
|
|
summary := make(map[string]any)
|
|
var events []models.Event
|
|
var presentDIMMs []string
|
|
dimmTotal := 0
|
|
dimmPresent := 0
|
|
physicalDriveSlots := 0
|
|
activePhysicalDriveSlots := 0
|
|
logicalDriveStatus := ""
|
|
|
|
for _, chassisPath := range chassisPaths {
|
|
doc, err := r.getJSON(joinPath(chassisPath, "/SensorsList"))
|
|
if err != nil || len(doc) == 0 {
|
|
continue
|
|
}
|
|
sensors, ok := doc["SensorsList"].([]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, item := range sensors {
|
|
sensor, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(asString(sensor["SensorName"]))
|
|
sensorType := strings.TrimSpace(asString(sensor["SensorType"]))
|
|
status := strings.TrimSpace(asString(sensor["Status"]))
|
|
switch {
|
|
case strings.HasPrefix(name, "DIMM") && strings.HasSuffix(name, "_Status") && strings.EqualFold(sensorType, "Memory"):
|
|
dimmTotal++
|
|
if redfishSlotStatusLooksPresent(status) {
|
|
dimmPresent++
|
|
presentDIMMs = append(presentDIMMs, strings.TrimSuffix(name, "_Status"))
|
|
}
|
|
case strings.EqualFold(sensorType, "Drive Slot"):
|
|
if strings.EqualFold(name, "Logical_Drive") {
|
|
logicalDriveStatus = firstNonEmpty(logicalDriveStatus, status)
|
|
continue
|
|
}
|
|
physicalDriveSlots++
|
|
if redfishSlotStatusLooksPresent(status) {
|
|
activePhysicalDriveSlots++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if dimmTotal > 0 {
|
|
sort.Strings(presentDIMMs)
|
|
summary["memory_slots"] = map[string]any{
|
|
"total": dimmTotal,
|
|
"present_count": dimmPresent,
|
|
"present_slots": presentDIMMs,
|
|
"source": "SensorsList",
|
|
}
|
|
events = append(events, models.Event{
|
|
Timestamp: replayEventTimestamp(collectedAt),
|
|
Source: "Redfish",
|
|
EventType: "Collection Info",
|
|
Severity: models.SeverityInfo,
|
|
Description: fmt.Sprintf("Memory slot sensors report %d populated positions out of %d", dimmPresent, dimmTotal),
|
|
RawData: firstNonEmpty(strings.Join(presentDIMMs, ", "), "no populated DIMM slots reported"),
|
|
})
|
|
}
|
|
if physicalDriveSlots > 0 || logicalDriveStatus != "" {
|
|
summary["drive_slots"] = map[string]any{
|
|
"physical_total": physicalDriveSlots,
|
|
"physical_active_count": activePhysicalDriveSlots,
|
|
"logical_drive_status": logicalDriveStatus,
|
|
"source": "SensorsList",
|
|
}
|
|
rawParts := []string{
|
|
fmt.Sprintf("physical_active=%d/%d", activePhysicalDriveSlots, physicalDriveSlots),
|
|
}
|
|
if logicalDriveStatus != "" {
|
|
rawParts = append(rawParts, "logical_drive="+logicalDriveStatus)
|
|
}
|
|
events = append(events, models.Event{
|
|
Timestamp: replayEventTimestamp(collectedAt),
|
|
Source: "Redfish",
|
|
EventType: "Collection Info",
|
|
Severity: models.SeverityInfo,
|
|
Description: fmt.Sprintf("Drive slot sensors report %d active physical slots out of %d", activePhysicalDriveSlots, physicalDriveSlots),
|
|
RawData: strings.Join(rawParts, "; "),
|
|
})
|
|
}
|
|
|
|
return summary, events
|
|
}
|
|
|
|
func buildBMCManagementSummaryEvent(summary map[string]any, collectedAt time.Time) []models.Event {
|
|
if len(summary) == 0 {
|
|
return nil
|
|
}
|
|
desc := fmt.Sprintf(
|
|
"BMC management interface %s link=%s ip=%s",
|
|
firstNonEmpty(asString(summary["interface_id"]), "unknown"),
|
|
firstNonEmpty(asString(summary["link_status"]), "unknown"),
|
|
firstNonEmpty(asString(summary["ipv4_address"]), "n/a"),
|
|
)
|
|
rawParts := make([]string, 0, 8)
|
|
for _, part := range []string{
|
|
"mac_address=" + strings.TrimSpace(asString(summary["mac_address"])),
|
|
"speed_mbps=" + strings.TrimSpace(asString(summary["speed_mbps"])),
|
|
"lldp_chassis_name=" + strings.TrimSpace(asString(summary["lldp_chassis_name"])),
|
|
"lldp_port_desc=" + strings.TrimSpace(asString(summary["lldp_port_desc"])),
|
|
"lldp_port_id=" + strings.TrimSpace(asString(summary["lldp_port_id"])),
|
|
"ipv4_gateway=" + strings.TrimSpace(asString(summary["ipv4_gateway"])),
|
|
} {
|
|
if !strings.HasSuffix(part, "=") {
|
|
rawParts = append(rawParts, part)
|
|
}
|
|
}
|
|
if vlan := asInt(summary["lldp_vlan_id"]); vlan > 0 {
|
|
rawParts = append(rawParts, fmt.Sprintf("lldp_vlan_id=%d", vlan))
|
|
}
|
|
if asBool(summary["ncsi_enabled"]) {
|
|
rawParts = append(rawParts, "ncsi_enabled=true")
|
|
}
|
|
return []models.Event{
|
|
{
|
|
Timestamp: replayEventTimestamp(collectedAt),
|
|
Source: "Redfish",
|
|
EventType: "Collection Info",
|
|
Severity: models.SeverityInfo,
|
|
Description: desc,
|
|
RawData: strings.Join(rawParts, "; "),
|
|
},
|
|
}
|
|
}
|
|
|
|
func redfishSlotStatusLooksPresent(status string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(status)) {
|
|
case "ok", "enabled", "present", "warning", "critical":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func replayEventTimestamp(collectedAt time.Time) time.Time {
|
|
if !collectedAt.IsZero() {
|
|
return collectedAt
|
|
}
|
|
return time.Now()
|
|
}
|
|
|
|
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
|
|
}
|
|
// Skip placeholder version strings that carry no useful information.
|
|
if strings.EqualFold(strings.TrimSpace(version), "N/A") {
|
|
continue
|
|
}
|
|
name := firmwareInventoryDeviceName(doc)
|
|
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 firmwareInventoryDeviceName(doc map[string]interface{}) string {
|
|
name := strings.TrimSpace(asString(doc["DeviceName"]))
|
|
if name != "" {
|
|
return name
|
|
}
|
|
|
|
id := strings.TrimSpace(asString(doc["Id"]))
|
|
rawName := strings.TrimSpace(asString(doc["Name"]))
|
|
if strings.EqualFold(rawName, "Software Inventory") || strings.EqualFold(rawName, "Firmware Inventory") {
|
|
if id != "" {
|
|
return id
|
|
}
|
|
}
|
|
if rawName != "" {
|
|
return rawName
|
|
}
|
|
return id
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// filterStorageDriveFirmware removes from fw any entries whose DeviceName+Version
|
|
// already appear as a storage drive's Model+Firmware. Drive firmware is already
|
|
// represented in the Storage section and should not be duplicated in the general
|
|
// firmware list.
|
|
func filterStorageDriveFirmware(fw []models.FirmwareInfo, storage []models.Storage) []models.FirmwareInfo {
|
|
if len(storage) == 0 {
|
|
return fw
|
|
}
|
|
driveFW := make(map[string]struct{}, len(storage))
|
|
for _, d := range storage {
|
|
model := strings.ToLower(strings.TrimSpace(d.Model))
|
|
rev := strings.ToLower(strings.TrimSpace(d.Firmware))
|
|
if model != "" && rev != "" {
|
|
driveFW[model+"|"+rev] = struct{}{}
|
|
}
|
|
}
|
|
out := fw[:0:0]
|
|
for _, f := range fw {
|
|
key := strings.ToLower(strings.TrimSpace(f.DeviceName)) + "|" + strings.ToLower(strings.TrimSpace(f.Version))
|
|
if _, skip := driveFW[key]; !skip {
|
|
out = append(out, f)
|
|
}
|
|
}
|
|
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 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
|
|
}
|
|
|
|
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 redfishFallbackMemberLooksLikePlaceholder(collectionPath, doc) {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(asString(doc["@odata.id"])) == "" {
|
|
doc["@odata.id"] = normalizeRedfishPath(p)
|
|
}
|
|
out = append(out, doc)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func redfishFallbackMemberLooksLikePlaceholder(collectionPath string, doc map[string]interface{}) bool {
|
|
if len(doc) == 0 {
|
|
return true
|
|
}
|
|
path := normalizeRedfishPath(collectionPath)
|
|
switch {
|
|
case strings.HasSuffix(path, "/NetworkAdapters"):
|
|
return redfishNetworkAdapterPlaceholderDoc(doc)
|
|
case strings.HasSuffix(path, "/PCIeDevices"):
|
|
return redfishPCIePlaceholderDoc(doc)
|
|
case strings.Contains(path, "/Storage"):
|
|
return redfishStoragePlaceholderDoc(doc)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func redfishNetworkAdapterPlaceholderDoc(doc map[string]interface{}) bool {
|
|
if normalizeRedfishIdentityField(asString(doc["Model"])) != "" ||
|
|
normalizeRedfishIdentityField(asString(doc["Manufacturer"])) != "" ||
|
|
normalizeRedfishIdentityField(asString(doc["SerialNumber"])) != "" ||
|
|
normalizeRedfishIdentityField(asString(doc["PartNumber"])) != "" ||
|
|
normalizeRedfishIdentityField(asString(doc["BDF"])) != "" ||
|
|
asHexOrInt(doc["VendorId"]) != 0 ||
|
|
asHexOrInt(doc["DeviceId"]) != 0 {
|
|
return false
|
|
}
|
|
return redfishDocHasOnlyAllowedKeys(doc,
|
|
"@odata.context",
|
|
"@odata.id",
|
|
"@odata.type",
|
|
"Id",
|
|
"Name",
|
|
)
|
|
}
|
|
|
|
func redfishPCIePlaceholderDoc(doc map[string]interface{}) bool {
|
|
if normalizeRedfishIdentityField(asString(doc["Model"])) != "" ||
|
|
normalizeRedfishIdentityField(asString(doc["Manufacturer"])) != "" ||
|
|
normalizeRedfishIdentityField(asString(doc["SerialNumber"])) != "" ||
|
|
normalizeRedfishIdentityField(asString(doc["PartNumber"])) != "" ||
|
|
normalizeRedfishIdentityField(asString(doc["BDF"])) != "" ||
|
|
asHexOrInt(doc["VendorId"]) != 0 ||
|
|
asHexOrInt(doc["DeviceId"]) != 0 {
|
|
return false
|
|
}
|
|
return redfishDocHasOnlyAllowedKeys(doc,
|
|
"@odata.context",
|
|
"@odata.id",
|
|
"@odata.type",
|
|
"Id",
|
|
"Name",
|
|
)
|
|
}
|
|
|
|
func redfishStoragePlaceholderDoc(doc map[string]interface{}) bool {
|
|
if normalizeRedfishIdentityField(asString(doc["Model"])) != "" ||
|
|
normalizeRedfishIdentityField(asString(doc["Manufacturer"])) != "" ||
|
|
normalizeRedfishIdentityField(asString(doc["SerialNumber"])) != "" ||
|
|
normalizeRedfishIdentityField(asString(doc["PartNumber"])) != "" ||
|
|
normalizeRedfishIdentityField(asString(doc["BDF"])) != "" ||
|
|
asHexOrInt(doc["VendorId"]) != 0 ||
|
|
asHexOrInt(doc["DeviceId"]) != 0 {
|
|
return false
|
|
}
|
|
if !redfishDocHasOnlyAllowedKeys(doc,
|
|
"@odata.id",
|
|
"@odata.type",
|
|
"Drives",
|
|
"Drives@odata.count",
|
|
"LogicalDisk",
|
|
"PhysicalDisk",
|
|
"Name",
|
|
) {
|
|
return false
|
|
}
|
|
return redfishFieldIsEmptyCollection(doc["Drives"]) &&
|
|
redfishFieldIsZeroLike(doc["Drives@odata.count"]) &&
|
|
redfishFieldIsEmptyCollection(doc["LogicalDisk"]) &&
|
|
redfishFieldIsEmptyCollection(doc["PhysicalDisk"])
|
|
}
|
|
|
|
func redfishDocHasOnlyAllowedKeys(doc map[string]interface{}, allowed ...string) bool {
|
|
if len(doc) == 0 {
|
|
return false
|
|
}
|
|
allowedSet := make(map[string]struct{}, len(allowed))
|
|
for _, key := range allowed {
|
|
allowedSet[key] = struct{}{}
|
|
}
|
|
for key := range doc {
|
|
if _, ok := allowedSet[key]; !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func redfishFieldIsEmptyCollection(v any) bool {
|
|
switch x := v.(type) {
|
|
case nil:
|
|
return true
|
|
case []interface{}:
|
|
return len(x) == 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func redfishFieldIsZeroLike(v any) bool {
|
|
switch x := v.(type) {
|
|
case nil:
|
|
return true
|
|
case int:
|
|
return x == 0
|
|
case int32:
|
|
return x == 0
|
|
case int64:
|
|
return x == 0
|
|
case float64:
|
|
return x == 0
|
|
case string:
|
|
x = strings.TrimSpace(x)
|
|
return x == "" || x == "0"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
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 ref, ok := links["PCIeFunction"].(map[string]interface{}); ok {
|
|
memberPath := asString(ref["@odata.id"])
|
|
if memberPath != "" {
|
|
memberDoc, err := r.getJSON(memberPath)
|
|
if err == nil {
|
|
return []map[string]interface{}{memberDoc}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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 dedupeJSONDocsByPath(docs []map[string]interface{}) []map[string]interface{} {
|
|
if len(docs) == 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{}, len(docs))
|
|
out := make([]map[string]interface{}, 0, len(docs))
|
|
for _, doc := range docs {
|
|
if len(doc) == 0 {
|
|
continue
|
|
}
|
|
key := normalizeRedfishPath(asString(doc["@odata.id"]))
|
|
if key == "" {
|
|
payload, err := json.Marshal(doc)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
key = string(payload)
|
|
}
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
out = append(out, doc)
|
|
}
|
|
return out
|
|
}
|
|
|
|
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
|
|
}
|
|
// Skip absent processor sockets — empty slots with no CPU installed.
|
|
if status, ok := doc["Status"].(map[string]interface{}); ok {
|
|
if strings.EqualFold(asString(status["State"]), "Absent") {
|
|
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]
|
|
// Skip empty DIMM slots — no installed memory.
|
|
if !dimm.Present {
|
|
continue
|
|
}
|
|
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) 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 stringsTrimTrailingSlash(s string) string {
|
|
for len(s) > 1 && s[len(s)-1] == '/' {
|
|
s = s[:len(s)-1]
|
|
}
|
|
return s
|
|
}
|