redfish: MSI support, fix zero dates, BMC MAC, Assembly FRU, crawler cleanup

- Add MSI CG480-S5063 (H100 SXM5) support:
  - collectGPUsFromProcessors: find GPUs via Processors/ProcessorType=GPU,
    resolve serials from Chassis/<GpuId>
  - looksLikeGPU: skip Description="Display Device" PCIe sidecars
  - isVirtualStorageDrive: filter AMI virtual USB drives (0-byte)
  - enrichNICMACsFromNetworkDeviceFunctions: pull MACs for MSI NICs
  - parseCPUs: filter by ProcessorType, parse Socket, L1/L2/L3 from ProcessorMemory
  - parseMemory: Location.PartLocation.ServiceLabel slot fallback
  - shouldCrawlPath: block /SubProcessors subtrees
- Fix status_checked_at/status_changed_at serializing as 0001-01-01:
  change all StatusCheckedAt/StatusChangedAt fields to *time.Time
- Redfish crawler cleanup:
  - Block non-inventory branches: AccountService, CertificateService,
    EventService, Registries, SessionService, TaskService, manager config paths,
    OperatingConfigs, BootOptions, HostPostCode, Bios/Settings, OEM KVM paths
  - Add Assembly to critical endpoints (FRU data)
  - Remove BootOptions from priority seeds
- collectBMCMAC: read BMC MAC from Managers/*/EthernetInterfaces
- collectAssemblyFRU: extract FRU serial/part from Chassis/*/Assembly
- Firmware: remove NetworkProtocol noise, fix SecureBoot field,
  filter BMCImageN redundant backup slots

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 08:12:17 +03:00
parent 21ea129933
commit 8d80048117
8 changed files with 430 additions and 54 deletions

View File

