From c47c34fd1155722ab1d85da889e1fff6a058c482 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Mon, 30 Mar 2026 15:04:17 +0300 Subject: [PATCH] feat(hpe): improve inventory extraction and export fidelity --- bible-local/10-decisions.md | 47 +- internal/collector/redfish.go | 166 ++++++- .../collector/redfish_replay_inventory.go | 33 +- internal/collector/redfish_test.go | 117 +++++ internal/exporter/reanimator_converter.go | 28 +- .../exporter/reanimator_converter_test.go | 23 + internal/models/memory.go | 29 ++ internal/parser/vendors/hpe_ilo_ahs/parser.go | 455 +++++++++++++++++- .../parser/vendors/hpe_ilo_ahs/parser_test.go | 63 ++- internal/server/device_repository.go | 2 +- internal/server/device_repository_test.go | 57 +++ internal/server/handlers.go | 28 +- 12 files changed, 989 insertions(+), 59 deletions(-) create mode 100644 internal/models/memory.go diff --git a/bible-local/10-decisions.md b/bible-local/10-decisions.md index eeab8ac..68d6f26 100644 --- a/bible-local/10-decisions.md +++ b/bible-local/10-decisions.md @@ -994,9 +994,54 @@ significant complexity before proving user value. - decode the outer `ABJR` container - gunzip embedded members when applicable - extract inventory from printable SMBIOS/FRU payloads -- extract storage/controller details from embedded Redfish JSON objects +- extract storage/controller/backplane details from embedded Redfish JSON objects +- enrich firmware and PSU inventory from auxiliary package payloads such as `bcert.pkg` - do not attempt complete semantic decoding of the internal `zbb` record format **Consequences:** - Parser reaches inventory-grade usefulness quickly for HPE `.ahs` uploads. - Storage inventory is stronger than text-only parsing because it reuses structured Redfish data when present. +- Auxiliary package payloads can supply missing firmware/PSU fields even when the main SMBIOS-like blob is incomplete. - Future deeper `zbb` decoding can be added incrementally without replacing the current parser contract. + +--- + +## ADL-039 — Canonical inventory keeps DIMMs with unknown capacity when identity is known + +**Date:** 2026-03-30 +**Context:** Some sources, notably HPE iLO AHS SMBIOS-like blobs, expose installed DIMM identity +(slot, serial, part number, manufacturer) but do not include capacity. The parser already extracts +those modules into `Hardware.Memory`, but canonical device building and export previously dropped +them because `size_mb == 0`. +**Decision:** Treat a DIMM as installed inventory when `present=true` and it has identifying +memory fields such as serial number or part number, even if `size_mb` is unknown. +**Consequences:** +- HPE AHS uploads now show real installed memory modules instead of hiding them. +- Empty slots still stay filtered because they lack inventory identity or are marked absent. +- Specification/export can include "size unknown" memory entries without inventing capacity data. + +--- + +## ADL-040 — HPE Redfish normalization prefers chassis `Devices/*` over generic PCIe topology labels + +**Date:** 2026-03-30 +**Context:** HPE ProLiant Gen11 Redfish snapshots expose parallel inventory trees. `Chassis/*/PCIeDevices/*` +is good for topology presence, but often reports only generic `DeviceType` values such as +`SingleFunction`. `Chassis/*/Devices/*` carries the concrete slot label, richer device type, and +product-vs-spare part identifiers for the same physical NIC/controller. Replay fallback over empty +storage volume collections can also discover `Volumes/Capabilities` children, which are not real +logical volumes. + +**Decision:** +- Treat Redfish `SKU` as a valid fallback for `hardware.board.part_number` when `PartNumber` is empty. +- Ignore `Volumes/Capabilities` documents during logical-volume parsing. +- Enrich `Chassis/*/PCIeDevices/*` entries with matching `Chassis/*/Devices/*` documents by + serial/name/part identity. +- Keep `pcie.device_class` semantic; do not replace it with model or part-number strings when + Redfish exposes only generic topology labels. + +**Consequences:** +- HPE Redfish imports now keep the server SKU in `hardware.board.part_number`. +- Empty volume collections no longer produce fake `Capabilities` volume records. +- HPE PCIe inventory gets better slot labels like `OCP 3.0 Slot 15` plus concrete classes such as + `LOM/NIC` or `SAS/SATA Storage Controller`. +- `part_number` remains available separately for model identity, without polluting the class field. diff --git a/internal/collector/redfish.go b/internal/collector/redfish.go index a5d4b72..bda3f87 100644 --- a/internal/collector/redfish.go +++ b/internal/collector/redfish.go @@ -1513,16 +1513,13 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http. } func (c *RedfishConnector) getChassisScopedPCIeSupplementalDocs(ctx context.Context, client *http.Client, req Request, baseURL string, doc map[string]interface{}) []map[string]interface{} { - if !looksLikeNVSwitchPCIeDoc(doc) { - return nil - } docPath := normalizeRedfishPath(asString(doc["@odata.id"])) chassisPath := chassisPathForPCIeDoc(docPath) if chassisPath == "" { return nil } - out := make([]map[string]interface{}, 0, 4) + out := make([]map[string]interface{}, 0, 6) seen := make(map[string]struct{}) add := func(path string) { path = normalizeRedfishPath(path) @@ -1540,8 +1537,19 @@ func (c *RedfishConnector) getChassisScopedPCIeSupplementalDocs(ctx context.Cont out = append(out, supplementalDoc) } - add(joinPath(chassisPath, "/EnvironmentMetrics")) - add(joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics")) + if looksLikeNVSwitchPCIeDoc(doc) { + add(joinPath(chassisPath, "/EnvironmentMetrics")) + add(joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics")) + } + deviceDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(chassisPath, "/Devices")) + if err == nil { + for _, deviceDoc := range deviceDocs { + if !redfishPCIeMatchesChassisDeviceDoc(doc, deviceDoc) { + continue + } + add(asString(deviceDoc["@odata.id"])) + } + } return out } @@ -3434,8 +3442,11 @@ func parseBoardInfo(system map[string]interface{}) models.BoardInfo { asString(system["Name"]), )), SerialNumber: normalizeRedfishIdentityField(asString(system["SerialNumber"])), - PartNumber: normalizeRedfishIdentityField(asString(system["PartNumber"])), - UUID: normalizeRedfishIdentityField(asString(system["UUID"])), + PartNumber: normalizeRedfishIdentityField(firstNonEmpty( + asString(system["PartNumber"]), + asString(system["SKU"]), + )), + UUID: normalizeRedfishIdentityField(asString(system["UUID"])), } } @@ -3818,6 +3829,22 @@ func parseStorageVolume(doc map[string]interface{}, controller string) models.St } } +func redfishVolumeCapabilitiesDoc(doc map[string]interface{}) bool { + if len(doc) == 0 { + return false + } + if strings.Contains(strings.ToLower(strings.TrimSpace(asString(doc["@odata.type"]))), "collectioncapabilities") { + return true + } + path := strings.ToLower(normalizeRedfishPath(asString(doc["@odata.id"]))) + if strings.HasSuffix(path, "/volumes/capabilities") { + return true + } + id := strings.TrimSpace(asString(doc["Id"])) + name := strings.ToLower(strings.TrimSpace(asString(doc["Name"]))) + return strings.EqualFold(id, "Capabilities") || strings.Contains(name, "capabilities for volumecollection") +} + func parseNIC(doc map[string]interface{}) models.NetworkAdapter { vendorID := asHexOrInt(doc["VendorId"]) deviceID := asHexOrInt(doc["DeviceId"]) @@ -4356,6 +4383,39 @@ func redfishFirstBoolAcrossDocs(docs []map[string]interface{}, keys ...string) * return nil } +func redfishFirstString(doc map[string]interface{}, keys ...string) string { + for _, key := range keys { + if v, ok := redfishLookupValue(doc, key); ok { + if s := strings.TrimSpace(asString(v)); s != "" { + return s + } + } + } + return "" +} + +func redfishFirstStringAcrossDocs(docs []map[string]interface{}, keys ...string) string { + for _, doc := range docs { + if v := redfishFirstString(doc, keys...); v != "" { + return v + } + } + return "" +} + +func redfishFirstLocationAcrossDocs(docs []map[string]interface{}, keys ...string) string { + for _, doc := range docs { + for _, key := range keys { + if v, ok := redfishLookupValue(doc, key); ok { + if loc := redfishLocationLabel(v); loc != "" { + return loc + } + } + } + } + return "" +} + func redfishLookupValue(doc map[string]interface{}, key string) (any, bool) { if doc == nil || strings.TrimSpace(key) == "" { return nil, false @@ -4537,8 +4597,9 @@ func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]inter } func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) models.PCIeDevice { + supplementalSlot := redfishFirstLocationAcrossDocs(supplementalDocs, "Slot", "Location", "PhysicalLocation") dev := models.PCIeDevice{ - Slot: firstNonEmpty(redfishLocationLabel(doc["Slot"]), redfishLocationLabel(doc["Location"]), asString(doc["Name"]), asString(doc["Id"])), + Slot: firstNonEmpty(redfishLocationLabel(doc["Slot"]), redfishLocationLabel(doc["Location"]), supplementalSlot, asString(doc["Name"]), asString(doc["Id"])), BDF: sanitizeRedfishBDF(asString(doc["BDF"])), DeviceClass: asString(doc["DeviceType"]), Manufacturer: asString(doc["Manufacturer"]), @@ -4578,6 +4639,9 @@ func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDoc dev.MaxLinkSpeed = firstNonEmpty(asString(fn["MaxLinkSpeedGTs"]), asString(fn["MaxLinkSpeed"])) } } + if dev.DeviceClass == "" || isGenericPCIeClassLabel(dev.DeviceClass) { + dev.DeviceClass = firstNonEmpty(redfishFirstStringAcrossDocs(supplementalDocs, "DeviceType"), dev.DeviceClass) + } if dev.DeviceClass == "" { dev.DeviceClass = "PCIe device" @@ -4588,15 +4652,22 @@ func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDoc } } if isGenericPCIeClassLabel(dev.DeviceClass) { - // Redfish DeviceType (e.g. MultiFunction/Simulated) is a topology attribute, - // not a user-facing device name. Prefer model/part labels when class cannot be resolved. - dev.DeviceClass = firstNonEmpty(asString(doc["Model"]), dev.PartNumber, dev.DeviceClass) + dev.DeviceClass = "PCIe device" } if strings.TrimSpace(dev.Manufacturer) == "" { - dev.Manufacturer = pciids.VendorName(dev.VendorID) + dev.Manufacturer = firstNonEmpty( + redfishFirstStringAcrossDocs(supplementalDocs, "Manufacturer"), + pciids.VendorName(dev.VendorID), + ) } if strings.TrimSpace(dev.PartNumber) == "" { - dev.PartNumber = pciids.DeviceName(dev.VendorID, dev.DeviceID) + dev.PartNumber = firstNonEmpty( + redfishFirstStringAcrossDocs(supplementalDocs, "ProductPartNumber", "PartNumber"), + pciids.DeviceName(dev.VendorID, dev.DeviceID), + ) + } + if normalizeRedfishIdentityField(dev.SerialNumber) == "" { + dev.SerialNumber = redfishFirstStringAcrossDocs(supplementalDocs, "SerialNumber") } return dev } @@ -4699,6 +4770,70 @@ func isGenericPCIeClassLabel(v string) bool { } } +func redfishPCIeMatchesChassisDeviceDoc(doc, deviceDoc map[string]interface{}) bool { + if len(doc) == 0 || len(deviceDoc) == 0 || redfishChassisDeviceDocLooksEmpty(deviceDoc) { + return false + } + docSerial := normalizeRedfishIdentityField(findFirstNormalizedStringByKeys(doc, "SerialNumber")) + deviceSerial := normalizeRedfishIdentityField(findFirstNormalizedStringByKeys(deviceDoc, "SerialNumber")) + if docSerial != "" && deviceSerial != "" && strings.EqualFold(docSerial, deviceSerial) { + return true + } + docTokens := redfishPCIeMatchTokens(doc) + deviceTokens := redfishPCIeMatchTokens(deviceDoc) + if len(docTokens) == 0 || len(deviceTokens) == 0 { + return false + } + for _, token := range docTokens { + for _, candidate := range deviceTokens { + if strings.EqualFold(token, candidate) { + return true + } + } + } + return false +} + +func redfishPCIeMatchTokens(doc map[string]interface{}) []string { + if len(doc) == 0 { + return nil + } + rawValues := []string{ + asString(doc["Name"]), + asString(doc["Model"]), + asString(doc["PartNumber"]), + asString(doc["ProductPartNumber"]), + } + out := make([]string, 0, len(rawValues)) + seen := make(map[string]struct{}, len(rawValues)) + for _, raw := range rawValues { + value := normalizeRedfishIdentityField(raw) + if value == "" { + continue + } + key := strings.ToLower(value) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, value) + } + return out +} + +func redfishChassisDeviceDocLooksEmpty(doc map[string]interface{}) bool { + name := strings.ToLower(strings.TrimSpace(asString(doc["Name"]))) + if strings.HasPrefix(name, "empty slot") { + return true + } + if strings.ToLower(strings.TrimSpace(asString(doc["DeviceType"]))) != "unknown" { + return false + } + return normalizeRedfishIdentityField(asString(doc["PartNumber"])) == "" && + normalizeRedfishIdentityField(asString(doc["ProductPartNumber"])) == "" && + normalizeRedfishIdentityField(findFirstNormalizedStringByKeys(doc, "SerialNumber")) == "" +} + func buildBDFfromOemPublic(doc map[string]interface{}) string { if len(doc) == 0 { return "" @@ -5126,6 +5261,9 @@ func classifyStorageType(doc map[string]interface{}) string { } func looksLikeVolume(doc map[string]interface{}) bool { + if redfishVolumeCapabilitiesDoc(doc) { + return false + } if asString(doc["RAIDType"]) != "" || asString(doc["VolumeType"]) != "" { return true } diff --git a/internal/collector/redfish_replay_inventory.go b/internal/collector/redfish_replay_inventory.go index 39cf313..2253248 100644 --- a/internal/collector/redfish_replay_inventory.go +++ b/internal/collector/redfish_replay_inventory.go @@ -143,24 +143,33 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st } func (r redfishSnapshotReader) getChassisScopedPCIeSupplementalDocs(doc map[string]interface{}) []map[string]interface{} { - if !looksLikeNVSwitchPCIeDoc(doc) { - return nil - } docPath := normalizeRedfishPath(asString(doc["@odata.id"])) chassisPath := chassisPathForPCIeDoc(docPath) if chassisPath == "" { return nil } - out := make([]map[string]interface{}, 0, 4) - for _, path := range []string{ - joinPath(chassisPath, "/EnvironmentMetrics"), - joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"), - } { - supplementalDoc, err := r.getJSON(path) - if err != nil || len(supplementalDoc) == 0 { - continue + + out := make([]map[string]interface{}, 0, 6) + if looksLikeNVSwitchPCIeDoc(doc) { + for _, path := range []string{ + joinPath(chassisPath, "/EnvironmentMetrics"), + joinPath(chassisPath, "/ThermalSubsystem/ThermalMetrics"), + } { + supplementalDoc, err := r.getJSON(path) + if err != nil || len(supplementalDoc) == 0 { + continue + } + out = append(out, supplementalDoc) + } + } + deviceDocs, err := r.getCollectionMembers(joinPath(chassisPath, "/Devices")) + if err == nil { + for _, deviceDoc := range deviceDocs { + if !redfishPCIeMatchesChassisDeviceDoc(doc, deviceDoc) { + continue + } + out = append(out, deviceDoc) } - out = append(out, supplementalDoc) } return out } diff --git a/internal/collector/redfish_test.go b/internal/collector/redfish_test.go index af3d506..cf470e3 100644 --- a/internal/collector/redfish_test.go +++ b/internal/collector/redfish_test.go @@ -1316,6 +1316,23 @@ func TestParsePCIeDevice_PrefersFunctionClassOverDeviceType(t *testing.T) { } } +func TestParsePCIeDevice_DoesNotPromotePartNumberToDeviceClass(t *testing.T) { + doc := map[string]interface{}{ + "Id": "NIC1", + "DeviceType": "SingleFunction", + "Model": "MCX75310AAS-NEAT", + "PartNumber": "MCX75310AAS-NEAT", + } + + got := parsePCIeDevice(doc, nil) + if got.DeviceClass != "PCIe device" { + t.Fatalf("expected generic PCIe class fallback, got %q", got.DeviceClass) + } + if got.PartNumber != "MCX75310AAS-NEAT" { + t.Fatalf("expected part number to stay intact, got %q", got.PartNumber) + } +} + func TestParsePCIeComponents_DoNotTreatNumericFunctionIDAsBDF(t *testing.T) { pcieFn := parsePCIeFunction(map[string]interface{}{ "Id": "1", @@ -2160,6 +2177,94 @@ func TestReplayCollectStorage_UsesKnownControllerRecoveryWhenEnabled(t *testing. } } +func TestReplayCollectStorageVolumes_SkipsVolumeCapabilitiesFallbackMember(t *testing.T) { + r := redfishSnapshotReader{tree: map[string]interface{}{ + "/redfish/v1/Systems/1/Storage": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Storage/DE00A000"}, + }, + }, + "/redfish/v1/Systems/1/Storage/DE00A000": map[string]interface{}{ + "Id": "DE00A000", + "Volumes": map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Storage/DE00A000/Volumes"}, + }, + "/redfish/v1/Systems/1/Storage/DE00A000/Volumes": map[string]interface{}{ + "@odata.id": "/redfish/v1/Systems/1/Storage/DE00A000/Volumes", + "@odata.type": "#VolumeCollection.VolumeCollection", + "Members": []interface{}{}, + "Members@odata.count": 0, + "Name": "MR Volume Collection", + }, + "/redfish/v1/Systems/1/Storage/DE00A000/Volumes/Capabilities": map[string]interface{}{ + "@odata.id": "/redfish/v1/Systems/1/Storage/DE00A000/Volumes/Capabilities", + "@odata.type": "#Volume.v1_9_0.Volume", + "Id": "Capabilities", + "Name": "Capabilities for VolumeCollection", + }, + }} + + got := r.collectStorageVolumes("/redfish/v1/Systems/1", testAnalysisPlan(redfishprofile.AnalysisDirectives{})) + if len(got) != 0 { + t.Fatalf("expected capabilities-only volume collection to stay empty, got %+v", got) + } +} + +func TestReplayCollectPCIeDevices_UsesChassisDeviceSupplementalDocs(t *testing.T) { + r := redfishSnapshotReader{tree: map[string]interface{}{ + "/redfish/v1/Chassis/1/PCIeDevices": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/2"}, + }, + }, + "/redfish/v1/Chassis/1/PCIeDevices/2": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/2", + "Name": "BCM 5719 1Gb 4p BASE-T OCP Adptr", + "Model": "P51183-001", + "PartNumber": "P51183-001", + "Manufacturer": "Broadcom", + "SerialNumber": "1CH0150001", + "DeviceType": "SingleFunction", + }, + "/redfish/v1/Chassis/1/Devices": map[string]interface{}{ + "Members": []interface{}{ + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/Devices/2"}, + map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1/Devices/4"}, + }, + }, + "/redfish/v1/Chassis/1/Devices/2": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/Devices/2", + "Name": "BCM 5719 1Gb 4p BASE-T OCP Adptr", + "DeviceType": "LOM/NIC", + "Manufacturer": "Broadcom", + "PartNumber": "BCM95719N1905HC", + "ProductPartNumber": "P51183-001", + "SerialNumber": "1CH0150001", + "Location": "OCP 3.0 Slot 15", + }, + "/redfish/v1/Chassis/1/Devices/4": map[string]interface{}{ + "@odata.id": "/redfish/v1/Chassis/1/Devices/4", + "Name": "Empty slot 2", + "DeviceType": "Unknown", + "Location": "PCI-E Slot 2", + "SerialNumber": "", + }, + }} + + got := r.collectPCIeDevices(nil, []string{"/redfish/v1/Chassis/1"}) + if len(got) != 1 { + t.Fatalf("expected one PCIe device, got %d", len(got)) + } + if got[0].Slot != "OCP 3.0 Slot 15" { + t.Fatalf("expected chassis device location to override weak slot label, got %+v", got[0]) + } + if got[0].DeviceClass != "LOM/NIC" { + t.Fatalf("expected chassis device type to enrich class, got %+v", got[0]) + } + if got[0].DeviceClass == "P51183-001" { + t.Fatalf("device class should not degrade into part number: %+v", got[0]) + } +} + func TestReplayCollectGPUs_DoesNotCollapseOnPlaceholderSerialAndSkipsNIC(t *testing.T) { r := redfishSnapshotReader{tree: map[string]interface{}{ "/redfish/v1/Chassis/1/PCIeDevices": map[string]interface{}{ @@ -2240,6 +2345,18 @@ func TestParseBoardInfo_NormalizesNullPlaceholders(t *testing.T) { } } +func TestParseBoardInfo_UsesSKUAsPartNumberFallback(t *testing.T) { + got := parseBoardInfo(map[string]interface{}{ + "Manufacturer": "HPE", + "Model": "ProLiant DL380 Gen11", + "SerialNumber": "CZ2D1X0GS4", + "SKU": "P52560-421", + }) + if got.PartNumber != "P52560-421" { + t.Fatalf("expected SKU to populate part number, got %q", got.PartNumber) + } +} + func TestShouldCrawlPath_SkipsJsonSchemas(t *testing.T) { if shouldCrawlPath("/redfish/v1/JsonSchemas") { t.Fatalf("expected /JsonSchemas to be skipped") diff --git a/internal/exporter/reanimator_converter.go b/internal/exporter/reanimator_converter.go index 7790e54..5054d6a 100644 --- a/internal/exporter/reanimator_converter.go +++ b/internal/exporter/reanimator_converter.go @@ -43,13 +43,13 @@ func ConvertToReanimator(result *models.AnalysisResult) (*ReanimatorExport, erro TargetHost: targetHost, CollectedAt: collectedAt, Hardware: ReanimatorHardware{ - Board: convertBoard(result.Hardware.BoardInfo), - Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)), - CPUs: dedupeCPUs(convertCPUsFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber, buildCPUMicrocodeBySocket(result.Hardware.Firmware))), - Memory: dedupeMemory(convertMemoryFromDevices(devices, collectedAt)), - Storage: dedupeStorage(convertStorageFromDevices(devices, collectedAt)), - PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)), - PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)), + Board: convertBoard(result.Hardware.BoardInfo), + Firmware: dedupeFirmware(convertFirmware(result.Hardware.Firmware)), + CPUs: dedupeCPUs(convertCPUsFromDevices(devices, collectedAt, result.Hardware.BoardInfo.SerialNumber, buildCPUMicrocodeBySocket(result.Hardware.Firmware))), + Memory: dedupeMemory(convertMemoryFromDevices(devices, collectedAt)), + Storage: dedupeStorage(convertStorageFromDevices(devices, collectedAt)), + PCIeDevices: dedupePCIe(convertPCIeFromDevices(devices, collectedAt)), + PowerSupplies: dedupePSUs(convertPSUsFromDevices(devices, collectedAt)), Sensors: convertSensors(result.Sensors), EventLogs: convertEventLogs(result.Events, collectedAt), }, @@ -669,7 +669,17 @@ func convertMemoryFromDevices(devices []models.HardwareDevice, collectedAt strin } present := boolFromPresentPtr(d.Present, true) status := normalizeStatus(d.Status, true) - if !present || d.SizeMB == 0 || status == "Empty" || strings.TrimSpace(d.SerialNumber) == "" { + mem := models.MemoryDIMM{ + Present: present, + SizeMB: d.SizeMB, + Type: d.Type, + Description: stringFromDetailMap(d.Details, "description"), + Manufacturer: d.Manufacturer, + SerialNumber: d.SerialNumber, + PartNumber: d.PartNumber, + Status: d.Status, + } + if !mem.IsInstalledInventory() || status == "Empty" || strings.TrimSpace(d.SerialNumber) == "" { continue } meta := buildStatusMeta(status, d.StatusCheckedAt, d.StatusChangedAt, d.StatusHistory, d.ErrorDescription, collectedAt) @@ -1334,7 +1344,7 @@ func convertMemory(memory []models.MemoryDIMM, collectedAt string) []ReanimatorM result := make([]ReanimatorMemory, 0, len(memory)) for _, mem := range memory { - if !mem.Present || mem.SizeMB == 0 || normalizeStatus(mem.Status, true) == "Empty" || strings.TrimSpace(mem.SerialNumber) == "" { + if !mem.IsInstalledInventory() || normalizeStatus(mem.Status, true) == "Empty" || strings.TrimSpace(mem.SerialNumber) == "" { continue } status := normalizeStatus(mem.Status, true) diff --git a/internal/exporter/reanimator_converter_test.go b/internal/exporter/reanimator_converter_test.go index 6eeed11..8802512 100644 --- a/internal/exporter/reanimator_converter_test.go +++ b/internal/exporter/reanimator_converter_test.go @@ -259,6 +259,29 @@ func TestConvertMemory(t *testing.T) { } } +func TestConvertMemory_KeepsInstalledDIMMWithUnknownSize(t *testing.T) { + memory := []models.MemoryDIMM{ + { + Slot: "PROC 1 DIMM 3", + Present: true, + SizeMB: 0, + Manufacturer: "Hynix", + PartNumber: "HMCG88AEBRA115N", + SerialNumber: "2B5F92C6", + Status: "OK", + }, + } + + result := convertMemory(memory, "2026-03-30T10:00:00Z") + + if len(result) != 1 { + t.Fatalf("expected 1 inventory-only DIMM, got %d", len(result)) + } + if result[0].PartNumber != "HMCG88AEBRA115N" || result[0].SerialNumber != "2B5F92C6" || result[0].SizeMB != 0 { + t.Fatalf("unexpected converted memory: %+v", result[0]) + } +} + func TestConvertToReanimator_CPUSerialIsNotSynthesizedAndSocketIsDeduped(t *testing.T) { input := &models.AnalysisResult{ Filename: "cpu-dedupe.json", diff --git a/internal/models/memory.go b/internal/models/memory.go new file mode 100644 index 0000000..764121a --- /dev/null +++ b/internal/models/memory.go @@ -0,0 +1,29 @@ +package models + +import "strings" + +// HasInventoryIdentity reports whether the DIMM has enough identifying +// inventory data to treat it as a populated module even when size is unknown. +func (m MemoryDIMM) HasInventoryIdentity() bool { + return strings.TrimSpace(m.SerialNumber) != "" || + strings.TrimSpace(m.PartNumber) != "" || + strings.TrimSpace(m.Type) != "" || + strings.TrimSpace(m.Technology) != "" || + strings.TrimSpace(m.Description) != "" +} + +// IsInstalledInventory reports whether the DIMM represents an installed module +// that should be kept in canonical inventory and exports. +func (m MemoryDIMM) IsInstalledInventory() bool { + if !m.Present { + return false + } + + status := strings.ToLower(strings.TrimSpace(m.Status)) + switch status { + case "empty", "absent", "not installed": + return false + } + + return m.SizeMB > 0 || m.HasInventoryIdentity() +} diff --git a/internal/parser/vendors/hpe_ilo_ahs/parser.go b/internal/parser/vendors/hpe_ilo_ahs/parser.go index 9030553..75680f3 100644 --- a/internal/parser/vendors/hpe_ilo_ahs/parser.go +++ b/internal/parser/vendors/hpe_ilo_ahs/parser.go @@ -25,12 +25,17 @@ const ( ) var ( - partNumberPattern = regexp.MustCompile(`(?i)^[a-z0-9]{1,4}\d{4,6}-[a-z0-9]{2,4}$`) - serverSerialRE = regexp.MustCompile(`(?i)(?:^|[_-])([a-z0-9]{10})(?:[_-]|\.)`) - dimmSlotRE = regexp.MustCompile(`^PROC\s+(\d+)\s+DIMM\s+(\d+)$`) - procSlotRE = regexp.MustCompile(`^Proc\s+(\d+)$`) - psuSlotRE = regexp.MustCompile(`^Power Supply\s+(\d+)$`) - eventTimeRE = regexp.MustCompile(`^\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}$`) + partNumberPattern = regexp.MustCompile(`(?i)^[a-z0-9]{1,4}\d{4,6}-[a-z0-9]{2,4}$`) + serverSerialRE = regexp.MustCompile(`(?i)(?:^|[_-])([a-z0-9]{10})(?:[_-]|\.)`) + dimmSlotRE = regexp.MustCompile(`^PROC\s+(\d+)\s+DIMM\s+(\d+)$`) + procSlotRE = regexp.MustCompile(`^Proc\s+(\d+)$`) + psuSlotRE = regexp.MustCompile(`^Power Supply\s+(\d+)$`) + eventTimeRE = regexp.MustCompile(`^\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}$`) + psuXMLRE = regexp.MustCompile(`(?s)(.*?)`) + firmwareLockdownRE = regexp.MustCompile(`(?s)(.*?)`) + xmlFieldRE = regexp.MustCompile(`(?s)<([A-Za-z0-9_-]+)>([^<]*)`) + psuLogRE = regexp.MustCompile(`Update bay (\d+) (SPN|Serial Number|Model Number|fw ver\.), value = ([A-Za-z0-9._-]+)`) + versionFragmentRE = regexp.MustCompile(`\d+(?:\.\d+)+`) ) func init() { @@ -129,6 +134,13 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er result.Hardware.NetworkAdapters = dedupeNetworkAdapters(parseNetworkAdapters(tokens)) result.Hardware.Firmware = dedupeFirmware(parseFirmware(tokens)) + psuSupplements := parsePSUSupplements(entries) + result.Hardware.PowerSupply = dedupePSUs(mergePSUs(result.Hardware.PowerSupply, psuSupplements)) + + lockdownFW, nicFirmwareByVendor := parseBCertFirmware(entries) + result.Hardware.NetworkAdapters = dedupeNetworkAdapters(enrichNetworkAdapters(result.Hardware.NetworkAdapters, nicFirmwareByVendor)) + result.Hardware.Firmware = dedupeFirmware(append(result.Hardware.Firmware, lockdownFW...)) + storage, volumes, controllerDevices, controllerFW := parseRedfishStorage(redfishDocs) result.Hardware.Storage = dedupeStorage(storage) result.Hardware.Volumes = volumes @@ -446,22 +458,37 @@ func parseDIMMs(tokens []string) []models.MemoryDIMM { func parsePSUs(tokens []string) []models.PSU { out := make([]models.PSU, 0, 4) - for i := 0; i+2 < len(tokens); i++ { + for i := 0; i < len(tokens); i++ { match := psuSlotRE.FindStringSubmatch(tokens[i]) if len(match) != 2 { continue } slot := "PSU " + match[1] - serial := tokens[i+1] - partNumber := tokens[i+2] - if isUnavailable(serial) && isUnavailable(partNumber) { + vendor := "" + serial := "" + partNumber := "" + for j := i + 1; j < len(tokens) && j <= i+5; j++ { + field := strings.TrimSpace(tokens[j]) + if strings.HasPrefix(field, "PciRoot(") || psuSlotRE.MatchString(field) || dimmSlotRE.MatchString(field) || procSlotRE.MatchString(field) || eventTimeRE.MatchString(field) { + break + } + switch { + case vendor == "" && looksLikePSUVendor(field): + vendor = field + case partNumber == "" && looksLikePartNumber(field): + partNumber = field + case serial == "" && isLikelySerial(field): + serial = field + } + } + if serial == "" && partNumber == "" { continue } psu := models.PSU{ Slot: slot, Present: true, Model: valueOr(partNumber, "Power Supply"), - Vendor: "HPE", + Vendor: valueOr(cleanUnavailable(vendor), "HPE"), SerialNumber: cleanUnavailable(serial), PartNumber: cleanUnavailable(partNumber), Status: "ok", @@ -471,6 +498,80 @@ func parsePSUs(tokens []string) []models.PSU { return out } +func parsePSUSupplements(entries []ahsEntry) []models.PSU { + bySlot := make(map[string]models.PSU) + + for _, entry := range entries { + text := string(entry.Content) + if text == "" { + continue + } + + if strings.EqualFold(entry.Name, "bcert.pkg") { + for _, match := range psuXMLRE.FindAllStringSubmatch(text, -1) { + slotNum, _ := strconv.Atoi(match[1]) + slot := fmt.Sprintf("PSU %d", slotNum+1) + fields := parseXMLFields(match[2]) + item := bySlot[slot] + item.Slot = slot + item.Present = strings.EqualFold(fields["Present"], "Yes") || item.Present + if serial := strings.TrimSpace(fields["SerialNumber"]); serial != "" { + item.SerialNumber = serial + } + if fw := strings.TrimSpace(fields["FirmwareVersion"]); fw != "" { + item.Firmware = fw + } + if spare := strings.TrimSpace(fields["SparePartNumber"]); spare != "" { + if item.Details == nil { + item.Details = make(map[string]any) + } + item.Details["spare_part_number"] = spare + } + bySlot[slot] = item + } + } + + for _, match := range psuLogRE.FindAllStringSubmatch(text, -1) { + slotNum, _ := strconv.Atoi(match[1]) + slot := fmt.Sprintf("PSU %d", slotNum+1) + item := bySlot[slot] + item.Slot = slot + item.Present = true + value := strings.TrimSpace(match[3]) + switch match[2] { + case "SPN": + if item.Details == nil { + item.Details = make(map[string]any) + } + item.Details["spare_part_number"] = value + case "Serial Number": + item.SerialNumber = value + case "Model Number": + item.Model = value + item.PartNumber = value + case "fw ver.": + item.Firmware = normalizeLooseVersion(value) + } + bySlot[slot] = item + } + } + + out := make([]models.PSU, 0, len(bySlot)) + for _, item := range bySlot { + if item.Slot == "" { + continue + } + item.Vendor = valueOr(item.Vendor, "HPE") + item.Status = valueOr(item.Status, "ok") + if item.Model == "" { + item.Model = valueOr(item.PartNumber, "Power Supply") + } + out = append(out, item) + } + sort.Slice(out, func(i, j int) bool { return out[i].Slot < out[j].Slot }) + return out +} + type pcieSequence struct { UEFIPath string Code string @@ -621,13 +722,53 @@ func parseRedfishStorage(docs map[string]map[string]any) ([]models.Storage, []mo storage := make([]models.Storage, 0, 8) volumes := make([]models.StorageVolume, 0, 4) - devices := make([]models.HardwareDevice, 0, 4) - firmware := make([]models.FirmwareInfo, 0, 4) + devices := make([]models.HardwareDevice, 0, 6) + firmware := make([]models.FirmwareInfo, 0, 8) + fabricNames := make(map[string]string) + fabricTypes := make(map[string]string) for _, path := range paths { doc := docs[path] docType := asString(doc["@odata.type"]) switch { + case strings.Contains(docType, "#Fabric."): + fabricID := redfishID(path) + fabricNames[fabricID] = strings.TrimSpace(asString(doc["Name"])) + fabricTypes[fabricID] = strings.TrimSpace(asString(doc["FabricType"])) + + case strings.Contains(docType, "#Switch."): + fabricID := fabricIDFromPath(path) + name := valueOr(fabricNames[fabricID], strings.TrimSpace(asString(doc["Name"]))) + model := strings.TrimSpace(asString(doc["Model"])) + fw := strings.TrimSpace(asString(doc["FirmwareVersion"])) + device := models.HardwareDevice{ + ID: "hpe-fabric-" + redfishID(path), + Kind: models.DeviceKindStorage, + Source: "redfish", + Slot: valueOr(fabricID, redfishID(path)), + DeviceClass: "storage_backplane", + Model: valueOr(name, model), + PartNumber: model, + Firmware: fw, + Status: redfishStatus(doc["Status"]), + Details: map[string]any{ + "odata_id": path, + "fabric_type": valueOr(fabricTypes[fabricID], strings.TrimSpace(asString(doc["FabricType"]))), + "switch_type": strings.TrimSpace(asString(doc["SwitchType"])), + "supported_protocols": stringSlice(doc["SupportedProtocols"]), + "domain_id": asInt64(doc["DomainID"]), + "fabric_name": fabricNames[fabricID], + "connected_chassis_id": asString(nested(doc, "Links", "Chassis", "@odata.id")), + }, + } + devices = append(devices, device) + if fw != "" { + firmware = append(firmware, models.FirmwareInfo{ + DeviceName: valueOr(name, model), + Version: fw, + }) + } + case strings.Contains(docType, "#StorageController."): slot := redfishServiceLabel(doc, "Location", "PartLocation", "ServiceLabel") model := valueOr(asString(doc["Model"]), asString(doc["Name"])) @@ -649,9 +790,16 @@ func parseRedfishStorage(docs map[string]map[string]any) ([]models.Storage, []mo Firmware: fw, Status: redfishStatus(doc["Status"]), Details: map[string]any{ - "odata_id": path, - "part_number": partNumber, - "sku": sku, + "odata_id": path, + "part_number": partNumber, + "sku": sku, + "speed_gbps": asFloat64(doc["SpeedGbps"]), + "supported_controller_protocols": stringSlice(doc["SupportedControllerProtocols"]), + "supported_device_protocols": stringSlice(doc["SupportedDeviceProtocols"]), + "supported_raid_types": stringSlice(doc["SupportedRAIDTypes"]), + "cache_total_mib": asInt64(nested(doc, "CacheSummary", "TotalCacheSizeMiB")), + "persistent_cache_mib": asInt64(nested(doc, "CacheSummary", "PersistentCacheSizeMiB")), + "durable_name": firstDurableName(doc), }, } if width := asInt(doc, "PCIeInterface", "LanesInUse"); width > 0 { @@ -692,8 +840,12 @@ func parseRedfishStorage(docs map[string]map[string]any) ([]models.Storage, []mo RemainingEndurancePct: endurance, Status: redfishStatus(doc["Status"]), Details: map[string]any{ - "odata_id": path, - "capacity_bytes": capacity, + "odata_id": path, + "capacity_bytes": capacity, + "failure_predicted": asBool(doc["FailurePredicted"]), + "negotiated_speed_gbps": asFloat64(doc["NegotiatedSpeedGbs"]), + "capable_speed_gbps": asFloat64(doc["CapableSpeedGbs"]), + "location_indicator_active": asBool(doc["LocationIndicatorActive"]), }, } storage = append(storage, entry) @@ -1005,6 +1157,16 @@ func isHPEManufacturer(v string) bool { return v == "HPE" || v == "HP" } +func looksLikePSUVendor(v string) bool { + v = strings.TrimSpace(strings.ToUpper(v)) + switch v { + case "HPE", "HP", "DELTA", "LITEON", "LTEON": + return true + default: + return false + } +} + func looksLikeServerModel(v string) bool { v = sanitizeModel(v) if v == "" { @@ -1115,6 +1277,163 @@ func inferVendor(model string) string { } } +func mergePSUs(base, extra []models.PSU) []models.PSU { + merged := make(map[string]models.PSU) + order := make([]string, 0, len(base)+len(extra)) + mergeOne := func(item models.PSU) { + key := strings.ToLower(strings.TrimSpace(item.Slot)) + if key == "" { + key = strings.ToLower(strings.TrimSpace(valueOr(item.SerialNumber, item.Model+"|"+item.PartNumber))) + } + if key == "" { + return + } + current, exists := merged[key] + if !exists { + merged[key] = item + order = append(order, key) + return + } + if current.Slot == "" { + current.Slot = item.Slot + } + current.Present = current.Present || item.Present + current.Model = valueOr(current.Model, item.Model) + current.Description = valueOr(current.Description, item.Description) + current.Vendor = valueOr(current.Vendor, item.Vendor) + if current.WattageW == 0 { + current.WattageW = item.WattageW + } + current.SerialNumber = valueOr(current.SerialNumber, item.SerialNumber) + current.PartNumber = valueOr(current.PartNumber, item.PartNumber) + current.Firmware = valueOr(current.Firmware, item.Firmware) + current.Status = valueOr(current.Status, item.Status) + current.InputType = valueOr(current.InputType, item.InputType) + if current.InputPowerW == 0 { + current.InputPowerW = item.InputPowerW + } + if current.OutputPowerW == 0 { + current.OutputPowerW = item.OutputPowerW + } + if current.InputVoltage == 0 { + current.InputVoltage = item.InputVoltage + } + if current.OutputVoltage == 0 { + current.OutputVoltage = item.OutputVoltage + } + if current.TemperatureC == 0 { + current.TemperatureC = item.TemperatureC + } + current.Details = mergeDetailMaps(current.Details, item.Details) + merged[key] = current + } + for _, item := range base { + mergeOne(item) + } + for _, item := range extra { + mergeOne(item) + } + out := make([]models.PSU, 0, len(order)) + for _, key := range order { + out = append(out, merged[key]) + } + return out +} + +func enrichNetworkAdapters(items []models.NetworkAdapter, firmwareByVendor map[string]string) []models.NetworkAdapter { + out := make([]models.NetworkAdapter, 0, len(items)) + for _, item := range items { + if item.Firmware == "" { + if fw := firmwareByVendor[strings.ToLower(strings.TrimSpace(item.Vendor))]; fw != "" { + item.Firmware = fw + } + } + out = append(out, item) + } + return out +} + +func parseBCertFirmware(entries []ahsEntry) ([]models.FirmwareInfo, map[string]string) { + out := make([]models.FirmwareInfo, 0, 8) + nicFirmwareByVendor := make(map[string]string) + seen := make(map[string]bool) + + tagNames := map[string]string{ + "SystemProgrammableLogicDevice": "System Programmable Logic Device", + "ServerPlatformServicesSPSFirmware": "Server Platform Services (SPS) Firmware", + "STMicroGen11TPM": "TPM Firmware", + "PrimaryR012U3x16slotsriserx8-x16-x8": "PCIe Riser 1 Programmable Logic Device", + "HPEMR408i-oGen11": "HPE MR408i-o Gen11", + "UBM3": "8 SFF 24G x1NVMe/SAS UBM3 BC BP", + "BCM57191Gb4pBASE-T": "BCM 5719 1Gb 4p BASE-T OCP Adptr", + "BCM57191Gb4pBASE-TOCP3": "BCM 5719 1Gb 4p BASE-T OCP Adptr", + } + + for _, entry := range entries { + if !strings.EqualFold(entry.Name, "bcert.pkg") { + continue + } + text := string(entry.Content) + for _, match := range firmwareLockdownRE.FindAllStringSubmatch(text, -1) { + fields := parseXMLFields(match[1]) + for tag, value := range fields { + name := tagNames[tag] + if name == "" { + continue + } + version := normalizeBCertVersion(tag, value) + if version == "" { + continue + } + appendFirmware(&out, seen, models.FirmwareInfo{ + DeviceName: name, + Version: version, + }) + if strings.Contains(name, "BCM 5719") { + nicFirmwareByVendor["broadcom"] = version + } + } + } + } + + return out, nicFirmwareByVendor +} + +func parseXMLFields(block string) map[string]string { + out := make(map[string]string) + for _, match := range xmlFieldRE.FindAllStringSubmatch(block, -1) { + out[match[1]] = strings.TrimSpace(match[2]) + } + return out +} + +func normalizeBCertVersion(tag, value string) string { + value = strings.TrimSpace(value) + if value == "" || strings.EqualFold(value, "NA") { + return "" + } + switch tag { + case "UBM3": + if idx := strings.LastIndex(value, "/"); idx >= 0 && idx+1 < len(value) { + return strings.TrimSpace(value[idx+1:]) + } + case "IntegratedLights-OutVI": + if idx := strings.Index(value, " - "); idx > 0 { + return strings.TrimSpace(value[:idx]) + } + case "U54": + return value + } + return value +} + +func normalizeLooseVersion(value string) string { + if match := versionFragmentRE.FindString(strings.TrimSpace(value)); match != "" { + return match + } + return strings.TrimSpace(value) +} + func slotLabelFromCode(code string) string { parts := strings.Split(code, ".") if len(parts) < 3 { @@ -1132,6 +1451,16 @@ func slotLabelFromCode(code string) string { } } +func fabricIDFromPath(path string) string { + parts := strings.Split(strings.Trim(path, "/"), "/") + for i := 0; i+1 < len(parts); i++ { + if parts[i] == "Fabrics" { + return parts[i+1] + } + } + return "" +} + func inferSeverity(message string) models.Severity { lower := strings.ToLower(message) switch { @@ -1261,6 +1590,24 @@ func asInt64(v any) int64 { } } +func asFloat64(v any) float64 { + switch t := v.(type) { + case float64: + return t + case float32: + return float64(t) + case int: + return float64(t) + case int64: + return float64(t) + case json.Number: + f, _ := t.Float64() + return f + default: + return 0 + } +} + func asOptionalInt(v any) *int { switch value := v.(type) { case float64: @@ -1274,6 +1621,11 @@ func asOptionalInt(v any) *int { } } +func asBool(v any) bool { + b, ok := v.(bool) + return ok && b +} + func valueOr(v, fallback string) string { if strings.TrimSpace(v) != "" { return strings.TrimSpace(v) @@ -1281,6 +1633,73 @@ func valueOr(v, fallback string) string { return strings.TrimSpace(fallback) } +func stringSlice(v any) []string { + items, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(items)) + for _, item := range items { + value := strings.TrimSpace(asString(item)) + if value == "" { + continue + } + out = append(out, value) + } + return out +} + +func firstDurableName(doc map[string]any) string { + items, ok := doc["Identifiers"].([]any) + if !ok { + return "" + } + for _, item := range items { + entry, ok := item.(map[string]any) + if !ok { + continue + } + if value := strings.TrimSpace(asString(entry["DurableName"])); value != "" { + return value + } + } + return "" +} + +func mergeDetailMaps(base, extra map[string]any) map[string]any { + if len(extra) == 0 { + return base + } + if base == nil { + base = make(map[string]any, len(extra)) + } + for key, value := range extra { + if _, exists := base[key]; !exists || isZeroValue(base[key]) { + base[key] = value + } + } + return base +} + +func isZeroValue(v any) bool { + switch t := v.(type) { + case nil: + return true + case string: + return strings.TrimSpace(t) == "" + case int: + return t == 0 + case int64: + return t == 0 + case float64: + return t == 0 + case bool: + return !t + default: + return false + } +} + func boolPtr(v bool) *bool { out := v return &out diff --git a/internal/parser/vendors/hpe_ilo_ahs/parser_test.go b/internal/parser/vendors/hpe_ilo_ahs/parser_test.go index 6b4d402..9c2bf3c 100644 --- a/internal/parser/vendors/hpe_ilo_ahs/parser_test.go +++ b/internal/parser/vendors/hpe_ilo_ahs/parser_test.go @@ -27,6 +27,7 @@ func TestParseAHSInventory(t *testing.T) { content := makeAHSArchive(t, []ahsTestEntry{ {Name: "CUST_INFO.DAT", Payload: make([]byte, 16)}, {Name: "0000088-2026-03-30.zbb", Payload: gzipBytes(t, []byte(sampleInventoryBlob()))}, + {Name: "bcert.pkg", Payload: []byte(sampleBCertBlob())}, }) result, err := p.Parse([]parser.ExtractedFile{{ @@ -73,6 +74,9 @@ func TestParseAHSInventory(t *testing.T) { if result.Hardware.PowerSupply[0].SerialNumber != "5XUWB0C4DJG4BV" { t.Fatalf("unexpected PSU serial: %q", result.Hardware.PowerSupply[0].SerialNumber) } + if result.Hardware.PowerSupply[0].Firmware != "2.00" { + t.Fatalf("unexpected PSU firmware: %q", result.Hardware.PowerSupply[0].Firmware) + } if len(result.Hardware.Storage) != 1 { t.Fatalf("expected one physical drive, got %d", len(result.Hardware.Storage)) @@ -93,6 +97,8 @@ func TestParseAHSInventory(t *testing.T) { } foundILO := false foundControllerFW := false + foundNICFW := false + foundBackplaneFW := false for _, item := range result.Hardware.Firmware { if item.DeviceName == "iLO 6" && item.Version == "v1.63p20" { foundILO = true @@ -100,6 +106,12 @@ func TestParseAHSInventory(t *testing.T) { if item.DeviceName == "HPE MR408i-o Gen11" && item.Version == "52.26.3-5379" { foundControllerFW = true } + if item.DeviceName == "BCM 5719 1Gb 4p BASE-T OCP Adptr" && item.Version == "20.28.41" { + foundNICFW = true + } + if item.DeviceName == "8 SFF 24G x1NVMe/SAS UBM3 BC BP" && item.Version == "1.24" { + foundBackplaneFW = true + } } if !foundILO { t.Fatalf("expected iLO firmware entry") @@ -107,6 +119,31 @@ func TestParseAHSInventory(t *testing.T) { if !foundControllerFW { t.Fatalf("expected controller firmware entry") } + if !foundNICFW { + t.Fatalf("expected broadcom firmware entry") + } + if !foundBackplaneFW { + t.Fatalf("expected backplane firmware entry") + } + + broadcomFound := false + backplaneFound := false + for _, nic := range result.Hardware.NetworkAdapters { + if nic.SerialNumber == "1CH0150001" && nic.Firmware == "20.28.41" { + broadcomFound = true + } + } + for _, dev := range result.Hardware.Devices { + if dev.DeviceClass == "storage_backplane" && dev.Firmware == "1.24" { + backplaneFound = true + } + } + if !broadcomFound { + t.Fatalf("expected broadcom adapter firmware to be enriched") + } + if !backplaneFound { + t.Fatalf("expected backplane canonical device") + } if len(result.Hardware.Devices) < 6 { t.Fatalf("expected canonical devices, got %d", len(result.Hardware.Devices)) @@ -146,17 +183,35 @@ func TestParseExampleAHS(t *testing.T) { if len(result.Hardware.Storage) < 2 { t.Fatalf("expected at least two drives, got %d", len(result.Hardware.Storage)) } + if len(result.Hardware.PowerSupply) != 2 { + t.Fatalf("expected exactly two PSUs, got %d: %+v", len(result.Hardware.PowerSupply), result.Hardware.PowerSupply) + } foundController := false + foundBackplaneFW := false + foundNICFW := false for _, device := range result.Hardware.Devices { if device.Model == "HPE MR408i-o Gen11" && device.SerialNumber == "PXSFQ0BBIJY3B3" { foundController = true - break + } + if device.DeviceClass == "storage_backplane" && device.Firmware == "1.24" { + foundBackplaneFW = true } } if !foundController { t.Fatalf("expected MR408i-o controller in canonical devices") } + for _, fw := range result.Hardware.Firmware { + if fw.DeviceName == "BCM 5719 1Gb 4p BASE-T OCP Adptr" && fw.Version == "20.28.41" { + foundNICFW = true + } + } + if !foundBackplaneFW { + t.Fatalf("expected backplane device in canonical devices") + } + if !foundNICFW { + t.Fatalf("expected broadcom firmware from bcert/pkg lockdown") + } } type ahsTestEntry struct { @@ -239,11 +294,17 @@ func sampleInventoryBlob() string { "03/30/2026 09:47:33", "iLO network link down.", `{"@odata.id":"/redfish/v1/Systems/1/Storage/DE00A000/Controllers/0","@odata.type":"#StorageController.v1_7_0.StorageController","Id":"0","Name":"HPE MR408i-o Gen11","FirmwareVersion":"52.26.3-5379","Manufacturer":"HPE","Model":"HPE MR408i-o Gen11","PartNumber":"P58543-001","SKU":"P58335-B21","SerialNumber":"PXSFQ0BBIJY3B3","Status":{"State":"Enabled","Health":"OK"},"Location":{"PartLocation":{"ServiceLabel":"Slot=14","LocationType":"Slot","LocationOrdinalValue":14}},"PCIeInterface":{"PCIeType":"Gen4","LanesInUse":8}}`, + `{"@odata.id":"/redfish/v1/Fabrics/DE00A000","@odata.type":"#Fabric.v1_3_0.Fabric","Id":"DE00A000","Name":"8 SFF 24G x1NVMe/SAS UBM3 BC BP","FabricType":"MultiProtocol"}`, + `{"@odata.id":"/redfish/v1/Fabrics/DE00A000/Switches/1","@odata.type":"#Switch.v1_9_1.Switch","Id":"1","Name":"Direct Attached","Model":"UBM3","FirmwareVersion":"1.24","SupportedProtocols":["SAS","SATA","NVMe"],"SwitchType":"MultiProtocol","Status":{"State":"Enabled","Health":"OK"}}`, `{"@odata.id":"/redfish/v1/Chassis/DE00A000/Drives/0","@odata.type":"#Drive.v1_17_0.Drive","Id":"0","Name":"480GB 6G SATA SSD","Status":{"State":"StandbyOffline","Health":"OK"},"PhysicalLocation":{"PartLocation":{"ServiceLabel":"Slot=14:Port=1:Box=3:Bay=1","LocationType":"Bay","LocationOrdinalValue":1}},"CapacityBytes":480103981056,"MediaType":"SSD","Model":"SAMSUNGMZ7L3480HCHQ-00A07","Protocol":"SATA","Revision":"JXTC604Q","SerialNumber":"S664NC0Y502720","PredictedMediaLifeLeftPercent":100}`, `{"@odata.id":"/redfish/v1/Chassis/DE00A000/Drives/64515","@odata.type":"#Drive.v1_17_0.Drive","Id":"64515","Name":"Empty Bay","Status":{"State":"Absent","Health":"OK"}}`, ) } +func sampleBCertBlob() string { + return `Yes5XUWB0C4DJG4BV2.00P44412-0010x126.1.4.471.51252.26.3-5379UBM3/1.2420.28.41` +} + func stringsJoin(parts ...string) string { return string(bytes.Join(func() [][]byte { out := make([][]byte, 0, len(parts)) diff --git a/internal/server/device_repository.go b/internal/server/device_repository.go index ebc09fc..89a8c28 100644 --- a/internal/server/device_repository.go +++ b/internal/server/device_repository.go @@ -81,7 +81,7 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice { } for _, mem := range hw.Memory { - if !mem.Present || mem.SizeMB == 0 { + if !mem.IsInstalledInventory() { continue } present := mem.Present diff --git a/internal/server/device_repository_test.go b/internal/server/device_repository_test.go index c89ecea..19303fe 100644 --- a/internal/server/device_repository_test.go +++ b/internal/server/device_repository_test.go @@ -90,6 +90,63 @@ func TestBuildHardwareDevices_MemorySameSerialDifferentSlots_NotDeduped(t *testi } } +func TestBuildHardwareDevices_ZeroSizeMemoryWithInventoryIsIncluded(t *testing.T) { + hw := &models.HardwareConfig{ + Memory: []models.MemoryDIMM{ + { + Slot: "PROC 1 DIMM 3", + Location: "PROC 1 DIMM 3", + Present: true, + SizeMB: 0, + Manufacturer: "Hynix", + SerialNumber: "2B5F92C6", + PartNumber: "HMCG88AEBRA115N", + Status: "ok", + }, + }, + } + + devices := BuildHardwareDevices(hw) + memoryCount := 0 + for _, d := range devices { + if d.Kind != models.DeviceKindMemory { + continue + } + memoryCount++ + if d.Slot != "PROC 1 DIMM 3" || d.PartNumber != "HMCG88AEBRA115N" || d.SerialNumber != "2B5F92C6" { + t.Fatalf("unexpected memory device: %+v", d) + } + } + if memoryCount != 1 { + t.Fatalf("expected 1 installed zero-size memory record, got %d", memoryCount) + } +} + +func TestBuildSpecification_ZeroSizeMemoryWithInventoryIsShown(t *testing.T) { + hw := &models.HardwareConfig{ + Memory: []models.MemoryDIMM{ + { + Slot: "PROC 1 DIMM 3", + Present: true, + SizeMB: 0, + Manufacturer: "Hynix", + PartNumber: "HMCG88AEBRA115N", + SerialNumber: "2B5F92C6", + Status: "ok", + }, + }, + } + + spec := buildSpecification(hw) + for _, line := range spec { + if line.Category == "Память" && line.Name == "Hynix HMCG88AEBRA115N (size unknown)" && line.Quantity == 1 { + return + } + } + + t.Fatalf("expected memory spec line for zero-size identified DIMM, got %+v", spec) +} + func TestBuildHardwareDevices_DuplicateSerials_AreAnnotated(t *testing.T) { hw := &models.HardwareConfig{ Memory: []models.MemoryDIMM{ diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 7448efb..ae6b01a 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -530,11 +530,21 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine { continue } present := mem.Present != nil && *mem.Present - // Skip empty slots (not present or 0 size) - if !present || mem.SizeMB == 0 { + if !present { continue } - // Include frequency if available + + if mem.SizeMB == 0 { + name := strings.TrimSpace(strings.Join(nonEmptyStrings(mem.Manufacturer, mem.PartNumber, mem.Type), " ")) + if name == "" { + name = "Installed DIMM (size unknown)" + } else { + name += " (size unknown)" + } + memGroups[name]++ + continue + } + key := "" currentSpeed := intFromDetails(mem.Details, "current_speed_mhz") if currentSpeed > 0 { @@ -626,6 +636,18 @@ func buildSpecification(hw *models.HardwareConfig) []SpecLine { return spec } +func nonEmptyStrings(values ...string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + out = append(out, value) + } + return out +} + func (s *Server) handleGetSerials(w http.ResponseWriter, r *http.Request) { result := s.GetResult() if result == nil {