1 Commits

Author SHA1 Message Date
Mikhail Chusavitin
c47c34fd11 feat(hpe): improve inventory extraction and export fidelity 2026-03-30 15:04:17 +03:00
12 changed files with 989 additions and 59 deletions

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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")

View File

@@ -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)

View File

@@ -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",

29
internal/models/memory.go Normal file
View File

@@ -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()
}

View File

@@ -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)<PowerSupplySlot id="(\d+)">(.*?)</PowerSupplySlot>`)
firmwareLockdownRE = regexp.MustCompile(`(?s)<FirmwareLockdown>(.*?)</FirmwareLockdown>`)
xmlFieldRE = regexp.MustCompile(`(?s)<([A-Za-z0-9_-]+)>([^<]*)</[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

View File

@@ -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 `<BC><MfgRecord><PowerSupplySlot id="0"><Present>Yes</Present><SerialNumber>5XUWB0C4DJG4BV</SerialNumber><FirmwareVersion>2.00</FirmwareVersion><SparePartNumber>P44412-001</SparePartNumber></PowerSupplySlot><FirmwareLockdown><SystemProgrammableLogicDevice>0x12</SystemProgrammableLogicDevice><ServerPlatformServicesSPSFirmware>6.1.4.47</ServerPlatformServicesSPSFirmware><STMicroGen11TPM>1.512</STMicroGen11TPM><HPEMR408i-oGen11>52.26.3-5379</HPEMR408i-oGen11><UBM3>UBM3/1.24</UBM3><BCM57191Gb4pBASE-TOCP3>20.28.41</BCM57191Gb4pBASE-TOCP3></FirmwareLockdown></MfgRecord></BC>`
}
func stringsJoin(parts ...string) string {
return string(bytes.Join(func() [][]byte {
out := make([][]byte, 0, len(parts))

View File

@@ -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

View File

@@ -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{

View File

@@ -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 {