@@ -1427,6 +1427,7 @@ func redfishCriticalEndpoints(systemPaths, chassisPaths, managerPaths []string)
add(joinPath(p, "/PCIeDevices")) add(joinPath(p, "/PCIeDevices"))
add(joinPath(p, "/Accelerators")) add(joinPath(p, "/Accelerators"))
add(joinPath(p, "/Drives")) add(joinPath(p, "/Drives"))
add(joinPath(p, "/Assembly"))
} }
for _, p := range managerPaths { for _, p := range managerPaths {
add(p) add(p)
@@ -1916,6 +1917,58 @@ func shouldCrawlPath(path string) bool {
return false return false
} }
} }
// Non-inventory top-level service branches.
for _, prefix := range []string{
"/redfish/v1/AccountService",
"/redfish/v1/CertificateService",
"/redfish/v1/EventService",
"/redfish/v1/Registries",
"/redfish/v1/SessionService",
"/redfish/v1/TaskService",
} {
if strings.HasPrefix(normalized, prefix) {
return false
}
}
// Manager-specific configuration paths (not hardware inventory).
if strings.Contains(normalized, "/Managers/") {
for _, part := range []string{
"/FirewallRules",
"/KvmService",
"/LldpService",
"/SecurityService",
"/SmtpService",
"/SnmpService",
"/SyslogService",
"/VirtualMedia",
"/VncService",
"/Certificates",
} {
if strings.Contains(normalized, part) {
return false
}
}
}
// Per-CPU operating frequency configurations — not hardware inventory.
if strings.HasSuffix(normalized, "/OperatingConfigs") {
return false
}
// Per-core/thread sub-processors — inventory is captured at the top processor level.
if strings.Contains(normalized, "/SubProcessors") {
return false
}
// Non-inventory system endpoints.
for _, part := range []string{
"/BootOptions",
"/HostPostCode",
"/Bios/Settings",
"/GetServerAllUSBStatus",
"/Oem/Public/KVM",
} {
if strings.Contains(normalized, part) {
return false
}
}
heavyParts := []string{ heavyParts := []string{
"/JsonSchemas", "/JsonSchemas",
"/LogServices/", "/LogServices/",
@@ -2435,24 +2488,76 @@ func findFirstNormalizedStringByKeys(doc map[string]interface{}, keys ...string)
func parseCPUs(docs []map[string]interface{}) []models.CPU { func parseCPUs(docs []map[string]interface{}) []models.CPU {
cpus := make([]models.CPU, 0, len(docs)) cpus := make([]models.CPU, 0, len(docs))
for idx, doc := range docs { socketIdx := 0
for _, doc := range docs {
// Skip non-CPU processors (GPUs, FPGAs, etc.) that some BMCs list in the
// same Processors collection.
if pt := strings.TrimSpace(asString(doc["ProcessorType"])); pt != "" &&
!strings.EqualFold(pt, "CPU") && !strings.EqualFold(pt, "General") {
continue
}
socket := socketIdx
socketIdx++
if s := strings.TrimSpace(asString(doc["Socket"])); s != "" {
// Parse numeric suffix from labels like "CPU0", "Processor 1", etc.
trimmed := strings.TrimLeft(strings.ToUpper(s), "ABCDEFGHIJKLMNOPQRSTUVWXYZ _")
if n, err := strconv.Atoi(trimmed); err == nil {
socket = n
}
}
l1, l2, l3 := parseCPUCachesFromProcessorMemory(doc)
cpus = append(cpus, models.CPU{ cpus = append(cpus, models.CPU{
Socket: idx, Socket: socket,
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])), Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
Cores: asInt(doc["TotalCores"]), Cores: asInt(doc["TotalCores"]),
Threads: asInt(doc["TotalThreads"]), Threads: asInt(doc["TotalThreads"]),
FrequencyMHz: asInt(doc["OperatingSpeedMHz"]), FrequencyMHz: asInt(doc["OperatingSpeedMHz"]),
MaxFreqMHz: asInt(doc["MaxSpeedMHz"]), MaxFreqMHz: asInt(doc["MaxSpeedMHz"]),
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"), SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
L1CacheKB: l1,
L2CacheKB: l2,
L3CacheKB: l3,
Status: mapStatus(doc["Status"]),
}) })
} }
return cpus return cpus
} }
// parseCPUCachesFromProcessorMemory reads L1/L2/L3 cache sizes from the
// Redfish ProcessorMemory array (Processor.v1_x spec).
func parseCPUCachesFromProcessorMemory(doc map[string]interface{}) (l1, l2, l3 int) {
mem, _ := doc["ProcessorMemory"].([]interface{})
for _, mAny := range mem {
m, ok := mAny.(map[string]interface{})
if !ok {
continue
}
capMiB := asInt(m["CapacityMiB"])
if capMiB == 0 {
continue
}
capKB := capMiB * 1024
switch strings.ToUpper(strings.TrimSpace(asString(m["MemoryType"]))) {
case "L1CACHE":
l1 = capKB
case "L2CACHE":
l2 = capKB
case "L3CACHE":
l3 = capKB
}
}
return
}
func parseMemory(docs []map[string]interface{}) []models.MemoryDIMM { func parseMemory(docs []map[string]interface{}) []models.MemoryDIMM {
out := make([]models.MemoryDIMM, 0, len(docs)) out := make([]models.MemoryDIMM, 0, len(docs))
for _, doc := range docs { for _, doc := range docs {
slot := firstNonEmpty(asString(doc["DeviceLocator"]), asString(doc["Name"]), asString(doc["Id"])) slot := firstNonEmpty(
asString(doc["DeviceLocator"]),
redfishLocationLabel(doc["Location"]),
asString(doc["Name"]),
asString(doc["Id"]),
)
present := true present := true
if strings.EqualFold(asString(doc["Status"]), "Absent") { if strings.EqualFold(asString(doc["Status"]), "Absent") {
present = false present = false
@@ -3154,6 +3259,12 @@ func hasMergedPlaceholderIndex(indexes map[int]struct{}, idx int) bool {
} }
func looksLikeGPU(doc map[string]interface{}, functionDocs []map[string]interface{}) bool { func looksLikeGPU(doc map[string]interface{}, functionDocs []map[string]interface{}) bool {
// "Display Device" is how MSI labels H100 secondary display/audio controller
// functions — these are not compute GPUs and should be excluded.
if strings.EqualFold(strings.TrimSpace(asString(doc["Description"])), "Display Device") {
return false
}
deviceType := strings.ToLower(asString(doc["DeviceType"])) deviceType := strings.ToLower(asString(doc["DeviceType"]))
if strings.Contains(deviceType, "gpu") || strings.Contains(deviceType, "graphics") || strings.Contains(deviceType, "accelerator") { if strings.Contains(deviceType, "gpu") || strings.Contains(deviceType, "graphics") || strings.Contains(deviceType, "accelerator") {
return true return true
@@ -3192,6 +3303,20 @@ func looksLikeGPU(doc map[string]interface{}, functionDocs []map[string]interfac
return false return false
} }
// isVirtualStorageDrive returns true for BMC-virtual drives that should not
// appear in hardware inventory (e.g. AMI virtual CD/USB sticks with 0 capacity).
func isVirtualStorageDrive(doc map[string]interface{}) bool {
if strings.EqualFold(asString(doc["Protocol"]), "USB") && asInt64(doc["CapacityBytes"]) == 0 {
return true
}
mfr := strings.ToUpper(strings.TrimSpace(asString(doc["Manufacturer"])))
model := strings.ToUpper(strings.TrimSpace(asString(doc["Model"])))
if strings.Contains(mfr, "AMI") && strings.Contains(model, "VIRTUAL") {
return true
}
return false
}
func looksLikeDrive(doc map[string]interface{}) bool { func looksLikeDrive(doc map[string]interface{}) bool {
if asString(doc["MediaType"]) != "" { if asString(doc["MediaType"]) != "" {
return true return true
@@ -3951,8 +4076,7 @@ func parseFirmware(system, bios, manager, secureBoot, networkProtocol map[string
appendFW("BIOS", asString(system["BiosVersion"])) appendFW("BIOS", asString(system["BiosVersion"]))
appendFW("BIOS", asString(bios["Version"])) appendFW("BIOS", asString(bios["Version"]))
appendFW("BMC", asString(manager["FirmwareVersion"])) appendFW("BMC", asString(manager["FirmwareVersion"]))
appendFW("SecureBoot", asString(secureBoot["SecureBootCurrentBoot"])) appendFW("SecureBoot", asString(secureBoot["SecureBootMode"]))
appendFW("NetworkProtocol", asString(networkProtocol["Id"]))
return out return out
} }
@@ -4441,7 +4565,6 @@ func redfishSnapshotPrioritySeeds(systemPaths, chassisPaths, managerPaths []stri
add(joinPath(p, "/Memory")) add(joinPath(p, "/Memory"))
add(joinPath(p, "/EthernetInterfaces")) add(joinPath(p, "/EthernetInterfaces"))
add(joinPath(p, "/NetworkInterfaces")) add(joinPath(p, "/NetworkInterfaces"))
add(joinPath(p, "/BootOptions"))
add(joinPath(p, "/PCIeDevices")) add(joinPath(p, "/PCIeDevices"))
add(joinPath(p, "/PCIeFunctions")) add(joinPath(p, "/PCIeFunctions"))
add(joinPath(p, "/Accelerators")) add(joinPath(p, "/Accelerators"))

View File

@@ -72,6 +72,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
psus := r.collectPSUs(chassisPaths) psus := r.collectPSUs(chassisPaths)
pcieDevices := r.collectPCIeDevices(systemPaths, chassisPaths) pcieDevices := r.collectPCIeDevices(systemPaths, chassisPaths)
gpus := r.collectGPUs(systemPaths, chassisPaths) gpus := r.collectGPUs(systemPaths, chassisPaths)
gpus = r.collectGPUsFromProcessors(systemPaths, chassisPaths, gpus)
nics := r.collectNICs(chassisPaths) nics := r.collectNICs(chassisPaths)
r.enrichNICsFromNetworkInterfaces(&nics, systemPaths) r.enrichNICsFromNetworkInterfaces(&nics, systemPaths)
thresholdSensors := r.collectThresholdSensors(chassisPaths) thresholdSensors := r.collectThresholdSensors(chassisPaths)
@@ -86,13 +87,15 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
firmware = dedupeFirmwareInfo(append(firmware, r.collectFirmwareInventory()...)) firmware = dedupeFirmwareInfo(append(firmware, r.collectFirmwareInventory()...))
boardInfo := parseBoardInfoWithFallback(systemDoc, chassisDoc, fruDoc) boardInfo := parseBoardInfoWithFallback(systemDoc, chassisDoc, fruDoc)
applyBoardInfoFallbackFromDocs(&boardInfo, boardFallbackDocs) applyBoardInfoFallbackFromDocs(&boardInfo, boardFallbackDocs)
boardInfo.BMCMACAddress = r.collectBMCMAC(managerPaths)
assemblyFRU := r.collectAssemblyFRU(chassisPaths)
collectedAt, sourceTimezone := inferRedfishCollectionTime(managerDoc, rawPayloads) collectedAt, sourceTimezone := inferRedfishCollectionTime(managerDoc, rawPayloads)
result := &models.AnalysisResult{ result := &models.AnalysisResult{
CollectedAt: collectedAt, CollectedAt: collectedAt,
SourceTimezone: sourceTimezone, SourceTimezone: sourceTimezone,
Events: append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...), Events: append(append(append(make([]models.Event, 0, len(discreteEvents)+len(healthEvents)+len(driveFetchWarningEvents)+1), healthEvents...), discreteEvents...), driveFetchWarningEvents...),
FRU: make([]models.FRUInfo, 0), FRU: assemblyFRU,
Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)), Sensors: dedupeSensorReadings(append(append(thresholdSensors, thermalSensors...), powerSensors...)),
RawPayloads: cloneRawPayloads(rawPayloads), RawPayloads: cloneRawPayloads(rawPayloads),
Hardware: &models.HardwareConfig{ Hardware: &models.HardwareConfig{
@@ -274,7 +277,12 @@ func (r redfishSnapshotReader) collectFirmwareInventory() []models.FirmwareInfo
asString(doc["Name"]), asString(doc["Name"]),
asString(doc["Id"]), asString(doc["Id"]),
) )
if strings.TrimSpace(name) == "" { name = strings.TrimSpace(name)
if name == "" {
continue
}
// BMCImageN entries are redundant backup slot labels; skip them.
if strings.HasPrefix(strings.ToLower(name), "bmcimage") {
continue continue
} }
out = append(out, models.FirmwareInfo{DeviceName: name, Version: version}) out = append(out, models.FirmwareInfo{DeviceName: name, Version: version})
@@ -977,8 +985,10 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
driveDocs, err := r.getCollectionMembers(driveCollectionPath) driveDocs, err := r.getCollectionMembers(driveCollectionPath)
if err == nil { if err == nil {
for _, driveDoc := range driveDocs { for _, driveDoc := range driveDocs {
if !isVirtualStorageDrive(driveDoc) {
out = append(out, parseDrive(driveDoc)) out = append(out, parseDrive(driveDoc))
} }
}
if len(driveDocs) == 0 { if len(driveDocs) == 0 {
for _, driveDoc := range r.probeDirectDiskBayChildren(driveCollectionPath) { for _, driveDoc := range r.probeDirectDiskBayChildren(driveCollectionPath) {
out = append(out, parseDrive(driveDoc)) out = append(out, parseDrive(driveDoc))
@@ -1002,8 +1012,10 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
if err != nil { if err != nil {
continue continue
} }
if !isVirtualStorageDrive(driveDoc) {
out = append(out, parseDrive(driveDoc)) out = append(out, parseDrive(driveDoc))
} }
}
continue continue
} }
if looksLikeDrive(member) { if looksLikeDrive(member) {
@@ -1154,6 +1166,10 @@ func (r redfishSnapshotReader) collectNICs(chassisPaths []string) []models.Netwo
functionDocs := r.getLinkedPCIeFunctions(pcieDoc) functionDocs := r.getLinkedPCIeFunctions(pcieDoc)
enrichNICFromPCIe(&nic, pcieDoc, functionDocs) enrichNICFromPCIe(&nic, pcieDoc, functionDocs)
} }
// Collect MACs from NetworkDeviceFunctions when not found via PCIe path.
if len(nic.MACAddresses) == 0 {
r.enrichNICMACsFromNetworkDeviceFunctions(&nic, doc)
}
nics = append(nics, nic) nics = append(nics, nic)
} }
} }
@@ -1268,3 +1284,237 @@ func stringsTrimTrailingSlash(s string) string {
} }
return s 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 from Chassis/<Id>.
gpuID := strings.TrimSpace(asString(doc["Id"]))
serial := ""
if chassisDoc, ok := chassisByID[strings.ToUpper(gpuID)]; ok {
serial = strings.TrimSpace(asString(chassisDoc["SerialNumber"]))
}
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
}

View File

@@ -385,10 +385,10 @@ func mergeCanonicalDevice(primary, secondary models.HardwareDevice) models.Hardw
fillFloat(&primary.InputVoltage, secondary.InputVoltage) fillFloat(&primary.InputVoltage, secondary.InputVoltage)
fillInt(&primary.TemperatureC, secondary.TemperatureC) fillInt(&primary.TemperatureC, secondary.TemperatureC)
fillString(&primary.Status, secondary.Status) fillString(&primary.Status, secondary.Status)
if primary.StatusCheckedAt.IsZero() && !secondary.StatusCheckedAt.IsZero() { if primary.StatusCheckedAt == nil && secondary.StatusCheckedAt != nil {
primary.StatusCheckedAt = secondary.StatusCheckedAt primary.StatusCheckedAt = secondary.StatusCheckedAt
} }
if primary.StatusChangedAt.IsZero() && !secondary.StatusChangedAt.IsZero() { if primary.StatusChangedAt == nil && secondary.StatusChangedAt != nil {
primary.StatusChangedAt = secondary.StatusChangedAt primary.StatusChangedAt = secondary.StatusChangedAt
} }
if primary.StatusAtCollect == nil && secondary.StatusAtCollect != nil { if primary.StatusAtCollect == nil && secondary.StatusAtCollect != nil {
@@ -1130,8 +1130,8 @@ type convertedStatusMeta struct {
func buildStatusMeta( func buildStatusMeta(
currentStatus string, currentStatus string,
checkedAt time.Time, checkedAt *time.Time,
changedAt time.Time, changedAt *time.Time,
statusAtCollection *models.StatusAtCollection, statusAtCollection *models.StatusAtCollection,
history []models.StatusHistoryEntry, history []models.StatusHistoryEntry,
errorDescription string, errorDescription string,
@@ -1145,7 +1145,7 @@ func buildStatusMeta(
convertedHistory := make([]ReanimatorStatusHistoryEntry, 0, len(history)) convertedHistory := make([]ReanimatorStatusHistoryEntry, 0, len(history))
for _, h := range history { for _, h := range history {
changed := formatOptionalRFC3339(h.ChangedAt) changed := formatOptionalRFC3339(&h.ChangedAt)
if changed == "" { if changed == "" {
continue continue
} }
@@ -1166,7 +1166,7 @@ func buildStatusMeta(
} }
if statusAtCollection != nil { if statusAtCollection != nil {
at := formatOptionalRFC3339(statusAtCollection.At) at := formatOptionalRFC3339(&statusAtCollection.At)
if at != "" && strings.TrimSpace(statusAtCollection.Status) != "" { if at != "" && strings.TrimSpace(statusAtCollection.Status) != "" {
meta.StatusAtCollection = &ReanimatorStatusAtCollection{ meta.StatusAtCollection = &ReanimatorStatusAtCollection{
Status: normalizeStatus(statusAtCollection.Status, true), Status: normalizeStatus(statusAtCollection.Status, true),
@@ -1191,8 +1191,8 @@ func buildStatusMeta(
return meta return meta
} }
func formatOptionalRFC3339(t time.Time) string { func formatOptionalRFC3339(t *time.Time) string {
if t.IsZero() { if t == nil || t.IsZero() {
return "" return ""
} }
return t.UTC().Format(time.RFC3339) return t.UTC().Format(time.RFC3339)

View File

@@ -148,8 +148,8 @@ type HardwareDevice struct {
TemperatureC int `json:"temperature_c,omitempty"` TemperatureC int `json:"temperature_c,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
StatusCheckedAt time.Time `json:"status_checked_at,omitempty"` StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt time.Time `json:"status_changed_at,omitempty"` StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"` StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"` StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"` ErrorDescription string `json:"error_description,omitempty"`
@@ -174,6 +174,7 @@ type BoardInfo struct {
PartNumber string `json:"part_number,omitempty"` PartNumber string `json:"part_number,omitempty"`
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
UUID string `json:"uuid,omitempty"` UUID string `json:"uuid,omitempty"`
BMCMACAddress string `json:"bmc_mac_address,omitempty"`
} }
// CPU represents processor information // CPU represents processor information
@@ -193,8 +194,8 @@ type CPU struct {
SerialNumber string `json:"serial_number,omitempty"` SerialNumber string `json:"serial_number,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
StatusCheckedAt time.Time `json:"status_checked_at,omitempty"` StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt time.Time `json:"status_changed_at,omitempty"` StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"` StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"` StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"` ErrorDescription string `json:"error_description,omitempty"`
@@ -217,8 +218,8 @@ type MemoryDIMM struct {
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
Ranks int `json:"ranks,omitempty"` Ranks int `json:"ranks,omitempty"`
StatusCheckedAt time.Time `json:"status_checked_at,omitempty"` StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt time.Time `json:"status_changed_at,omitempty"` StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"` StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"` StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"` ErrorDescription string `json:"error_description,omitempty"`
@@ -240,8 +241,8 @@ type Storage struct {
BackplaneID int `json:"backplane_id,omitempty"` BackplaneID int `json:"backplane_id,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
StatusCheckedAt time.Time `json:"status_checked_at,omitempty"` StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt time.Time `json:"status_changed_at,omitempty"` StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"` StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"` StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"` ErrorDescription string `json:"error_description,omitempty"`
@@ -278,8 +279,8 @@ type PCIeDevice struct {
MACAddresses []string `json:"mac_addresses,omitempty"` MACAddresses []string `json:"mac_addresses,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
StatusCheckedAt time.Time `json:"status_checked_at,omitempty"` StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt time.Time `json:"status_changed_at,omitempty"` StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"` StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"` StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"` ErrorDescription string `json:"error_description,omitempty"`
@@ -314,8 +315,8 @@ type PSU struct {
OutputVoltage float64 `json:"output_voltage,omitempty"` OutputVoltage float64 `json:"output_voltage,omitempty"`
TemperatureC int `json:"temperature_c,omitempty"` TemperatureC int `json:"temperature_c,omitempty"`
StatusCheckedAt time.Time `json:"status_checked_at,omitempty"` StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt time.Time `json:"status_changed_at,omitempty"` StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"` StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"` StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"` ErrorDescription string `json:"error_description,omitempty"`
@@ -352,8 +353,8 @@ type GPU struct {
CurrentLinkSpeed string `json:"current_link_speed,omitempty"` CurrentLinkSpeed string `json:"current_link_speed,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
StatusCheckedAt time.Time `json:"status_checked_at,omitempty"` StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt time.Time `json:"status_changed_at,omitempty"` StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"` StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"` StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"` ErrorDescription string `json:"error_description,omitempty"`
@@ -377,8 +378,8 @@ type NetworkAdapter struct {
MACAddresses []string `json:"mac_addresses,omitempty"` MACAddresses []string `json:"mac_addresses,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
StatusCheckedAt time.Time `json:"status_checked_at,omitempty"` StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt time.Time `json:"status_changed_at,omitempty"` StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"` StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"` StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"` ErrorDescription string `json:"error_description,omitempty"`

View File

@@ -70,11 +70,13 @@ func applyGPUStatusFromEvents(hw *models.HardwareConfig, events []models.Event)
ChangedAt: e.Timestamp, ChangedAt: e.Timestamp,
Details: strings.TrimSpace(e.Description), Details: strings.TrimSpace(e.Description),
}) })
gpu.StatusChangedAt = e.Timestamp ts := e.Timestamp
gpu.StatusChangedAt = &ts
currentStatus[idx] = newStatus currentStatus[idx] = newStatus
} }
gpu.StatusCheckedAt = e.Timestamp ts := e.Timestamp
gpu.StatusCheckedAt = &ts
} }
} }
@@ -85,7 +87,7 @@ func applyGPUStatusFromEvents(hw *models.HardwareConfig, events []models.Event)
} else { } else {
gpu.ErrorDescription = "" gpu.ErrorDescription = ""
} }
if gpu.StatusCheckedAt.IsZero() && strings.TrimSpace(gpu.Status) == "" { if gpu.StatusCheckedAt == nil && strings.TrimSpace(gpu.Status) == "" {
gpu.Status = "OK" gpu.Status = "OK"
} }
} }

View File

@@ -230,7 +230,7 @@ func ApplyGPUAndNVSwitchCheckTimes(result *models.AnalysisResult, times componen
if ts.IsZero() { if ts.IsZero() {
continue continue
} }
gpu.StatusCheckedAt = ts gpu.StatusCheckedAt = &ts
status := strings.TrimSpace(gpu.Status) status := strings.TrimSpace(gpu.Status)
if status == "" { if status == "" {
status = "Unknown" status = "Unknown"
@@ -261,7 +261,7 @@ func ApplyGPUAndNVSwitchCheckTimes(result *models.AnalysisResult, times componen
continue continue
} }
dev.StatusCheckedAt = ts dev.StatusCheckedAt = &ts
status := strings.TrimSpace(dev.Status) status := strings.TrimSpace(dev.Status)
if status == "" { if status == "" {
status = "Unknown" status = "Unknown"

View File

@@ -72,20 +72,20 @@ func TestApplyGPUAndNVSwitchCheckTimes(t *testing.T) {
NVSwitchBySlot: map[string]time.Time{"NVSWITCH0": nvsTs}, NVSwitchBySlot: map[string]time.Time{"NVSWITCH0": nvsTs},
}) })
if got := result.Hardware.GPUs[0].StatusCheckedAt; !got.Equal(gpuTs) { if got := result.Hardware.GPUs[0].StatusCheckedAt; got == nil || !got.Equal(gpuTs) {
t.Fatalf("expected gpu status_checked_at %s, got %s", gpuTs.Format(time.RFC3339), got.Format(time.RFC3339)) t.Fatalf("expected gpu status_checked_at %s, got %v", gpuTs.Format(time.RFC3339), got)
} }
if result.Hardware.GPUs[0].StatusAtCollect == nil || !result.Hardware.GPUs[0].StatusAtCollect.At.Equal(gpuTs) { if result.Hardware.GPUs[0].StatusAtCollect == nil || !result.Hardware.GPUs[0].StatusAtCollect.At.Equal(gpuTs) {
t.Fatalf("expected gpu status_at_collection.at %s", gpuTs.Format(time.RFC3339)) t.Fatalf("expected gpu status_at_collection.at %s", gpuTs.Format(time.RFC3339))
} }
if got := result.Hardware.PCIeDevices[0].StatusCheckedAt; !got.Equal(nvsTs) { if got := result.Hardware.PCIeDevices[0].StatusCheckedAt; got == nil || !got.Equal(nvsTs) {
t.Fatalf("expected nvswitch status_checked_at %s, got %s", nvsTs.Format(time.RFC3339), got.Format(time.RFC3339)) t.Fatalf("expected nvswitch status_checked_at %s, got %v", nvsTs.Format(time.RFC3339), got)
} }
if result.Hardware.PCIeDevices[0].StatusAtCollect == nil || !result.Hardware.PCIeDevices[0].StatusAtCollect.At.Equal(nvsTs) { if result.Hardware.PCIeDevices[0].StatusAtCollect == nil || !result.Hardware.PCIeDevices[0].StatusAtCollect.At.Equal(nvsTs) {
t.Fatalf("expected nvswitch status_at_collection.at %s", nvsTs.Format(time.RFC3339)) t.Fatalf("expected nvswitch status_at_collection.at %s", nvsTs.Format(time.RFC3339))
} }
if !result.Hardware.PCIeDevices[1].StatusCheckedAt.IsZero() { if result.Hardware.PCIeDevices[1].StatusCheckedAt != nil {
t.Fatalf("expected non-nvswitch device status_checked_at to stay zero") t.Fatalf("expected non-nvswitch device status_checked_at to stay nil")
} }
} }

View File

@@ -568,10 +568,10 @@ func mergeDevices(primary, secondary models.HardwareDevice) models.HardwareDevic
fillFloat(&primary.InputVoltage, secondary.InputVoltage) fillFloat(&primary.InputVoltage, secondary.InputVoltage)
fillInt(&primary.TemperatureC, secondary.TemperatureC) fillInt(&primary.TemperatureC, secondary.TemperatureC)
fillString(&primary.Status, secondary.Status) fillString(&primary.Status, secondary.Status)
if primary.StatusCheckedAt.IsZero() && !secondary.StatusCheckedAt.IsZero() { if primary.StatusCheckedAt == nil && secondary.StatusCheckedAt != nil {
primary.StatusCheckedAt = secondary.StatusCheckedAt primary.StatusCheckedAt = secondary.StatusCheckedAt
} }
if primary.StatusChangedAt.IsZero() && !secondary.StatusChangedAt.IsZero() { if primary.StatusChangedAt == nil && secondary.StatusChangedAt != nil {
primary.StatusChangedAt = secondary.StatusChangedAt primary.StatusChangedAt = secondary.StatusChangedAt
} }
if primary.StatusAtCollect == nil && secondary.StatusAtCollect != nil { if primary.StatusAtCollect == nil && secondary.StatusAtCollect != nil {