feat(hpe): improve inventory extraction and export fidelity
This commit is contained in:
+152
-14
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user