diff --git a/bible-local/10-decisions.md b/bible-local/10-decisions.md index 516e2db..6d519ef 100644 --- a/bible-local/10-decisions.md +++ b/bible-local/10-decisions.md @@ -1445,3 +1445,45 @@ in it — other vendor parsers have no comparable source for this data yet. - No other vendor parser populates `Hardware.Licenses` yet. If a future TSR/log source carries license data outside a Redfish walk (e.g. embedded in a vendor-specific XML/JSON file), it needs its own parsing — `collectLicenses()` only covers the generic Redfish `LicenseService` path. + +## ADL-053 — Added `hardware.storage[].vendor_id`/`device_id` (contract v2.13) + +**Date:** 2026-08-11 +**Context:** Contract v2.13 added optional `vendor_id`/`device_id` (PCI Vendor ID / Device ID, decimal) +to `hardware.storage[]`, modeled on the existing `pcie_devices[].vendor_id`/`device_id` fields, for +NVMe drives whose numeric PCI IDs are known. Reanimator uses these to merge differently-worded model +strings for the same physical device into one registry entry, same as it already does for +`pcie_devices`. `bible-local/docs/hardware-ingest-contract.md` refreshed 2.12 → 2.13 from +`reanimator/core/bible-local/docs/hardware-ingest-contract.md`. +**Decision:** +- `models.Storage.VendorID`/`DeviceID` (int, `vendor_id`/`device_id,omitempty`) added + (`internal/models/models.go`), mirroring `models.PCIeDevice`/`models.HardwareDevice`. +- `exporter.ReanimatorStorage.VendorID`/`DeviceID` added; wired through both `convertStorage` (legacy + direct path) and `convertStorageFromDevices` (canonical-devices path, the one actually used by + `ConvertToReanimator`). The canonical-devices path required a second fix: + `canonicalDevicesForExport`'s `hw.Storage` → `HardwareDevice` conversion loop + (`reanimator_converter.go`, storage `appendDevice` call) wasn't copying `VendorID`/`DeviceID` onto the + `HardwareDevice` either — both hops needed the field, same failure shape as ADL-051's `Present` bug. +- Populated at collection time in two places that actually have PCI IDs for storage today: + - **Redfish collector** (`internal/collector/redfish.go` live path and + `internal/collector/redfish_replay_storage.go` snapshot-replay path, the one Dell iDRAC10 TSR + walks go through): `parseDriveWithSupplementalDocs` now reads `VendorId`/`DeviceId` off the drive + doc itself, then off any doc in `supplementalDocs` — every `collectStorage`/`collectStorage` + (replay) call site was updated to append the drive's linked `PCIeFunctions` doc(s) + (`getLinkedPCIeFunctions`) into `supplementalDocs`, same helper already used for GPU/NIC/PCIeDevice. + Reuses the existing generic `Links.PCIeFunctions` mechanism — no Drive-specific fetch added. + - **Inspur** (`internal/parser/vendors/inspur/asset.go`, `ParseAssetJSON`): `asset.json`'s own + `PcieInfo[]` array already carries `VendorId`/`DeviceId`/`PcieSlot` per entry; built a + `PcieSlot → (VendorID, DeviceID)` map from it and look it up by `hdd.PcieSlot` when building each + `Storage` entry — no new data source, same join key (`PcieSlot`) already used to enrich NVMe model + name/serial from `devicefrusdr.log`/`audit.log`. +- Other vendor parsers (Dell WSMAN/DCIM-XML path, h3c, lenovo_xcc, xigmanas) have no numeric PCI ID + source for storage today and were left unchanged. Unraid (`lspci -nn` output) and xfusion (`PCIe Card + Info` table) have numeric IDs in a sibling structure but no reliable join key back to the specific + storage entry yet — deferred, would need a new BDF↔block-device correlation mechanism. +**Consequences:** +- `go build ./...` and `go test ./...` clean. Added coverage: `TestParseComponentDetails_UseLinkedSupplementalMetrics` + (redfish_test.go, linked-PCIeFunction vendor/device ID extraction), + `TestParseAssetJSON_HddEnrichedWithPcieVendorDeviceID` (inspur), and + `TestConvertToReanimator_StorageVendorDeviceIDSurvivesRoundTrip` (exporter, full export→marshal→ + reimport→re-export round trip). diff --git a/bible-local/docs/hardware-ingest-contract.md b/bible-local/docs/hardware-ingest-contract.md index 7aefc7d..ceae3f3 100644 --- a/bible-local/docs/hardware-ingest-contract.md +++ b/bible-local/docs/hardware-ingest-contract.md @@ -1,6 +1,6 @@ --- title: Hardware Ingest JSON Contract -version: "2.12" +version: "2.13" updated: "2026-08-11" maintainer: Reanimator Core audience: external-integrators, ai-agents @@ -9,7 +9,7 @@ language: ru # Интеграция с Reanimator: контракт JSON-импорта аппаратного обеспечения -Версия: **2.12** · Дата: **2026-08-11** +Версия: **2.13** · Дата: **2026-08-11** Документ описывает формат JSON для передачи данных об аппаратном обеспечении серверов в систему **Reanimator** (управление жизненным циклом аппаратного обеспечения). Предназначен для разработчиков смежных систем (Redfish-коллекторов, агентов мониторинга, CMDB-экспортёров) и может быть включён в документацию интегрируемых проектов. @@ -22,6 +22,7 @@ language: ru | Версия | Дата | Изменения | |--------|------|-----------| +| 2.13 | 2026-08-11 | Для `hardware.storage[]` добавлены необязательные поля `vendor_id`/`device_id` (PCI Vendor ID / Device ID, decimal) — по образцу `pcie_devices`; передавайте их для NVMe-накопителей, если аудит-софт их знает. `vendor_id`/`device_id` (у `storage` и `pcie_devices`) теперь явно приоритетны при разрешении канонического вендора/модели устройства (объединение разных текстовых названий одного и того же устройства в одну запись реестра) и участвуют в генерации `vendor_serial`, если у `storage` нет `serial_number`, но известен `slot` | | 2.12 | 2026-08-11 | Добавлена необязательная секция `hardware.licenses[]` для лицензий на ПО/прошивку и связанного софтверного функционала (feature-on-demand, vGPU/iDRAC/iLO-style advanced-лицензии и т.п.). Лицензия может быть системной (без `component_ref`) либо привязанной к конкретному компоненту (`component_ref`). Секция использует те же общие поля статуса/истории, что и остальные компонентные секции | | 2.11 | 2026-06-19 | В `pcie_devices[]` добавлен необязательный массив `sfp_modules[]` с идентификацией и DOM telemetry SFP/QSFP-модулей. Скалярные поля `sfp_temperature_c` / `sfp_tx_power_dbm` / `sfp_rx_power_dbm` / `sfp_voltage_v` / `sfp_bias_ma` помечены как deprecated (принимаются, но `sfp_modules[]` имеет приоритет) | | 2.10 | 2026-04-29 | Для `hardware.storage[]` добавлены необязательные числовые поля `logical_block_size_bytes`, `physical_block_size_bytes`, `metadata_bytes_per_block` для нормализованного описания формата блока накопителя | @@ -369,6 +370,8 @@ GET /ingest/hardware/jobs/{job_id} |------|-----|-------------|----------| | `slot` | string | нет | Канонический адрес установки PCIe-устройства; передавайте BDF (`0000:18:00.0`) | | `serial_number` | string | нет | Серийный номер | +| `vendor_id` | int | нет | PCI Vendor ID (decimal) — для NVMe-накопителей, если известен | +| `device_id` | int | нет | PCI Device ID (decimal) — для NVMe-накопителей, если известен | | `model` | string | нет | Модель | | `manufacturer` | string | нет | Производитель | | `type` | string | нет | Тип: `NVMe`, `SSD`, `HDD` | @@ -395,7 +398,9 @@ GET /ingest/hardware/jobs/{job_id} | `present` | bool | нет | Наличие (по умолчанию `true`) | | + общие поля статуса | | | см. раздел выше | -Диск без `serial_number` игнорируется. Изменение `firmware` создаёт событие `FIRMWARE_CHANGED`. +Диск без `serial_number` и без `slot` игнорируется. Если `serial_number` отсутствует, но известен `slot` (BDF NVMe-устройства), `vendor_serial` генерируется по тому же принципу, что и для `pcie_devices` (см. «Обработка отсутствующих serial_number» ниже). Изменение `firmware` создаёт событие `FIRMWARE_CHANGED`. + +`vendor_id`/`device_id` используются в первую очередь при разрешении канонического вендора/модели устройства (объединяют разные текстовые названия одного и того же устройства, которые могли передать разные версии/источники сборщика, в одну запись реестра) — так же, как и для `pcie_devices`. Это не замена `serial_number` как идентификатора конкретного физического экземпляра: `vendor_id`/`device_id` одинаковы для всех накопителей одной модели. Формат вида `512+8` в контракт не добавляется отдельным строковым полем. Если источник знает такую форму, он должен передавать её как: - `logical_block_size_bytes = 512` @@ -407,6 +412,8 @@ GET /ingest/hardware/jobs/{job_id} { "slot": "OB01", "type": "NVMe", + "vendor_id": 32902, + "device_id": 62305, "model": "INTEL SSDPF2KX076T1", "size_gb": 7680, "logical_block_size_bytes": 512, @@ -472,6 +479,8 @@ GET /ingest/hardware/jobs/{job_id} `numa_node` передавайте для NIC / InfiniBand / RAID / GPU, когда источник знает CPU/NUMA affinity. Поле сохраняется в snapshot-атрибутах PCIe-компонента и дублируется в telemetry для topology use cases. Поля `temperature_c` и `power_w` используйте для device-level telemetry GPU / accelerator / smart PCIe devices. Они не влияют на идентификацию компонента. +`vendor_id`/`device_id` используются в первую очередь при разрешении канонического вендора/модели устройства (см. аналогичное примечание в разделе `storage`). + **Deprecated поля sfp_\*:** Скалярные поля `sfp_temperature_c`, `sfp_tx_power_dbm`, `sfp_rx_power_dbm`, `sfp_voltage_v`, `sfp_bias_ma` продолжают приниматься, но помечены как deprecated since 2.11. Если в payload одновременно присутствуют `sfp_modules[]` и deprecated sfp_-скаляры — приоритет у `sfp_modules[]`, скаляры игнорируются. Deprecated поля будут удалены в версии 3.0. **Генерация serial_number при отсутствии или `"N/A"`:** `{board_serial}-PCIE-{slot}`, где `slot` для PCIe равен BDF. @@ -892,8 +901,8 @@ PSU без `serial_number` игнорируется. |-----|-----------| | CPU | Генерируется: `{board_serial}-CPU-{socket}` | | PCIe | Генерируется: `{board_serial}-PCIE-{slot}` (если serial = `"N/A"` или пустой; `slot` для PCIe = BDF) | +| Storage | Если известен `slot` (BDF NVMe): генерируется `{board_serial}-STORAGE-{slot}`. Если `slot` не известен — компонент игнорируется | | Memory | Компонент игнорируется | -| Storage | Компонент игнорируется | | PSU | Компонент игнорируется | Если `serial_number` не уникален внутри одного payload для того же `model`: diff --git a/internal/collector/redfish.go b/internal/collector/redfish.go index 8f7b497..afbc1d1 100644 --- a/internal/collector/redfish.go +++ b/internal/collector/redfish.go @@ -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"]), diff --git a/internal/collector/redfish_replay_storage.go b/internal/collector/redfish_replay_storage.go index 2ee27c3..b744915 100644 --- a/internal/collector/redfish_replay_storage.go +++ b/internal/collector/redfish_replay_storage.go @@ -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...)) } } diff --git a/internal/collector/redfish_test.go b/internal/collector/redfish_test.go index 0f0e6c1..7c72ece 100644 --- a/internal/collector/redfish_test.go +++ b/internal/collector/redfish_test.go @@ -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", diff --git a/internal/exporter/reanimator_converter.go b/internal/exporter/reanimator_converter.go index c36d393..0c5ae0d 100644 --- a/internal/exporter/reanimator_converter.go +++ b/internal/exporter/reanimator_converter.go @@ -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, diff --git a/internal/exporter/reanimator_converter_test.go b/internal/exporter/reanimator_converter_test.go index afb24d8..8dbfc2a 100644 --- a/internal/exporter/reanimator_converter_test.go +++ b/internal/exporter/reanimator_converter_test.go @@ -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. diff --git a/internal/exporter/reanimator_models.go b/internal/exporter/reanimator_models.go index b5d12d4..230286f 100644 --- a/internal/exporter/reanimator_models.go +++ b/internal/exporter/reanimator_models.go @@ -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"` diff --git a/internal/models/models.go b/internal/models/models.go index cb6a8d6..66efe3d 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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"` diff --git a/internal/parser/vendors/inspur/asset.go b/internal/parser/vendors/inspur/asset.go index 06a019f..431ff4d 100644 --- a/internal/parser/vendors/inspur/asset.go +++ b/internal/parser/vendors/inspur/asset.go @@ -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, diff --git a/internal/parser/vendors/inspur/storage_serial_fallback_test.go b/internal/parser/vendors/inspur/storage_serial_fallback_test.go index 40f9fa6..e3cc976 100644 --- a/internal/parser/vendors/inspur/storage_serial_fallback_test.go +++ b/internal/parser/vendors/inspur/storage_serial_fallback_test.go @@ -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: [