feat(exporter): collect and export storage.vendor_id/device_id (contract v2.13)
Add PCI Vendor ID / Device ID to hardware.storage[] per the updated Reanimator ingest contract, mirroring the existing pcie_devices[] fields. Populated for Redfish-collected NVMe drives (live + TSR replay, via linked PCIeFunctions) and for Inspur (from asset.json's own PcieInfo[], joined by PcieSlot). Also fixes canonicalDevicesForExport dropping the fields when converting Storage into the canonical HardwareDevice list used by the actual export path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
063587958e
commit
399eca5f49
@@ -813,6 +813,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
||||
continue
|
||||
}
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
supplementalDocs = append(supplementalDocs, c.getLinkedPCIeFunctions(ctx, client, req, baseURL, driveDoc)...)
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
if len(driveDocs) == 0 {
|
||||
@@ -821,6 +822,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
||||
continue
|
||||
}
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
supplementalDocs = append(supplementalDocs, c.getLinkedPCIeFunctions(ctx, client, req, baseURL, driveDoc)...)
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
@@ -846,6 +848,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
||||
continue
|
||||
}
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
supplementalDocs = append(supplementalDocs, c.getLinkedPCIeFunctions(ctx, client, req, baseURL, driveDoc)...)
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
continue
|
||||
@@ -857,6 +860,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
||||
continue
|
||||
}
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
supplementalDocs = append(supplementalDocs, c.getLinkedPCIeFunctions(ctx, client, req, baseURL, member)...)
|
||||
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
|
||||
}
|
||||
|
||||
@@ -868,6 +872,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
||||
for _, driveDoc := range driveDocs {
|
||||
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
supplementalDocs = append(supplementalDocs, c.getLinkedPCIeFunctions(ctx, client, req, baseURL, driveDoc)...)
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
@@ -890,6 +895,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
||||
}) {
|
||||
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
supplementalDocs = append(supplementalDocs, c.getLinkedPCIeFunctions(ctx, client, req, baseURL, driveDoc)...)
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
@@ -922,6 +928,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
||||
continue
|
||||
}
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
supplementalDocs = append(supplementalDocs, c.getLinkedPCIeFunctions(ctx, client, req, baseURL, driveDoc)...)
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
@@ -934,6 +941,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
||||
continue
|
||||
}
|
||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
supplementalDocs = append(supplementalDocs, c.getLinkedPCIeFunctions(ctx, client, req, baseURL, driveDoc)...)
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
@@ -3495,11 +3503,27 @@ func parseDriveWithSupplementalDocs(doc map[string]interface{}, supplementalDocs
|
||||
|
||||
storageType := classifyStorageType(doc)
|
||||
|
||||
// Drive resources rarely carry PCI Vendor/Device IDs directly; a linked
|
||||
// PCIeFunction document (passed here as part of supplementalDocs, e.g. via
|
||||
// getLinkedPCIeFunctions) usually does for NVMe drives.
|
||||
vendorID := asHexOrInt(doc["VendorId"])
|
||||
deviceID := asHexOrInt(doc["DeviceId"])
|
||||
for _, sd := range supplementalDocs {
|
||||
if vendorID == 0 {
|
||||
vendorID = asHexOrInt(sd["VendorId"])
|
||||
}
|
||||
if deviceID == 0 {
|
||||
deviceID = asHexOrInt(sd["DeviceId"])
|
||||
}
|
||||
}
|
||||
|
||||
slot := normalizeRAIDDriveSlot(firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])))
|
||||
return models.Storage{
|
||||
Slot: slot,
|
||||
Type: storageType,
|
||||
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
|
||||
VendorID: vendorID,
|
||||
DeviceID: deviceID,
|
||||
SizeGB: sizeGB,
|
||||
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
|
||||
Manufacturer: asString(doc["Manufacturer"]),
|
||||
|
||||
@@ -16,6 +16,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
||||
for _, driveDoc := range driveDocs {
|
||||
if !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
supplementalDocs = append(supplementalDocs, r.getLinkedPCIeFunctions(driveDoc)...)
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
@@ -25,6 +26,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
||||
continue
|
||||
}
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
supplementalDocs = append(supplementalDocs, r.getLinkedPCIeFunctions(driveDoc)...)
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
@@ -48,6 +50,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
||||
}
|
||||
if !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
supplementalDocs = append(supplementalDocs, r.getLinkedPCIeFunctions(driveDoc)...)
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
@@ -58,6 +61,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
||||
continue
|
||||
}
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
supplementalDocs = append(supplementalDocs, r.getLinkedPCIeFunctions(member)...)
|
||||
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
|
||||
}
|
||||
|
||||
@@ -68,6 +72,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
||||
for _, driveDoc := range driveDocs {
|
||||
if looksLikeDrive(driveDoc) && !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
supplementalDocs = append(supplementalDocs, r.getLinkedPCIeFunctions(driveDoc)...)
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
@@ -88,6 +93,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
||||
for _, driveDoc := range r.collectKnownStorageMembers(systemPath, plan.KnownStorageDriveCollections) {
|
||||
if looksLikeDrive(driveDoc) && !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||
supplementalDocs = append(supplementalDocs, r.getLinkedPCIeFunctions(driveDoc)...)
|
||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1933,6 +1933,20 @@ func TestParseComponentDetails_UseLinkedSupplementalMetrics(t *testing.T) {
|
||||
t.Fatalf("expected drive details from supplemental metrics, got %#v", drive.Details)
|
||||
}
|
||||
|
||||
driveWithPCIeFn := parseDriveWithSupplementalDocs(
|
||||
map[string]interface{}{
|
||||
"Id": "Drive1",
|
||||
"SerialNumber": "NVME-001",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"VendorId": "0x144D",
|
||||
"DeviceId": "0xA808",
|
||||
},
|
||||
)
|
||||
if driveWithPCIeFn.VendorID != 0x144D || driveWithPCIeFn.DeviceID != 0xA808 {
|
||||
t.Fatalf("expected vendor/device id from linked PCIeFunction doc, got vendor=%#x device=%#x", driveWithPCIeFn.VendorID, driveWithPCIeFn.DeviceID)
|
||||
}
|
||||
|
||||
psu := parsePSUWithSupplementalDocs(
|
||||
map[string]interface{}{
|
||||
"MemberId": "PSU0",
|
||||
|
||||
@@ -175,6 +175,8 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
|
||||
Kind: models.DeviceKindStorage,
|
||||
Slot: stor.Slot,
|
||||
Model: stor.Model,
|
||||
VendorID: stor.VendorID,
|
||||
DeviceID: stor.DeviceID,
|
||||
Manufacturer: stor.Manufacturer,
|
||||
RemainingEndurancePct: stor.RemainingEndurancePct,
|
||||
SerialNumber: stor.SerialNumber,
|
||||
@@ -771,6 +773,8 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
|
||||
Slot: d.Slot,
|
||||
Type: d.Type,
|
||||
Model: d.Model,
|
||||
VendorID: d.VendorID,
|
||||
DeviceID: d.DeviceID,
|
||||
SizeGB: d.SizeGB,
|
||||
SerialNumber: d.SerialNumber,
|
||||
Manufacturer: d.Manufacturer,
|
||||
@@ -1452,6 +1456,8 @@ func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorSt
|
||||
Slot: stor.Slot,
|
||||
Type: stor.Type,
|
||||
Model: stor.Model,
|
||||
VendorID: stor.VendorID,
|
||||
DeviceID: stor.DeviceID,
|
||||
SizeGB: stor.SizeGB,
|
||||
SerialNumber: stor.SerialNumber,
|
||||
Manufacturer: stor.Manufacturer,
|
||||
|
||||
@@ -2078,6 +2078,60 @@ func TestConvertToReanimator_MemoryAndPSURoundTripSurvivesReimport(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertToReanimator_StorageVendorDeviceIDSurvivesRoundTrip covers the
|
||||
// hardware.storage[].vendor_id/device_id contract fields (v2.13).
|
||||
func TestConvertToReanimator_StorageVendorDeviceIDSurvivesRoundTrip(t *testing.T) {
|
||||
original := &models.AnalysisResult{
|
||||
Filename: "test.zip",
|
||||
CollectedAt: time.Date(2026, 8, 11, 0, 0, 0, 0, time.UTC),
|
||||
Hardware: &models.HardwareConfig{
|
||||
BoardInfo: models.BoardInfo{
|
||||
Manufacturer: "Dell Inc.",
|
||||
ProductName: "PowerEdge R7715",
|
||||
SerialNumber: "1TVFYL4",
|
||||
},
|
||||
Storage: []models.Storage{
|
||||
{
|
||||
Slot: "OB01",
|
||||
Type: "NVMe",
|
||||
Model: "INTEL SSDPF2KX076T1",
|
||||
VendorID: 32902,
|
||||
DeviceID: 62305,
|
||||
SerialNumber: "BTAX41900GF87P6DGN",
|
||||
Present: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
exported, err := ConvertToReanimator(original)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertToReanimator() failed: %v", err)
|
||||
}
|
||||
if len(exported.Hardware.Storage) != 1 {
|
||||
t.Fatalf("expected 1 exported storage entry, got %d", len(exported.Hardware.Storage))
|
||||
}
|
||||
if exported.Hardware.Storage[0].VendorID != 32902 || exported.Hardware.Storage[0].DeviceID != 62305 {
|
||||
t.Fatalf("expected exported storage vendor_id/device_id, got %+v", exported.Hardware.Storage[0])
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal exported: %v", err)
|
||||
}
|
||||
var reimported models.AnalysisResult
|
||||
if err := json.Unmarshal(raw, &reimported); err != nil {
|
||||
t.Fatalf("unmarshal into AnalysisResult: %v", err)
|
||||
}
|
||||
reconverted, err := ConvertToReanimator(&reimported)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertToReanimator() on reimported result failed: %v", err)
|
||||
}
|
||||
if len(reconverted.Hardware.Storage) != 1 || reconverted.Hardware.Storage[0].VendorID != 32902 || reconverted.Hardware.Storage[0].DeviceID != 62305 {
|
||||
t.Fatalf("storage vendor_id/device_id did not survive reanimator round trip, got %+v", reconverted.Hardware.Storage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertToReanimator_ExportsLicenses covers the hardware.licenses contract
|
||||
// section (v2.12): system-level licenses (no component_ref) and component-scoped
|
||||
// licenses, skipping records without a name and records the source marked absent.
|
||||
|
||||
@@ -118,6 +118,8 @@ type ReanimatorStorage struct {
|
||||
Slot string `json:"slot"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Model string `json:"model"`
|
||||
VendorID int `json:"vendor_id,omitempty"`
|
||||
DeviceID int `json:"device_id,omitempty"`
|
||||
SizeGB int `json:"size_gb,omitempty"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
|
||||
@@ -275,6 +275,8 @@ type Storage struct {
|
||||
Present bool `json:"present"`
|
||||
Location string `json:"location,omitempty"` // Front/Rear
|
||||
BackplaneID int `json:"backplane_id,omitempty"`
|
||||
VendorID int `json:"vendor_id,omitempty"` // PCI Vendor ID (decimal), NVMe drives only
|
||||
DeviceID int `json:"device_id,omitempty"` // PCI Device ID (decimal), NVMe drives only
|
||||
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"` // 0-100 %; nil = not reported
|
||||
LogicalBlockSizeBytes int64 `json:"logical_block_size_bytes,omitempty"`
|
||||
PhysicalBlockSizeBytes int64 `json:"physical_block_size_bytes,omitempty"`
|
||||
|
||||
+19
@@ -162,6 +162,23 @@ func ParseAssetJSON(content []byte, pcieSlotDeviceNames map[int]string, pcieSlot
|
||||
}
|
||||
}
|
||||
|
||||
// Build a PcieSlot -> (VendorId, DeviceId) map from asset.json's own PcieInfo section,
|
||||
// used below to enrich NVMe HddInfo entries (BMC does not populate vendor/device IDs
|
||||
// directly on HddInfo).
|
||||
pcieSlotVendorID := make(map[int]int, len(asset.PcieInfo))
|
||||
pcieSlotDeviceID := make(map[int]int, len(asset.PcieInfo))
|
||||
for _, pcie := range asset.PcieInfo {
|
||||
if pcie.PcieSlot <= 0 {
|
||||
continue
|
||||
}
|
||||
if pcie.VendorId > 0 {
|
||||
pcieSlotVendorID[pcie.PcieSlot] = pcie.VendorId
|
||||
}
|
||||
if pcie.DeviceId > 0 {
|
||||
pcieSlotDeviceID[pcie.PcieSlot] = pcie.DeviceId
|
||||
}
|
||||
}
|
||||
|
||||
// Parse storage info
|
||||
for _, hdd := range asset.HddInfo {
|
||||
slot := normalizeAssetHDDSlot(hdd.LocationString, hdd.Location, hdd.DiskInterfaceType)
|
||||
@@ -207,6 +224,8 @@ func ParseAssetJSON(content []byte, pcieSlotDeviceNames map[int]string, pcieSlot
|
||||
Slot: slot,
|
||||
Type: storageType,
|
||||
Model: modelName,
|
||||
VendorID: pcieSlotVendorID[hdd.PcieSlot],
|
||||
DeviceID: pcieSlotDeviceID[hdd.PcieSlot],
|
||||
SizeGB: hdd.Capacity,
|
||||
SerialNumber: serial,
|
||||
Manufacturer: manufacturer,
|
||||
|
||||
@@ -44,6 +44,45 @@ func TestParseAssetJSON_HddSlotFallbackAndPresence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAssetJSON_HddEnrichedWithPcieVendorDeviceID(t *testing.T) {
|
||||
content := []byte(`{
|
||||
"HddInfo": [
|
||||
{
|
||||
"PresentBitmap": [1],
|
||||
"SerialNumber": "NVME-SN-1",
|
||||
"Manufacturer": "",
|
||||
"ModelName": "",
|
||||
"FirmwareVersion": "",
|
||||
"Capacity": 7680,
|
||||
"Location": 1,
|
||||
"DiskInterfaceType": 5,
|
||||
"MediaType": 1,
|
||||
"LocationString": "OB01",
|
||||
"PcieSlot": 12
|
||||
}
|
||||
],
|
||||
"PcieInfo": [
|
||||
{
|
||||
"VendorId": 32902,
|
||||
"DeviceId": 62305,
|
||||
"PcieSlot": 12,
|
||||
"LocString": "OB01"
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
hw, err := ParseAssetJSON(content, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAssetJSON failed: %v", err)
|
||||
}
|
||||
if len(hw.Storage) != 1 {
|
||||
t.Fatalf("expected 1 storage entry, got %d", len(hw.Storage))
|
||||
}
|
||||
if hw.Storage[0].VendorID != 32902 || hw.Storage[0].DeviceID != 62305 {
|
||||
t.Fatalf("expected vendor_id/device_id enriched from PcieInfo, got vendor=%d device=%d", hw.Storage[0].VendorID, hw.Storage[0].DeviceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDiskBackplaneInfo_PopulatesOnlyMissingPresentDrives(t *testing.T) {
|
||||
text := `RESTful diskbackplane info:
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user