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
@@ -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
|
- 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
|
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.
|
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).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Hardware Ingest JSON Contract
|
title: Hardware Ingest JSON Contract
|
||||||
version: "2.12"
|
version: "2.13"
|
||||||
updated: "2026-08-11"
|
updated: "2026-08-11"
|
||||||
maintainer: Reanimator Core
|
maintainer: Reanimator Core
|
||||||
audience: external-integrators, ai-agents
|
audience: external-integrators, ai-agents
|
||||||
@@ -9,7 +9,7 @@ language: ru
|
|||||||
|
|
||||||
# Интеграция с Reanimator: контракт JSON-импорта аппаратного обеспечения
|
# Интеграция с Reanimator: контракт JSON-импорта аппаратного обеспечения
|
||||||
|
|
||||||
Версия: **2.12** · Дата: **2026-08-11**
|
Версия: **2.13** · Дата: **2026-08-11**
|
||||||
|
|
||||||
Документ описывает формат JSON для передачи данных об аппаратном обеспечении серверов в систему **Reanimator** (управление жизненным циклом аппаратного обеспечения).
|
Документ описывает формат JSON для передачи данных об аппаратном обеспечении серверов в систему **Reanimator** (управление жизненным циклом аппаратного обеспечения).
|
||||||
Предназначен для разработчиков смежных систем (Redfish-коллекторов, агентов мониторинга, CMDB-экспортёров) и может быть включён в документацию интегрируемых проектов.
|
Предназначен для разработчиков смежных систем (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.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.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` для нормализованного описания формата блока накопителя |
|
| 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`) |
|
| `slot` | string | нет | Канонический адрес установки PCIe-устройства; передавайте BDF (`0000:18:00.0`) |
|
||||||
| `serial_number` | string | нет | Серийный номер |
|
| `serial_number` | string | нет | Серийный номер |
|
||||||
|
| `vendor_id` | int | нет | PCI Vendor ID (decimal) — для NVMe-накопителей, если известен |
|
||||||
|
| `device_id` | int | нет | PCI Device ID (decimal) — для NVMe-накопителей, если известен |
|
||||||
| `model` | string | нет | Модель |
|
| `model` | string | нет | Модель |
|
||||||
| `manufacturer` | string | нет | Производитель |
|
| `manufacturer` | string | нет | Производитель |
|
||||||
| `type` | string | нет | Тип: `NVMe`, `SSD`, `HDD` |
|
| `type` | string | нет | Тип: `NVMe`, `SSD`, `HDD` |
|
||||||
@@ -395,7 +398,9 @@ GET /ingest/hardware/jobs/{job_id}
|
|||||||
| `present` | bool | нет | Наличие (по умолчанию `true`) |
|
| `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` в контракт не добавляется отдельным строковым полем. Если источник знает такую форму, он должен передавать её как:
|
Формат вида `512+8` в контракт не добавляется отдельным строковым полем. Если источник знает такую форму, он должен передавать её как:
|
||||||
- `logical_block_size_bytes = 512`
|
- `logical_block_size_bytes = 512`
|
||||||
@@ -407,6 +412,8 @@ GET /ingest/hardware/jobs/{job_id}
|
|||||||
{
|
{
|
||||||
"slot": "OB01",
|
"slot": "OB01",
|
||||||
"type": "NVMe",
|
"type": "NVMe",
|
||||||
|
"vendor_id": 32902,
|
||||||
|
"device_id": 62305,
|
||||||
"model": "INTEL SSDPF2KX076T1",
|
"model": "INTEL SSDPF2KX076T1",
|
||||||
"size_gb": 7680,
|
"size_gb": 7680,
|
||||||
"logical_block_size_bytes": 512,
|
"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.
|
`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. Они не влияют на идентификацию компонента.
|
Поля `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.
|
**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.
|
**Генерация serial_number при отсутствии или `"N/A"`:** `{board_serial}-PCIE-{slot}`, где `slot` для PCIe равен BDF.
|
||||||
@@ -892,8 +901,8 @@ PSU без `serial_number` игнорируется.
|
|||||||
|-----|-----------|
|
|-----|-----------|
|
||||||
| CPU | Генерируется: `{board_serial}-CPU-{socket}` |
|
| CPU | Генерируется: `{board_serial}-CPU-{socket}` |
|
||||||
| PCIe | Генерируется: `{board_serial}-PCIE-{slot}` (если serial = `"N/A"` или пустой; `slot` для PCIe = BDF) |
|
| PCIe | Генерируется: `{board_serial}-PCIE-{slot}` (если serial = `"N/A"` или пустой; `slot` для PCIe = BDF) |
|
||||||
|
| Storage | Если известен `slot` (BDF NVMe): генерируется `{board_serial}-STORAGE-{slot}`. Если `slot` не известен — компонент игнорируется |
|
||||||
| Memory | Компонент игнорируется |
|
| Memory | Компонент игнорируется |
|
||||||
| Storage | Компонент игнорируется |
|
|
||||||
| PSU | Компонент игнорируется |
|
| PSU | Компонент игнорируется |
|
||||||
|
|
||||||
Если `serial_number` не уникален внутри одного payload для того же `model`:
|
Если `serial_number` не уникален внутри одного payload для того же `model`:
|
||||||
|
|||||||
@@ -813,6 +813,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
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...))
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
if len(driveDocs) == 0 {
|
if len(driveDocs) == 0 {
|
||||||
@@ -821,6 +822,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
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...))
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -846,6 +848,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
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...))
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -857,6 +860,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
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...))
|
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,6 +872,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
for _, driveDoc := range driveDocs {
|
for _, driveDoc := range driveDocs {
|
||||||
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
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...))
|
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) {
|
if looksLikeDrive(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
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...))
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -922,6 +928,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
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...))
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -934,6 +941,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
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...))
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3495,11 +3503,27 @@ func parseDriveWithSupplementalDocs(doc map[string]interface{}, supplementalDocs
|
|||||||
|
|
||||||
storageType := classifyStorageType(doc)
|
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"])))
|
slot := normalizeRAIDDriveSlot(firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])))
|
||||||
return models.Storage{
|
return models.Storage{
|
||||||
Slot: slot,
|
Slot: slot,
|
||||||
Type: storageType,
|
Type: storageType,
|
||||||
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
|
Model: firstNonEmpty(asString(doc["Model"]), asString(doc["Name"])),
|
||||||
|
VendorID: vendorID,
|
||||||
|
DeviceID: deviceID,
|
||||||
SizeGB: sizeGB,
|
SizeGB: sizeGB,
|
||||||
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
|
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
|
||||||
Manufacturer: asString(doc["Manufacturer"]),
|
Manufacturer: asString(doc["Manufacturer"]),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
|||||||
for _, driveDoc := range driveDocs {
|
for _, driveDoc := range driveDocs {
|
||||||
if !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
if !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
|
supplementalDocs = append(supplementalDocs, r.getLinkedPCIeFunctions(driveDoc)...)
|
||||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,6 +26,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
|
supplementalDocs = append(supplementalDocs, r.getLinkedPCIeFunctions(driveDoc)...)
|
||||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,6 +50,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
|||||||
}
|
}
|
||||||
if !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
if !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
|
supplementalDocs = append(supplementalDocs, r.getLinkedPCIeFunctions(driveDoc)...)
|
||||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,6 +61,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
supplementalDocs := r.getLinkedSupplementalDocs(member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
supplementalDocs := r.getLinkedSupplementalDocs(member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
|
supplementalDocs = append(supplementalDocs, r.getLinkedPCIeFunctions(member)...)
|
||||||
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
|
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +72,7 @@ func (r redfishSnapshotReader) collectStorage(systemPath string, plan redfishpro
|
|||||||
for _, driveDoc := range driveDocs {
|
for _, driveDoc := range driveDocs {
|
||||||
if looksLikeDrive(driveDoc) && !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
if looksLikeDrive(driveDoc) && !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
|
supplementalDocs = append(supplementalDocs, r.getLinkedPCIeFunctions(driveDoc)...)
|
||||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
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) {
|
for _, driveDoc := range r.collectKnownStorageMembers(systemPath, plan.KnownStorageDriveCollections) {
|
||||||
if looksLikeDrive(driveDoc) && !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
if looksLikeDrive(driveDoc) && !isAbsentDriveDoc(driveDoc) && !isVirtualStorageDrive(driveDoc) {
|
||||||
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
|
||||||
|
supplementalDocs = append(supplementalDocs, r.getLinkedPCIeFunctions(driveDoc)...)
|
||||||
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
|
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)
|
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(
|
psu := parsePSUWithSupplementalDocs(
|
||||||
map[string]interface{}{
|
map[string]interface{}{
|
||||||
"MemberId": "PSU0",
|
"MemberId": "PSU0",
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ func buildDevicesFromLegacy(hw *models.HardwareConfig) []models.HardwareDevice {
|
|||||||
Kind: models.DeviceKindStorage,
|
Kind: models.DeviceKindStorage,
|
||||||
Slot: stor.Slot,
|
Slot: stor.Slot,
|
||||||
Model: stor.Model,
|
Model: stor.Model,
|
||||||
|
VendorID: stor.VendorID,
|
||||||
|
DeviceID: stor.DeviceID,
|
||||||
Manufacturer: stor.Manufacturer,
|
Manufacturer: stor.Manufacturer,
|
||||||
RemainingEndurancePct: stor.RemainingEndurancePct,
|
RemainingEndurancePct: stor.RemainingEndurancePct,
|
||||||
SerialNumber: stor.SerialNumber,
|
SerialNumber: stor.SerialNumber,
|
||||||
@@ -771,6 +773,8 @@ func convertStorageFromDevices(devices []models.HardwareDevice, collectedAt stri
|
|||||||
Slot: d.Slot,
|
Slot: d.Slot,
|
||||||
Type: d.Type,
|
Type: d.Type,
|
||||||
Model: d.Model,
|
Model: d.Model,
|
||||||
|
VendorID: d.VendorID,
|
||||||
|
DeviceID: d.DeviceID,
|
||||||
SizeGB: d.SizeGB,
|
SizeGB: d.SizeGB,
|
||||||
SerialNumber: d.SerialNumber,
|
SerialNumber: d.SerialNumber,
|
||||||
Manufacturer: d.Manufacturer,
|
Manufacturer: d.Manufacturer,
|
||||||
@@ -1452,6 +1456,8 @@ func convertStorage(storage []models.Storage, collectedAt string) []ReanimatorSt
|
|||||||
Slot: stor.Slot,
|
Slot: stor.Slot,
|
||||||
Type: stor.Type,
|
Type: stor.Type,
|
||||||
Model: stor.Model,
|
Model: stor.Model,
|
||||||
|
VendorID: stor.VendorID,
|
||||||
|
DeviceID: stor.DeviceID,
|
||||||
SizeGB: stor.SizeGB,
|
SizeGB: stor.SizeGB,
|
||||||
SerialNumber: stor.SerialNumber,
|
SerialNumber: stor.SerialNumber,
|
||||||
Manufacturer: stor.Manufacturer,
|
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
|
// TestConvertToReanimator_ExportsLicenses covers the hardware.licenses contract
|
||||||
// section (v2.12): system-level licenses (no component_ref) and component-scoped
|
// section (v2.12): system-level licenses (no component_ref) and component-scoped
|
||||||
// licenses, skipping records without a name and records the source marked absent.
|
// licenses, skipping records without a name and records the source marked absent.
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ type ReanimatorStorage struct {
|
|||||||
Slot string `json:"slot"`
|
Slot string `json:"slot"`
|
||||||
Type string `json:"type,omitempty"`
|
Type string `json:"type,omitempty"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
|
VendorID int `json:"vendor_id,omitempty"`
|
||||||
|
DeviceID int `json:"device_id,omitempty"`
|
||||||
SizeGB int `json:"size_gb,omitempty"`
|
SizeGB int `json:"size_gb,omitempty"`
|
||||||
SerialNumber string `json:"serial_number"`
|
SerialNumber string `json:"serial_number"`
|
||||||
Manufacturer string `json:"manufacturer,omitempty"`
|
Manufacturer string `json:"manufacturer,omitempty"`
|
||||||
|
|||||||
@@ -275,6 +275,8 @@ type Storage struct {
|
|||||||
Present bool `json:"present"`
|
Present bool `json:"present"`
|
||||||
Location string `json:"location,omitempty"` // Front/Rear
|
Location string `json:"location,omitempty"` // Front/Rear
|
||||||
BackplaneID int `json:"backplane_id,omitempty"`
|
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
|
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"` // 0-100 %; nil = not reported
|
||||||
LogicalBlockSizeBytes int64 `json:"logical_block_size_bytes,omitempty"`
|
LogicalBlockSizeBytes int64 `json:"logical_block_size_bytes,omitempty"`
|
||||||
PhysicalBlockSizeBytes int64 `json:"physical_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
|
// Parse storage info
|
||||||
for _, hdd := range asset.HddInfo {
|
for _, hdd := range asset.HddInfo {
|
||||||
slot := normalizeAssetHDDSlot(hdd.LocationString, hdd.Location, hdd.DiskInterfaceType)
|
slot := normalizeAssetHDDSlot(hdd.LocationString, hdd.Location, hdd.DiskInterfaceType)
|
||||||
@@ -207,6 +224,8 @@ func ParseAssetJSON(content []byte, pcieSlotDeviceNames map[int]string, pcieSlot
|
|||||||
Slot: slot,
|
Slot: slot,
|
||||||
Type: storageType,
|
Type: storageType,
|
||||||
Model: modelName,
|
Model: modelName,
|
||||||
|
VendorID: pcieSlotVendorID[hdd.PcieSlot],
|
||||||
|
DeviceID: pcieSlotDeviceID[hdd.PcieSlot],
|
||||||
SizeGB: hdd.Capacity,
|
SizeGB: hdd.Capacity,
|
||||||
SerialNumber: serial,
|
SerialNumber: serial,
|
||||||
Manufacturer: manufacturer,
|
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) {
|
func TestParseDiskBackplaneInfo_PopulatesOnlyMissingPresentDrives(t *testing.T) {
|
||||||
text := `RESTful diskbackplane info:
|
text := `RESTful diskbackplane info:
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user