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, "/Accelerators"))
add(joinPath(p, "/Drives"))
add(joinPath(p, "/Assembly"))
}
for _, p := range managerPaths {
add(p)
@@ -1916,6 +1917,58 @@ func shouldCrawlPath(path string) bool {
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{
"/JsonSchemas",
"/LogServices/",
@@ -2435,24 +2488,76 @@ func findFirstNormalizedStringByKeys(doc map[string]interface{}, keys ...string)
func parseCPUs(docs []map[string]interface{}) []models.CPU {
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{
Socket: idx,
Socket: socket,
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
Cores: asInt(doc["TotalCores"]),
Threads: asInt(doc["TotalThreads"]),
FrequencyMHz: asInt(doc["OperatingSpeedMHz"]),
MaxFreqMHz: asInt(doc["MaxSpeedMHz"]),
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
L1CacheKB: l1,
L2CacheKB: l2,
L3CacheKB: l3,
Status: mapStatus(doc["Status"]),
})
}
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 {
out := make([]models.MemoryDIMM, 0, len(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
if strings.EqualFold(asString(doc["Status"]), "Absent") {
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 {
// "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"]))
if strings.Contains(deviceType, "gpu") || strings.Contains(deviceType, "graphics") || strings.Contains(deviceType, "accelerator") {
return true
@@ -3192,6 +3303,20 @@ func looksLikeGPU(doc map[string]interface{}, functionDocs []map[string]interfac
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 {
if asString(doc["MediaType"]) != "" {
return true
@@ -3951,8 +4076,7 @@ func parseFirmware(system, bios, manager, secureBoot, networkProtocol map[string
appendFW("BIOS", asString(system["BiosVersion"]))
appendFW("BIOS", asString(bios["Version"]))
appendFW("BMC", asString(manager["FirmwareVersion"]))
appendFW("SecureBoot", asString(secureBoot["SecureBootCurrentBoot"]))
appendFW("NetworkProtocol", asString(networkProtocol["Id"]))
appendFW("SecureBoot", asString(secureBoot["SecureBootMode"]))
return out
}
@@ -4441,7 +4565,6 @@ func redfishSnapshotPrioritySeeds(systemPaths, chassisPaths, managerPaths []stri
add(joinPath(p, "/Memory"))
add(joinPath(p, "/EthernetInterfaces"))
add(joinPath(p, "/NetworkInterfaces"))
add(joinPath(p, "/BootOptions"))
add(joinPath(p, "/PCIeDevices"))
add(joinPath(p, "/PCIeFunctions"))
add(joinPath(p, "/Accelerators"))