export: align reanimator and enrich redfish metrics

This commit is contained in:
Mikhail Chusavitin
2026-03-15 21:38:28 +03:00
parent 0acdc2b202
commit 9007f1b360
17 changed files with 3756 additions and 650 deletions

View File

@@ -30,25 +30,41 @@ Implementation files:
- `internal/exporter/reanimator_models.go`
- `internal/exporter/reanimator_converter.go`
- `internal/server/handlers.go`
- `bible-local/docs/hardware-ingest-contract.md`
Conversion rules:
- canonical source is `hardware.devices`
- canonical source is merged canonical inventory derived from `hardware.devices` plus legacy hardware slices
- output must conform to the strict Reanimator ingest contract in `docs/hardware-ingest-contract.md`
- timestamps are RFC3339
- status is normalized to Reanimator-friendly values
- missing PCIe serials may be generated from board serial + slot
- missing CPU serials may be generated from board serial + socket
- CPU `firmware` field means CPU microcode, not generic processor firmware inventory
- `NULL`-style board manufacturer/product values are treated as absent
- optional component telemetry/health fields are exported when LOGPile already has the data
- partial `hardware.devices` must not suppress components still present only in legacy parser/collector fields
- `present` is not serialized for exported components; presence is expressed by the existence of the component record itself
## Inclusion rules
Included:
- empty memory slots (`present=false`) for topology visibility
- PCIe-class devices even when serial must be synthesized
- contract `v2.4` component telemetry and health fields when source data exists
- hardware sensors grouped into `fans`, `power`, `temperatures`, `other`
- Redfish linked metric docs that carry component telemetry: `ProcessorMetrics`, `MemoryMetrics`, `DriveMetrics`, `EnvironmentMetrics`, `Metrics`
Excluded:
- memory with missing serial number
- memory with `present=false` or `status=Empty`
- CPUs with `present=false`
- storage without `serial_number`
- storage with `present=false`
- power supplies without `serial_number`
- power supplies with `present=false`
- non-present network adapters
- non-present PCIe / GPU devices
- device-bound firmware duplicated at top-level firmware list
- any field not present in the strict ingest contract
## Batch convert
@@ -61,3 +77,8 @@ Behavior:
- each file is parsed independently
- one bad file must not fail the whole batch if at least one conversion succeeds
- result artifact is temporary and deleted after download
## CSV export
`GET /api/export/csv` uses the same merged canonical inventory as Reanimator export,
with legacy network-card fallback kept only for records that still have no canonical device match.

View File

@@ -299,4 +299,159 @@ not the firmware inventory ID. The patterns were dead code from the moment they
correct prefix+digit checks (`"gpu" + digit`, `"nic" + digit`) and explicit string checks
(`"nvmecontroller"`, `"power supply"`, `"software inventory"`).
## ADL-020 — Dell TSR device-bound firmware filtered via FQDD; InfiniBand routed to NetworkAdapters
**Date:** 2026-03-15
**Context:** Dell TSR `sysinfo_DCIM_SoftwareIdentity.xml` lists firmware for every installed
component. `parseSoftwareIdentityXML` dumped all of these into `hardware.firmware` without
filtering, so device-bound entries such as `"Mellanox Network Adapter"` (FQDD `InfiniBand.Slot.1-1`)
and `"PERC H755 Front"` (FQDD `RAID.SL.3-1`) appeared in the reanimator export alongside system
firmware like BIOS and iDRAC. Confirmed on PowerEdge R6625 (8VS2LG4).
Additionally, `DCIM_InfiniBandView` was not handled in the parser switch, so Mellanox ConnectX-6
appeared only as a PCIe device with `model: "16x or x16"` (from `DataBusWidth` fallback).
`parseControllerView` called `addFirmware` with description `"storage controller"` instead of the
FQDD, so the FQDD-based filter in the exporter could not remove it.
**Decision:**
1. `isDeviceBoundFirmwareFQDD` extended with `"infiniband."` and `"fc."` prefixes; `"raid.backplane."`
broadened to `"raid."` to cover `RAID.SL.*`, `RAID.Integrated.*`, etc.
2. `DCIM_InfiniBandView` routed to `parseNICView` → device appears as `NetworkAdapter` with correct
firmware, MAC address, and VendorID/DeviceID.
3. `"InfiniBand."` added to `pcieFQDDNoisePrefix` to suppress the duplicate `DCIM_PCIDeviceView`
entry (DataBusWidth-only, no useful data).
4. `parseControllerView` now passes `fqdd` as the `addFirmware` description so the FQDD filter
removes the entry in the exporter.
5. `parsePCIeDeviceView` now prioritises `props["description"]` (chip model, e.g. `"MT28908 Family
[ConnectX-6]"`) over `props["devicedescription"]` (location string) for `pcie.Description`.
6. `convertPCIeDevices` model fallback order: `PartNumber → Description → DeviceClass`.
**Consequences:**
- `hardware.firmware` contains only system-level entries; NIC/RAID/storage-controller firmware
lives on the respective device record.
- `TestParseDellInfiniBandView` and `TestIsDeviceBoundFirmwareFQDD` guard the regression.
- Any future Dell TSR device class whose FQDD prefix is not yet in the prefix list may still leak;
extend `isDeviceBoundFirmwareFQDD` and add a test case when encountered.
---
## ADL-021 — pci.ids enrichment: chip model and vendor resolved from PCI IDs when source data is generic or missing
**Date:** 2026-03-15
**Context:**
Dell TSR `DCIM_InfiniBandView.ProductName` reports a generic marketing name ("Mellanox Network
Adapter") instead of the precise chip identifier ("MT28908 Family [ConnectX-6]"). The actual
chip model is available in `pci.ids` by VendorID:DeviceID (15B3:101B). Vendor name may also be
absent when no `VendorName` / `Manufacturer` property is present.
The general rule was established: *if model is not found in source data but PCI IDs are known,
resolve model from `pci.ids`*. This rule applies broadly across all export paths.
**Decision (two-layer enrichment):**
1. **Parser layer (Dell, `parseNICView`):** When `VendorID != 0 && DeviceID != 0`, prefer
`pciids.DeviceName(vendorID, deviceID)` over the product name from logs. This makes the chip
identifier the primary model for NIC/InfiniBand adapters (more specific than marketing name).
Fill `Vendor` from `pciids.VendorName(vendorID)` when the vendor field is otherwise empty.
Same fallback applied in `parsePCIeDeviceView` for empty `Description`.
2. **Exporter layer (`convertPCIeFromDevices`):** General rule — when `d.Model == ""` after all
legacy fallbacks and `VendorID != 0 && DeviceID != 0`, set `model = pciids.DeviceName(...)`.
Also fill empty `manufacturer` from `pciids.VendorName(...)`. This covers all parsers/sources.
**Consequences:**
- Mellanox InfiniBand slot now reports `model: "MT28908 Family [ConnectX-6]"` and
`manufacturer: "Mellanox Technologies"` in the reanimator export.
- For NICs where pci.ids has no entry, the original product name is kept (pci.ids returns "").
- `TestParseDellInfiniBandView` asserts the model and vendor from pci.ids.
---
## ADL-022 — CPUAffinity parsed into NUMANode for PCIe, NIC, and controller devices
**Date:** 2026-03-15
**Context:**
Dell TSR DCIM view classes report `CPUAffinity` for NIC, InfiniBand, PCIe, and controller
devices. Values are "1", "2" (NUMA node index), or "Not Applicable" (for devices that bridge
both CPUs or have no CPU affinity). This data is needed for topology-aware diagnostics.
**Decision:**
- Add `NUMANode int` (JSON: `"numa_node,omitempty"`) to `models.PCIeDevice`,
`models.NetworkAdapter`, `models.HardwareDevice`, and `ReanimatorPCIe`.
- Parse from `props["cpuaffinity"]` using `parseIntLoose`: numeric values ("1", "2") map
directly; "Not Applicable" returns 0 (omitted via `omitempty`).
- Thread through `buildDevicesFromLegacy` (PCIe and NIC sections) and `convertPCIeFromDevices`.
- `parseControllerView` also parses CPUAffinity since RAID controllers have NUMA affinity.
**Consequences:**
- `numa_node: 1` or `2` appears in reanimator export for devices with known affinity.
- Value 0 / absent means "not reported" — covers both "Not Applicable" and sources that don't
provide CPUAffinity at all.
- `TestParseDellCPUAffinity` verifies numeric values parsed correctly and "Not Applicable"→0.
---
## ADL-023 — Reanimator export must match ingest contract exactly
**Date:** 2026-03-15
**Context:**
LOGPile's Reanimator export had drifted from the strict ingest contract. It emitted fields that
Reanimator does not currently accept (`status_at_collection`, `numa_node`),
while missing fields and sections now present in the contract (`hardware.sensors`,
`pcie_devices[].mac_addresses`). Memory export rules also diverged from the ingest side: empty or
serial-less DIMMs were still exported.
**Decision:**
- Treat the Reanimator ingest contract as the authoritative schema for `GET /api/export/reanimator`.
- Emit only fields present in the current upstream contract revision.
- Add `hardware.sensors`, `pcie_devices[].mac_addresses`, `pcie_devices[].numa_node`, and
upstream-approved component telemetry/health fields.
- Leave out fields that are still not part of the upstream contract.
- Map internal `source_type=archive` to external `source_type=logfile`.
- Skip memory entries that are empty, not present, or missing serial numbers.
- Generate CPU and PCIe serials only in the forms allowed by the contract.
- Mirror the applied contract in `bible-local/docs/hardware-ingest-contract.md`.
**Consequences:**
- Some previously exported diagnostic fields are intentionally dropped from the Reanimator payload
until the upstream contract adds them.
- Internal models may retain richer fields than the current export schema.
- `hardware.devices` is canonical only after merge with legacy hardware slices; partial parser-owned
canonical records must not hide CPUs, memory, storage, NICs, or PSUs still stored in legacy
fields.
- CSV and Reanimator exports must use the same merged canonical inventory to avoid divergent export
contents across surfaces.
- Future exporter changes must update both the code and the mirrored contract document together.
---
## ADL-024 — Component presence is implicit; Redfish linked metrics are part of replay correctness
**Date:** 2026-03-15
**Context:**
The upstream ingest contract allows `present`, but current export semantics do not need to send
`present=true` for populated components. At the same time, several important Redfish component
telemetry fields were only available through linked metric resources such as `ProcessorMetrics`,
`MemoryMetrics`, and `DriveMetrics`. Without collecting and replaying these linked documents,
live collection and raw snapshot replay still underreported component health fields.
**Decision:**
- Do not serialize `present=true` in Reanimator export. Presence is represented by the presence of
the component record itself.
- Do not export component records marked `present=false`.
- Interpret CPU `firmware` in Reanimator payload as CPU microcode.
- Treat Redfish linked metric resources `ProcessorMetrics`, `MemoryMetrics`, `DriveMetrics`,
`EnvironmentMetrics`, and generic `Metrics` as part of analyzer correctness when they are linked
from component resources.
- Replay logic must merge these linked metric resources back into CPU, memory, storage, PCIe, GPU,
NIC, and PSU component `Details` the same way live collection expects them to be used.
**Consequences:**
- Reanimator payloads are smaller and avoid redundant `present=true` noise while still excluding
empty slots and absent components.
- Any future exporter change that reintroduces serialized component presence needs an explicit
contract review.
- Raw Redfish snapshot completeness now includes linked per-component metric resources, not only
top-level inventory members.
- CPU microcode is no longer expected in top-level `hardware.firmware`; it belongs on the CPU
component record.
<!-- Add new decisions below this line using the format above -->

View File

@@ -21,6 +21,7 @@ Keep top-level docs minimal and put maintained architecture/API contracts here.
| [05-collectors.md](05-collectors.md) | Live collection behavior |
| [06-parsers.md](06-parsers.md) | Archive parser framework and vendor coverage |
| [07-exporters.md](07-exporters.md) | Raw export, Reanimator export, batch convert |
| [docs/hardware-ingest-contract.md](docs/hardware-ingest-contract.md) | Reanimator ingest schema mirrored locally |
| [08-build-release.md](08-build-release.md) | Build and release workflow |
| [09-testing.md](09-testing.md) | Test expectations and regression rules |
| [10-decisions.md](10-decisions.md) | Architectural Decision Log |

View File

@@ -0,0 +1,330 @@
# Hardware Ingest Contract
Version: 2.4
Updated: 2026-03-15
Source: Reanimator Core `hardware-ingest-contract.md`
This file mirrors the external Reanimator hardware-ingest contract that LOGPile targets.
The Reanimator endpoint uses strict JSON decoding. Any field not listed here must not be emitted.
## Endpoint
```http
POST /ingest/hardware
Content-Type: application/json
```
The ingest request is asynchronous.
Accepted response:
```json
{
"status": "accepted",
"job_id": "job_01J..."
}
```
Final result is available from:
```http
GET /ingest/hardware/jobs/{job_id}
```
## Top-level payload
```json
{
"filename": "redfish://10.10.10.103",
"source_type": "api",
"protocol": "redfish",
"target_host": "10.10.10.103",
"collected_at": "2026-02-10T15:30:00Z",
"hardware": {
"board": {},
"firmware": [],
"cpus": [],
"memory": [],
"storage": [],
"pcie_devices": [],
"power_supplies": [],
"sensors": {}
}
}
```
## Top-level rules
- `collected_at` is required and must be RFC3339
- `hardware.board.serial_number` is required
- `source_type` allowed values: `api`, `logfile`, `manual`
- `protocol` allowed values: `redfish`, `ipmi`, `snmp`, `ssh`
- Unknown JSON keys are rejected by Reanimator
## Shared component status fields
Allowed on `cpus`, `memory`, `storage`, `pcie_devices`, `power_supplies`:
- `status`
- `status_checked_at`
- `status_changed_at`
- `status_history`
- `error_description`
`status_history[]` items:
- `status`
- `changed_at`
- `details`
Do not emit `status_at_collection`; it is not part of the current strict ingest schema.
## `hardware.board`
Allowed fields:
- `serial_number` required
- `manufacturer`
- `product_name`
- `part_number`
- `uuid`
String values equal to `"NULL"` should be omitted.
## `hardware.firmware`
Allowed fields:
- `device_name`
- `version`
Only system-level firmware belongs here.
Device-bound firmware must stay on the relevant device record and must not be duplicated at the top level.
## `hardware.cpus`
Allowed fields:
- `socket`
- `model`
- `manufacturer`
- `cores`
- `threads`
- `frequency_mhz`
- `max_frequency_mhz`
- `temperature_c`
- `power_w`
- `throttled`
- `correctable_error_count`
- `uncorrectable_error_count`
- `life_remaining_pct`
- `life_used_pct`
- `serial_number`
- `firmware`
- `present`
- shared status fields
Exporter rule:
- if CPU serial is missing, generate `{board_serial}-CPU-{socket}`
## `hardware.memory`
Allowed fields:
- `slot`
- `location`
- `present`
- `serial_number`
- `part_number`
- `manufacturer`
- `size_mb`
- `type`
- `max_speed_mhz`
- `current_speed_mhz`
- `temperature_c`
- `correctable_ecc_error_count`
- `uncorrectable_ecc_error_count`
- `life_remaining_pct`
- `life_used_pct`
- `spare_blocks_remaining_pct`
- `performance_degraded`
- `data_loss_detected`
- shared status fields
Exporter rules:
- skip memory items with missing `serial_number`
- skip memory items with `present=false`
- skip memory items with `status=Empty`
## `hardware.storage`
Allowed fields:
- `slot`
- `serial_number`
- `model`
- `manufacturer`
- `type`
- `interface`
- `size_gb`
- `temperature_c`
- `power_on_hours`
- `power_cycles`
- `unsafe_shutdowns`
- `media_errors`
- `error_log_entries`
- `written_bytes`
- `read_bytes`
- `life_used_pct`
- `firmware`
- `present`
- `remaining_endurance_pct`
- `life_remaining_pct`
- `available_spare_pct`
- `reallocated_sectors`
- `current_pending_sectors`
- `offline_uncorrectable`
- shared status fields
Exporter rule:
- skip storage items with missing `serial_number`
## `hardware.pcie_devices`
Allowed fields:
- `slot`
- `vendor_id`
- `device_id`
- `numa_node`
- `temperature_c`
- `power_w`
- `life_remaining_pct`
- `life_used_pct`
- `ecc_corrected_total`
- `ecc_uncorrected_total`
- `hw_slowdown`
- `battery_charge_pct`
- `battery_health_pct`
- `battery_temperature_c`
- `battery_voltage_v`
- `battery_replace_required`
- `sfp_temperature_c`
- `sfp_tx_power_dbm`
- `sfp_rx_power_dbm`
- `sfp_voltage_v`
- `sfp_bias_ma`
- `bdf`
- `device_class`
- `manufacturer`
- `model`
- `serial_number`
- `firmware`
- `link_width`
- `link_speed`
- `max_link_width`
- `max_link_speed`
- `mac_addresses`
- `present`
- shared status fields
Known `device_class` values:
- `MassStorageController`
- `StorageController`
- `NetworkController`
- `EthernetController`
- `FibreChannelController`
- `VideoController`
- `ProcessingAccelerator`
- `DisplayController`
Exporter rules:
- if PCIe serial is missing or placeholder-like, generate `{board_serial}-PCIE-{slot}`
- `numa_node` is allowed again in the upstream contract
- network adapters should emit `mac_addresses` when available
- do not emit fields outside the upstream contract
## `hardware.power_supplies`
Allowed fields:
- `slot`
- `present`
- `serial_number`
- `part_number`
- `model`
- `vendor`
- `wattage_w`
- `firmware`
- `input_type`
- `input_voltage`
- `input_power_w`
- `output_power_w`
- `temperature_c`
- `life_remaining_pct`
- `life_used_pct`
- shared status fields
Exporter rule:
- skip PSUs with missing `serial_number`
## `hardware.sensors`
Shape:
```json
{
"fans": [],
"power": [],
"temperatures": [],
"other": []
}
```
### `sensors.fans`
Allowed fields:
- `name` required
- `location`
- `rpm`
- `status`
### `sensors.power`
Allowed fields:
- `name` required
- `location`
- `voltage_v`
- `current_a`
- `power_w`
- `status`
### `sensors.temperatures`
Allowed fields:
- `name` required
- `location`
- `celsius`
- `threshold_warning_celsius`
- `threshold_critical_celsius`
- `status`
### `sensors.other`
Allowed fields:
- `name` required
- `location`
- `value`
- `unit`
- `status`
Sensor rules:
- dedupe within one payload by `(section, name)`, keeping the first item
- skip sensors without `name`
- the current LOGPile exporter maps generic `SensorReading` values into these four groups heuristically

View File

@@ -417,11 +417,13 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
driveDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, driveCollectionPath)
if err == nil {
for _, driveDoc := range driveDocs {
out = append(out, parseDrive(driveDoc))
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
if len(driveDocs) == 0 {
for _, driveDoc := range c.probeDirectDiskBayChildren(ctx, client, req, baseURL, driveCollectionPath) {
out = append(out, parseDrive(driveDoc))
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
}
@@ -442,14 +444,16 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
if err != nil {
continue
}
out = append(out, parseDrive(driveDoc))
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
continue
}
// Some implementations return drive fields right in storage member object.
if looksLikeDrive(member) {
out = append(out, parseDrive(member))
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
}
// Supermicro/RAID implementations can expose physical disks under chassis enclosures
@@ -459,7 +463,8 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
if err == nil {
for _, driveDoc := range driveDocs {
if looksLikeDrive(driveDoc) {
out = append(out, parseDrive(driveDoc))
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
if len(driveDocs) == 0 {
@@ -477,7 +482,8 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
"/Storage/IntelVROC/Controllers/1/Drives",
}) {
if looksLikeDrive(driveDoc) {
out = append(out, parseDrive(driveDoc))
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
@@ -493,7 +499,7 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
if !ok || !looksLikeDrive(devDoc) {
continue
}
out = append(out, parseDrive(devDoc))
out = append(out, parseDriveWithSupplementalDocs(devDoc))
}
}
@@ -508,7 +514,8 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
if !looksLikeDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
for _, chassisPath := range chassisPaths {
@@ -519,7 +526,8 @@ func (c *RedfishConnector) collectStorage(ctx context.Context, client *http.Clie
if !looksLikeDrive(driveDoc) {
continue
}
out = append(out, parseDrive(driveDoc))
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
@@ -575,7 +583,11 @@ func (c *RedfishConnector) collectNICs(ctx context.Context, client *http.Client,
continue
}
functionDocs := c.getLinkedPCIeFunctions(ctx, client, req, baseURL, pcieDoc)
enrichNICFromPCIe(&nic, pcieDoc, functionDocs)
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, pcieDoc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")...)
}
enrichNICFromPCIe(&nic, pcieDoc, functionDocs, supplementalDocs)
}
nics = append(nics, nic)
}
@@ -591,7 +603,8 @@ func (c *RedfishConnector) collectPSUs(ctx context.Context, client *http.Client,
// Redfish 2022+/X14+ commonly uses PowerSubsystem as the primary source.
if memberDocs, err := c.getCollectionMembers(ctx, client, req, baseURL, joinPath(chassisPath, "/PowerSubsystem/PowerSupplies")); err == nil && len(memberDocs) > 0 {
for _, doc := range memberDocs {
idx = appendPSU(&out, seen, parsePSU(doc, idx), idx)
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx)
}
continue
}
@@ -604,7 +617,8 @@ func (c *RedfishConnector) collectPSUs(ctx context.Context, client *http.Client,
if !ok {
continue
}
idx = appendPSU(&out, seen, parsePSU(doc, idx), idx)
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx)
}
}
}
@@ -654,6 +668,37 @@ func redfishLinkedPath(doc map[string]interface{}, key string) string {
return ""
}
func (c *RedfishConnector) getLinkedSupplementalDocs(
ctx context.Context,
client *http.Client,
req Request,
baseURL string,
doc map[string]interface{},
keys ...string,
) []map[string]interface{} {
if len(doc) == 0 || len(keys) == 0 {
return nil
}
var out []map[string]interface{}
seen := make(map[string]struct{})
for _, key := range keys {
path := normalizeRedfishPath(redfishLinkedPath(doc, key))
if path == "" {
continue
}
if _, ok := seen[path]; ok {
continue
}
supplementalDoc, err := c.getJSON(ctx, client, req, baseURL, path)
if err != nil || len(supplementalDoc) == 0 {
continue
}
seen[path] = struct{}{}
out = append(out, supplementalDoc)
}
return out
}
func (c *RedfishConnector) collectGPUs(ctx context.Context, client *http.Client, req Request, baseURL string, systemPaths, chassisPaths []string) []models.GPU {
collections := make([]string, 0, len(systemPaths)*3+len(chassisPaths)*2)
for _, systemPath := range systemPaths {
@@ -681,7 +726,11 @@ func (c *RedfishConnector) collectGPUs(ctx context.Context, client *http.Client,
continue
}
gpu := parseGPU(doc, functionDocs, idx)
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")...)
}
gpu := parseGPUWithSupplementalDocs(doc, functionDocs, supplementalDocs, idx)
idx++
if shouldSkipGenericGPUDuplicate(out, gpu) {
continue
@@ -723,7 +772,11 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
if looksLikeGPU(doc, functionDocs) {
continue
}
dev := parsePCIeDevice(doc, functionDocs)
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, doc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")...)
}
dev := parsePCIeDeviceWithSupplementalDocs(doc, functionDocs, supplementalDocs)
if isUnidentifiablePCIeDevice(dev) {
continue
}
@@ -738,7 +791,8 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
continue
}
for idx, fn := range functionDocs {
dev := parsePCIeFunction(fn, idx+1)
supplementalDocs := c.getLinkedSupplementalDocs(ctx, client, req, baseURL, fn, "EnvironmentMetrics", "Metrics")
dev := parsePCIeFunctionWithSupplementalDocs(fn, supplementalDocs, idx+1)
out = append(out, dev)
}
}
@@ -1333,7 +1387,7 @@ func shouldAdaptiveNVMeProbe(collectionDoc map[string]interface{}) bool {
// RoT and similar component chassis that expose an empty /Drives collection.
func chassisTypeCanHaveNVMe(chassisType string) bool {
switch strings.ToLower(strings.TrimSpace(chassisType)) {
case "module", // GPU SXM, NVLinkManagementNIC, PCIeRetimer
case "module", // GPU SXM, NVLinkManagementNIC, PCIeRetimer
"component", // ERoT, IRoT, BMC, FPGA sub-chassis
"zone": // HGX_Chassis_0 fabric zone
return false
@@ -1945,9 +1999,15 @@ func shouldCrawlPath(path string) bool {
if strings.Contains(normalized, "/Memory/") {
after := strings.SplitN(normalized, "/Memory/", 2)
if len(after) == 2 && strings.Count(after[1], "/") >= 1 {
// Keep direct DIMM resources (/Memory/<slot>) but skip nested subresources
// like /Memory/<slot>/Assembly and /Memory/<slot>/MemoryMetrics.
return false
// Keep direct DIMM resources and selected metrics subresources, but skip
// unrelated nested branches like Assembly.
return strings.HasSuffix(normalized, "/MemoryMetrics")
}
}
if strings.Contains(normalized, "/Processors/") {
after := strings.SplitN(normalized, "/Processors/", 2)
if len(after) == 2 && strings.Count(after[1], "/") >= 1 {
return strings.HasSuffix(normalized, "/ProcessorMetrics")
}
}
// Non-inventory top-level service branches.
@@ -2551,6 +2611,7 @@ func parseCPUs(docs []map[string]interface{}) []models.CPU {
L2CacheKB: l2,
L3CacheKB: l3,
Status: mapStatus(doc["Status"]),
Details: redfishCPUDetails(doc),
})
}
return cpus
@@ -2614,12 +2675,102 @@ func parseMemory(docs []map[string]interface{}) []models.MemoryDIMM {
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
PartNumber: asString(doc["PartNumber"]),
Status: mapStatus(doc["Status"]),
Details: redfishMemoryDetails(doc),
})
}
return out
}
func redfishCPUDetails(doc map[string]interface{}) map[string]any {
return redfishCPUDetailsAcrossDocs(doc)
}
func redfishCPUDetailsAcrossDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) map[string]any {
lookupDocs := append([]map[string]interface{}{doc}, supplementalDocs...)
details := make(map[string]any)
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature", "CurrentTemperature", "temperature",
))
addFloatDetail(details, "power_w", redfishFirstNumericAcrossDocs(lookupDocs,
"PowerConsumedWatts", "PowerWatts", "PowerConsumptionWatts",
))
addBoolDetail(details, "throttled", redfishFirstBoolAcrossDocs(lookupDocs,
"Throttled", "ThermalThrottled", "PerformanceThrottled",
))
addInt64Detail(details, "correctable_error_count", redfishFirstInt64AcrossDocs(lookupDocs,
"CorrectableErrorCount", "CorrectableErrors", "CorrectableECCErrorCount",
))
addInt64Detail(details, "uncorrectable_error_count", redfishFirstInt64AcrossDocs(lookupDocs,
"UncorrectableErrorCount", "UncorrectableErrors", "UncorrectableECCErrorCount",
))
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeRemainingPercent", "PredictedLifeLeftPercent",
))
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeUsedPercent", "PercentageLifeUsed",
))
for _, lookupDoc := range lookupDocs {
if microcode, ok := redfishLookupValue(lookupDoc, "MicrocodeVersion"); ok {
if s := strings.TrimSpace(asString(microcode)); s != "" {
details["microcode"] = s
break
}
}
if microcode, ok := redfishLookupValue(lookupDoc, "Microcode"); ok {
if s := strings.TrimSpace(asString(microcode)); s != "" {
details["microcode"] = s
break
}
}
}
if len(details) == 0 {
return nil
}
return details
}
func redfishMemoryDetails(doc map[string]interface{}) map[string]any {
return redfishMemoryDetailsAcrossDocs(doc)
}
func redfishMemoryDetailsAcrossDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) map[string]any {
lookupDocs := append([]map[string]interface{}{doc}, supplementalDocs...)
details := make(map[string]any)
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature", "CurrentTemperature", "temperature",
))
addInt64Detail(details, "correctable_ecc_error_count", redfishFirstInt64AcrossDocs(lookupDocs,
"CorrectableECCErrorCount", "CorrectableErrorCount", "CorrectableErrors",
))
addInt64Detail(details, "uncorrectable_ecc_error_count", redfishFirstInt64AcrossDocs(lookupDocs,
"UncorrectableECCErrorCount", "UncorrectableErrorCount", "UncorrectableErrors",
))
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeRemainingPercent", "PredictedLifeLeftPercent",
))
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeUsedPercent", "PercentageLifeUsed",
))
addFloatDetail(details, "spare_blocks_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"SpareBlocksRemainingPercent", "SpareBlocksRemainingPct",
))
addBoolDetail(details, "performance_degraded", redfishFirstBoolAcrossDocs(lookupDocs,
"PerformanceDegraded", "Degraded",
))
addBoolDetail(details, "data_loss_detected", redfishFirstBoolAcrossDocs(lookupDocs,
"DataLossDetected", "DataLoss",
))
if len(details) == 0 {
return nil
}
return details
}
func parseDrive(doc map[string]interface{}) models.Storage {
return parseDriveWithSupplementalDocs(doc)
}
func parseDriveWithSupplementalDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) models.Storage {
sizeGB := 0
if capBytes := asInt64(doc["CapacityBytes"]); capBytes > 0 {
sizeGB = int(capBytes / (1024 * 1024 * 1024))
@@ -2644,6 +2795,7 @@ func parseDrive(doc map[string]interface{}) models.Storage {
Firmware: asString(doc["Revision"]),
Interface: asString(doc["Protocol"]),
Present: true,
Details: redfishDriveDetailsWithSupplementalDocs(doc, supplementalDocs...),
}
}
@@ -2744,6 +2896,7 @@ func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
Slot: firstNonEmpty(asString(doc["Id"]), asString(doc["Name"])),
Location: location,
Present: !strings.EqualFold(mapStatus(doc["Status"]), "Absent"),
BDF: asString(doc["BDF"]),
Model: strings.TrimSpace(model),
Vendor: strings.TrimSpace(vendor),
VendorID: vendorID,
@@ -2753,6 +2906,7 @@ func parseNIC(doc map[string]interface{}) models.NetworkAdapter {
Firmware: firmware,
PortCount: portCount,
Status: mapStatus(doc["Status"]),
Details: redfishPCIeDetails(doc, nil),
}
}
@@ -2786,23 +2940,53 @@ func networkAdapterPCIeDevicePaths(doc map[string]interface{}) []string {
return out
}
func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{}, functionDocs []map[string]interface{}) {
func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) {
if nic == nil {
return
}
if strings.TrimSpace(nic.BDF) == "" {
nic.BDF = firstNonEmpty(asString(pcieDoc["BDF"]), buildBDFfromOemPublic(pcieDoc))
}
if nic.VendorID == 0 {
nic.VendorID = asHexOrInt(pcieDoc["VendorId"])
}
if nic.DeviceID == 0 {
nic.DeviceID = asHexOrInt(pcieDoc["DeviceId"])
}
if nic.LinkWidth == 0 {
nic.LinkWidth = asInt(pcieDoc["CurrentLinkWidth"])
}
if nic.MaxLinkWidth == 0 {
nic.MaxLinkWidth = asInt(pcieDoc["MaxLinkWidth"])
}
if strings.TrimSpace(nic.LinkSpeed) == "" {
nic.LinkSpeed = firstNonEmpty(asString(pcieDoc["CurrentLinkSpeedGTs"]), asString(pcieDoc["CurrentLinkSpeed"]))
}
if strings.TrimSpace(nic.MaxLinkSpeed) == "" {
nic.MaxLinkSpeed = firstNonEmpty(asString(pcieDoc["MaxLinkSpeedGTs"]), asString(pcieDoc["MaxLinkSpeed"]))
}
for _, fn := range functionDocs {
if strings.TrimSpace(nic.BDF) == "" {
nic.BDF = asString(fn["FunctionId"])
}
if nic.VendorID == 0 {
nic.VendorID = asHexOrInt(fn["VendorId"])
}
if nic.DeviceID == 0 {
nic.DeviceID = asHexOrInt(fn["DeviceId"])
}
if nic.LinkWidth == 0 {
nic.LinkWidth = asInt(fn["CurrentLinkWidth"])
}
if nic.MaxLinkWidth == 0 {
nic.MaxLinkWidth = asInt(fn["MaxLinkWidth"])
}
if strings.TrimSpace(nic.LinkSpeed) == "" {
nic.LinkSpeed = firstNonEmpty(asString(fn["CurrentLinkSpeedGTs"]), asString(fn["CurrentLinkSpeed"]))
}
if strings.TrimSpace(nic.MaxLinkSpeed) == "" {
nic.MaxLinkSpeed = firstNonEmpty(asString(fn["MaxLinkSpeedGTs"]), asString(fn["MaxLinkSpeed"]))
}
}
if strings.TrimSpace(nic.Vendor) == "" {
nic.Vendor = pciids.VendorName(nic.VendorID)
@@ -2812,9 +2996,14 @@ func enrichNICFromPCIe(nic *models.NetworkAdapter, pcieDoc map[string]interface{
nic.Model = resolved
}
}
nic.Details = mergeGenericDetails(nic.Details, redfishPCIeDetailsWithSupplementalDocs(pcieDoc, functionDocs, supplementalDocs))
}
func parsePSU(doc map[string]interface{}, idx int) models.PSU {
return parsePSUWithSupplementalDocs(doc, idx)
}
func parsePSUWithSupplementalDocs(doc map[string]interface{}, idx int, supplementalDocs ...map[string]interface{}) models.PSU {
status := mapStatus(doc["Status"])
present := true
if statusMap, ok := doc["Status"].(map[string]interface{}); ok {
@@ -2852,10 +3041,334 @@ func parsePSU(doc map[string]interface{}, idx int) models.PSU {
InputPowerW: asInt(doc["PowerInputWatts"]),
OutputPowerW: asInt(doc["LastPowerOutputWatts"]),
InputVoltage: asFloat(doc["LineInputVoltage"]),
Details: redfishPSUDetailsWithSupplementalDocs(doc, supplementalDocs...),
}
}
func redfishDriveDetails(doc map[string]interface{}) map[string]any {
return redfishDriveDetailsWithSupplementalDocs(doc)
}
func redfishDriveDetailsWithSupplementalDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) map[string]any {
details := make(map[string]any)
lookupDocs := append([]map[string]interface{}{doc}, supplementalDocs...)
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature", "CurrentTemperature", "temperature",
))
addInt64Detail(details, "power_on_hours", redfishFirstInt64AcrossDocs(lookupDocs,
"PowerOnHours", "PowerOnHour",
))
addInt64Detail(details, "power_cycles", redfishFirstInt64AcrossDocs(lookupDocs,
"PowerCycles", "PowerCycleCount",
))
addInt64Detail(details, "unsafe_shutdowns", redfishFirstInt64AcrossDocs(lookupDocs,
"UnsafeShutdowns", "UnsafeShutdownCount",
))
addInt64Detail(details, "media_errors", redfishFirstInt64AcrossDocs(lookupDocs,
"MediaErrors", "MediaErrorCount",
))
addInt64Detail(details, "error_log_entries", redfishFirstInt64AcrossDocs(lookupDocs,
"ErrorLogEntries", "ErrorLogEntryCount",
))
addInt64Detail(details, "written_bytes", redfishFirstInt64AcrossDocs(lookupDocs,
"WrittenBytes", "BytesWritten",
))
addInt64Detail(details, "read_bytes", redfishFirstInt64AcrossDocs(lookupDocs,
"ReadBytes", "BytesRead",
))
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"PredictedMediaLifeLeftPercent", "LifeRemainingPercent", "PercentageDriveLifeUsedInverse", "PercentLifeRemaining",
))
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"PercentageDriveLifeUsed", "LifeUsedPercent", "PercentageUsed", "PercentLifeUsed",
))
addFloatDetail(details, "available_spare_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"AvailableSparePercent", "AvailableSpare", "PercentAvailableSpare",
))
addInt64Detail(details, "reallocated_sectors", redfishFirstInt64AcrossDocs(lookupDocs,
"ReallocatedSectors", "ReallocatedSectorCount",
))
addInt64Detail(details, "current_pending_sectors", redfishFirstInt64AcrossDocs(lookupDocs,
"CurrentPendingSectors", "CurrentPendingSectorCount",
))
addInt64Detail(details, "offline_uncorrectable", redfishFirstInt64AcrossDocs(lookupDocs,
"OfflineUncorrectable", "OfflineUncorrectableSectorCount",
))
if len(details) == 0 {
return nil
}
return details
}
func redfishPSUDetails(doc map[string]interface{}) map[string]any {
return redfishPSUDetailsWithSupplementalDocs(doc)
}
func redfishPSUDetailsWithSupplementalDocs(doc map[string]interface{}, supplementalDocs ...map[string]interface{}) map[string]any {
details := make(map[string]any)
lookupDocs := append([]map[string]interface{}{doc}, supplementalDocs...)
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature",
))
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeRemainingPercent", "PredictedLifeLeftPercent",
))
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeUsedPercent", "PercentageLifeUsed",
))
if len(details) == 0 {
return nil
}
return details
}
func redfishPCIeDetails(doc map[string]interface{}, functionDocs []map[string]interface{}) map[string]any {
return redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, nil)
}
func redfishPCIeDetailsWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) map[string]any {
lookupDocs := make([]map[string]interface{}, 0, 1+len(functionDocs)+len(supplementalDocs))
lookupDocs = append(lookupDocs, doc)
lookupDocs = append(lookupDocs, functionDocs...)
lookupDocs = append(lookupDocs, supplementalDocs...)
details := make(map[string]any)
addFloatDetail(details, "temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"TemperatureCelsius", "TemperatureC", "Temperature",
))
addFloatDetail(details, "power_w", redfishFirstNumericAcrossDocs(lookupDocs,
"PowerConsumedWatts", "PowerWatts", "PowerConsumptionWatts",
))
addFloatDetail(details, "life_remaining_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeRemainingPercent", "PredictedLifeLeftPercent",
))
addFloatDetail(details, "life_used_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"LifeUsedPercent", "PercentageLifeUsed",
))
addInt64Detail(details, "ecc_corrected_total", redfishFirstInt64AcrossDocs(lookupDocs,
"ECCCorrectedTotal", "CorrectableECCErrorCount", "CorrectableErrorCount",
))
addInt64Detail(details, "ecc_uncorrected_total", redfishFirstInt64AcrossDocs(lookupDocs,
"ECCUncorrectedTotal", "UncorrectableECCErrorCount", "UncorrectableErrorCount",
))
addBoolDetail(details, "hw_slowdown", redfishFirstBoolAcrossDocs(lookupDocs,
"HWSlowdown", "HardwareSlowdown",
))
addFloatDetail(details, "battery_charge_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"BatteryChargePercent", "BatteryChargePct",
))
addFloatDetail(details, "battery_health_pct", redfishFirstNumericAcrossDocs(lookupDocs,
"BatteryHealthPercent", "BatteryHealthPct",
))
addFloatDetail(details, "battery_temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"BatteryTemperatureCelsius", "BatteryTemperatureC",
))
addFloatDetail(details, "battery_voltage_v", redfishFirstNumericAcrossDocs(lookupDocs,
"BatteryVoltage", "BatteryVoltageV",
))
addBoolDetail(details, "battery_replace_required", redfishFirstBoolAcrossDocs(lookupDocs,
"BatteryReplaceRequired", "ReplaceBattery",
))
addFloatDetail(details, "sfp_temperature_c", redfishFirstNumericAcrossDocs(lookupDocs,
"SFPTemperatureCelsius", "SFPTemperatureC", "TransceiverTemperatureCelsius",
))
addFloatDetail(details, "sfp_tx_power_dbm", redfishFirstNumericAcrossDocs(lookupDocs,
"SFPTXPowerDBm", "SFPTransmitPowerDBm", "TxPowerDBm",
))
addFloatDetail(details, "sfp_rx_power_dbm", redfishFirstNumericAcrossDocs(lookupDocs,
"SFPRXPowerDBm", "SFPReceivePowerDBm", "RxPowerDBm",
))
addFloatDetail(details, "sfp_voltage_v", redfishFirstNumericAcrossDocs(lookupDocs,
"SFPVoltageV", "TransceiverVoltageV",
))
addFloatDetail(details, "sfp_bias_ma", redfishFirstNumericAcrossDocs(lookupDocs,
"SFPBiasMA", "BiasCurrentMA", "LaserBiasCurrentMA",
))
if len(details) == 0 {
return nil
}
return details
}
func redfishFirstNumeric(doc map[string]interface{}, keys ...string) float64 {
for _, key := range keys {
if v, ok := redfishLookupValue(doc, key); ok {
if f := asFloat(v); f != 0 {
return f
}
}
}
return 0
}
func redfishFirstNumericAcrossDocs(docs []map[string]interface{}, keys ...string) float64 {
for _, doc := range docs {
if v := redfishFirstNumeric(doc, keys...); v != 0 {
return v
}
}
return 0
}
func redfishFirstNumericWithFunctions(doc map[string]interface{}, functionDocs []map[string]interface{}, keys ...string) float64 {
if v := redfishFirstNumeric(doc, keys...); v != 0 {
return v
}
for _, fn := range functionDocs {
if v := redfishFirstNumeric(fn, keys...); v != 0 {
return v
}
}
return 0
}
func redfishFirstInt64(doc map[string]interface{}, keys ...string) int64 {
for _, key := range keys {
if v, ok := redfishLookupValue(doc, key); ok {
if n := asInt64(v); n != 0 {
return n
}
if n := int64(asInt(v)); n != 0 {
return n
}
}
}
return 0
}
func redfishFirstInt64AcrossDocs(docs []map[string]interface{}, keys ...string) int64 {
for _, doc := range docs {
if v := redfishFirstInt64(doc, keys...); v != 0 {
return v
}
}
return 0
}
func redfishFirstInt64WithFunctions(doc map[string]interface{}, functionDocs []map[string]interface{}, keys ...string) int64 {
if v := redfishFirstInt64(doc, keys...); v != 0 {
return v
}
for _, fn := range functionDocs {
if v := redfishFirstInt64(fn, keys...); v != 0 {
return v
}
}
return 0
}
func redfishFirstBoolAcrossDocs(docs []map[string]interface{}, keys ...string) *bool {
for _, doc := range docs {
if v := redfishFirstBool(doc, keys...); v != nil {
return v
}
}
return nil
}
func redfishLookupValue(doc map[string]interface{}, key string) (any, bool) {
if doc == nil || strings.TrimSpace(key) == "" {
return nil, false
}
if v, ok := doc[key]; ok {
return v, true
}
if oem, ok := doc["Oem"].(map[string]interface{}); ok {
if v, ok := redfishLookupNestedValue(oem, key); ok {
return v, true
}
}
return nil, false
}
func redfishFirstBool(doc map[string]interface{}, keys ...string) *bool {
for _, key := range keys {
if v, ok := redfishLookupValue(doc, key); ok {
if b, ok := asBoolPtr(v); ok {
return &b
}
}
}
return nil
}
func redfishFirstBoolWithFunctions(doc map[string]interface{}, functionDocs []map[string]interface{}, keys ...string) *bool {
if v := redfishFirstBool(doc, keys...); v != nil {
return v
}
for _, fn := range functionDocs {
if v := redfishFirstBool(fn, keys...); v != nil {
return v
}
}
return nil
}
func redfishLookupNestedValue(doc map[string]interface{}, key string) (any, bool) {
if doc == nil {
return nil, false
}
if v, ok := doc[key]; ok {
return v, true
}
for _, value := range doc {
nested, ok := value.(map[string]interface{})
if !ok {
continue
}
if v, ok := redfishLookupNestedValue(nested, key); ok {
return v, true
}
}
return nil, false
}
func addFloatDetail(dst map[string]any, key string, value float64) {
if value == 0 {
return
}
dst[key] = value
}
func addInt64Detail(dst map[string]any, key string, value int64) {
if value == 0 {
return
}
dst[key] = value
}
func addBoolDetail(dst map[string]any, key string, value *bool) {
if value == nil {
return
}
dst[key] = *value
}
func asBoolPtr(v any) (bool, bool) {
switch x := v.(type) {
case bool:
return x, true
case string:
switch strings.ToLower(strings.TrimSpace(x)) {
case "true", "yes", "enabled", "1":
return true, true
case "false", "no", "disabled", "0":
return false, true
}
case float64:
return x != 0, true
case int:
return x != 0, true
case int64:
return x != 0, true
}
return false, false
}
func parseGPU(doc map[string]interface{}, functionDocs []map[string]interface{}, idx int) models.GPU {
return parseGPUWithSupplementalDocs(doc, functionDocs, nil, idx)
}
func parseGPUWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}, idx int) models.GPU {
slot := firstNonEmpty(
redfishLocationLabel(doc["Slot"]),
redfishLocationLabel(doc["Location"]),
@@ -2876,6 +3389,7 @@ func parseGPU(doc map[string]interface{}, functionDocs []map[string]interface{},
PartNumber: asString(doc["PartNumber"]),
Firmware: asString(doc["FirmwareVersion"]),
Status: mapStatus(doc["Status"]),
Details: redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, supplementalDocs),
}
if bdf := asString(doc["BDF"]); bdf != "" {
@@ -2928,6 +3442,10 @@ func parseGPU(doc map[string]interface{}, functionDocs []map[string]interface{},
}
func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]interface{}) models.PCIeDevice {
return parsePCIeDeviceWithSupplementalDocs(doc, functionDocs, nil)
}
func parsePCIeDeviceWithSupplementalDocs(doc map[string]interface{}, functionDocs []map[string]interface{}, supplementalDocs []map[string]interface{}) models.PCIeDevice {
dev := models.PCIeDevice{
Slot: firstNonEmpty(redfishLocationLabel(doc["Slot"]), redfishLocationLabel(doc["Location"]), asString(doc["Name"]), asString(doc["Id"])),
BDF: asString(doc["BDF"]),
@@ -2937,6 +3455,7 @@ func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]inter
SerialNumber: findFirstNormalizedStringByKeys(doc, "SerialNumber"),
VendorID: asHexOrInt(doc["VendorId"]),
DeviceID: asHexOrInt(doc["DeviceId"]),
Details: redfishPCIeDetailsWithSupplementalDocs(doc, functionDocs, supplementalDocs),
}
if strings.TrimSpace(dev.BDF) == "" {
dev.BDF = buildBDFfromOemPublic(doc)
@@ -2992,6 +3511,10 @@ func parsePCIeDevice(doc map[string]interface{}, functionDocs []map[string]inter
}
func parsePCIeFunction(doc map[string]interface{}, idx int) models.PCIeDevice {
return parsePCIeFunctionWithSupplementalDocs(doc, nil, idx)
}
func parsePCIeFunctionWithSupplementalDocs(doc map[string]interface{}, supplementalDocs []map[string]interface{}, idx int) models.PCIeDevice {
slot := firstNonEmpty(redfishLocationLabel(doc["Location"]), asString(doc["Id"]), asString(doc["Name"]))
if slot == "" {
slot = fmt.Sprintf("PCIeFn%d", idx)
@@ -3009,6 +3532,7 @@ func parsePCIeFunction(doc map[string]interface{}, idx int) models.PCIeDevice {
LinkSpeed: firstNonEmpty(asString(doc["CurrentLinkSpeedGTs"]), asString(doc["CurrentLinkSpeed"])),
MaxLinkWidth: asInt(doc["MaxLinkWidth"]),
MaxLinkSpeed: firstNonEmpty(asString(doc["MaxLinkSpeedGTs"]), asString(doc["MaxLinkSpeed"])),
Details: redfishPCIeDetailsWithSupplementalDocs(doc, nil, supplementalDocs),
}
if isGenericPCIeClassLabel(dev.DeviceClass) {
if resolved := pciids.DeviceName(dev.VendorID, dev.DeviceID); resolved != "" {
@@ -3552,8 +4076,10 @@ func storageIdentityKey(item models.Storage) string {
func richerStorageEntry(a, b models.Storage) models.Storage {
if storageRichnessScore(b) > storageRichnessScore(a) {
b.Details = mergeGenericDetails(b.Details, a.Details)
return b
}
a.Details = mergeGenericDetails(a.Details, b.Details)
return a
}
@@ -3815,6 +4341,9 @@ func networkAdapterRichnessScore(nic models.NetworkAdapter) int {
if normalizeRedfishIdentityField(nic.Firmware) != "" {
score += 8
}
if looksLikeCanonicalBDF(strings.TrimSpace(nic.BDF)) {
score += 10
}
if normalizeRedfishIdentityField(nic.PartNumber) != "" {
score += 6
}
@@ -3827,6 +4356,12 @@ func networkAdapterRichnessScore(nic models.NetworkAdapter) int {
if nic.PortCount > 0 {
score += 4
}
if nic.LinkWidth > 0 || nic.MaxLinkWidth > 0 {
score += 4
}
if strings.TrimSpace(nic.LinkSpeed) != "" || strings.TrimSpace(nic.MaxLinkSpeed) != "" {
score += 4
}
if len(nic.MACAddresses) > 0 {
score += 4
}
@@ -3853,6 +4388,9 @@ func mergeNetworkAdapterEntries(a, b models.NetworkAdapter) models.NetworkAdapte
if strings.TrimSpace(out.Location) == "" && strings.TrimSpace(donor.Location) != "" {
out.Location = donor.Location
}
if strings.TrimSpace(out.BDF) == "" && strings.TrimSpace(donor.BDF) != "" {
out.BDF = donor.BDF
}
if normalizeNetworkAdapterModel(out) == "" && normalizeNetworkAdapterModel(donor) != "" {
out.Model = donor.Model
}
@@ -3883,6 +4421,18 @@ func mergeNetworkAdapterEntries(a, b models.NetworkAdapter) models.NetworkAdapte
if strings.TrimSpace(out.PortType) == "" && strings.TrimSpace(donor.PortType) != "" {
out.PortType = donor.PortType
}
if out.LinkWidth == 0 && donor.LinkWidth > 0 {
out.LinkWidth = donor.LinkWidth
}
if strings.TrimSpace(out.LinkSpeed) == "" && strings.TrimSpace(donor.LinkSpeed) != "" {
out.LinkSpeed = donor.LinkSpeed
}
if out.MaxLinkWidth == 0 && donor.MaxLinkWidth > 0 {
out.MaxLinkWidth = donor.MaxLinkWidth
}
if strings.TrimSpace(out.MaxLinkSpeed) == "" && strings.TrimSpace(donor.MaxLinkSpeed) != "" {
out.MaxLinkSpeed = donor.MaxLinkSpeed
}
if strings.TrimSpace(out.Status) == "" && strings.TrimSpace(donor.Status) != "" {
out.Status = donor.Status
}
@@ -3890,6 +4440,7 @@ func mergeNetworkAdapterEntries(a, b models.NetworkAdapter) models.NetworkAdapte
if len(donor.MACAddresses) > 0 {
out.MACAddresses = dedupeStrings(append(append([]string{}, out.MACAddresses...), donor.MACAddresses...))
}
out.Details = mergeGenericDetails(out.Details, donor.Details)
return out
}
@@ -4041,6 +4592,7 @@ func mergePCIeDeviceEntries(a, b models.PCIeDevice) models.PCIeDevice {
if len(donor.MACAddresses) > 0 {
out.MACAddresses = dedupeStrings(append(append([]string{}, out.MACAddresses...), donor.MACAddresses...))
}
out.Details = mergeGenericDetails(out.Details, donor.Details)
return out
}
@@ -4147,9 +4699,25 @@ func mergePSUEntries(a, b models.PSU) models.PSU {
if out.TemperatureC == 0 && donor.TemperatureC > 0 {
out.TemperatureC = donor.TemperatureC
}
out.Details = mergeGenericDetails(out.Details, donor.Details)
return out
}
func mergeGenericDetails(primary, secondary map[string]any) map[string]any {
if len(secondary) == 0 {
return primary
}
if primary == nil {
primary = make(map[string]any, len(secondary))
}
for key, value := range secondary {
if _, ok := primary[key]; !ok {
primary[key] = value
}
}
return primary
}
func dedupeStorageVolumes(items []models.StorageVolume) []models.StorageVolume {
seen := make(map[string]struct{}, len(items))
out := make([]models.StorageVolume, 0, len(items))

View File

@@ -62,8 +62,8 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
if emit != nil {
emit(Progress{Status: "running", Progress: 55, Message: "Redfish snapshot: replay CPU/RAM/Storage..."})
}
processors, _ := r.getCollectionMembers(joinPath(primarySystem, "/Processors"))
memory, _ := r.getCollectionMembers(joinPath(primarySystem, "/Memory"))
processors := r.collectProcessors(primarySystem)
memory := r.collectMemory(primarySystem)
storageDevices := r.collectStorage(primarySystem)
storageVolumes := r.collectStorageVolumes(primarySystem)
@@ -101,8 +101,8 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
RawPayloads: cloneRawPayloads(rawPayloads),
Hardware: &models.HardwareConfig{
BoardInfo: boardInfo,
CPUs: parseCPUs(processors),
Memory: parseMemory(memory),
CPUs: processors,
Memory: memory,
Storage: storageDevices,
Volumes: storageVolumes,
PCIeDevices: pcieDevices,
@@ -977,6 +977,77 @@ func (r redfishSnapshotReader) getLinkedPCIeFunctions(doc map[string]interface{}
return nil
}
func (r redfishSnapshotReader) getLinkedSupplementalDocs(doc map[string]interface{}, keys ...string) []map[string]interface{} {
if len(doc) == 0 || len(keys) == 0 {
return nil
}
var out []map[string]interface{}
seen := make(map[string]struct{})
for _, key := range keys {
path := normalizeRedfishPath(redfishLinkedPath(doc, key))
if path == "" {
continue
}
if _, ok := seen[path]; ok {
continue
}
supplementalDoc, err := r.getJSON(path)
if err != nil || len(supplementalDoc) == 0 {
continue
}
seen[path] = struct{}{}
out = append(out, supplementalDoc)
}
return out
}
func (r redfishSnapshotReader) collectProcessors(systemPath string) []models.CPU {
memberDocs, err := r.getCollectionMembers(joinPath(systemPath, "/Processors"))
if err != nil || len(memberDocs) == 0 {
return nil
}
out := make([]models.CPU, 0, len(memberDocs))
socketIdx := 0
for _, doc := range memberDocs {
if pt := strings.TrimSpace(asString(doc["ProcessorType"])); pt != "" &&
!strings.EqualFold(pt, "CPU") && !strings.EqualFold(pt, "General") {
continue
}
cpu := parseCPUs([]map[string]interface{}{doc})[0]
if cpu.Socket == 0 && socketIdx > 0 && strings.TrimSpace(asString(doc["Socket"])) == "" {
cpu.Socket = socketIdx
if cpu.Details == nil {
cpu.Details = map[string]any{}
}
cpu.Details["socket"] = cpu.Socket
}
supplementalDocs := r.getLinkedSupplementalDocs(doc, "ProcessorMetrics", "EnvironmentMetrics", "Metrics")
if len(supplementalDocs) > 0 {
cpu.Details = mergeGenericDetails(cpu.Details, redfishCPUDetailsAcrossDocs(doc, supplementalDocs...))
}
out = append(out, cpu)
socketIdx++
}
return out
}
func (r redfishSnapshotReader) collectMemory(systemPath string) []models.MemoryDIMM {
memberDocs, err := r.getCollectionMembers(joinPath(systemPath, "/Memory"))
if err != nil || len(memberDocs) == 0 {
return nil
}
out := make([]models.MemoryDIMM, 0, len(memberDocs))
for _, doc := range memberDocs {
dimm := parseMemory([]map[string]interface{}{doc})[0]
supplementalDocs := r.getLinkedSupplementalDocs(doc, "MemoryMetrics", "EnvironmentMetrics", "Metrics")
if len(supplementalDocs) > 0 {
dimm.Details = mergeGenericDetails(dimm.Details, redfishMemoryDetailsAcrossDocs(doc, supplementalDocs...))
}
out = append(out, dimm)
}
return out
}
func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storage {
var out []models.Storage
storageMembers, _ := r.getCollectionMembers(joinPath(systemPath, "/Storage"))
@@ -987,12 +1058,14 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
if err == nil {
for _, driveDoc := range driveDocs {
if !isVirtualStorageDrive(driveDoc) {
out = append(out, parseDrive(driveDoc))
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
if len(driveDocs) == 0 {
for _, driveDoc := range r.probeDirectDiskBayChildren(driveCollectionPath) {
out = append(out, parseDrive(driveDoc))
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
}
@@ -1014,13 +1087,15 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
continue
}
if !isVirtualStorageDrive(driveDoc) {
out = append(out, parseDrive(driveDoc))
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
continue
}
if looksLikeDrive(member) {
out = append(out, parseDrive(member))
supplementalDocs := r.getLinkedSupplementalDocs(member, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(member, supplementalDocs...))
}
for _, enclosurePath := range redfishLinkRefs(member, "Links", "Enclosures") {
@@ -1028,7 +1103,8 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
if err == nil {
for _, driveDoc := range driveDocs {
if looksLikeDrive(driveDoc) {
out = append(out, parseDrive(driveDoc))
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
if len(driveDocs) == 0 {
@@ -1045,7 +1121,8 @@ func (r redfishSnapshotReader) collectStorage(systemPath string) []models.Storag
"/Storage/IntelVROC/Controllers/1/Drives",
}) {
if looksLikeDrive(driveDoc) {
out = append(out, parseDrive(driveDoc))
supplementalDocs := r.getLinkedSupplementalDocs(driveDoc, "DriveMetrics", "EnvironmentMetrics", "Metrics")
out = append(out, parseDriveWithSupplementalDocs(driveDoc, supplementalDocs...))
}
}
@@ -1165,7 +1242,11 @@ func (r redfishSnapshotReader) collectNICs(chassisPaths []string) []models.Netwo
continue
}
functionDocs := r.getLinkedPCIeFunctions(pcieDoc)
enrichNICFromPCIe(&nic, pcieDoc, functionDocs)
supplementalDocs := r.getLinkedSupplementalDocs(pcieDoc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
}
enrichNICFromPCIe(&nic, pcieDoc, functionDocs, supplementalDocs)
}
// Collect MACs from NetworkDeviceFunctions when not found via PCIe path.
if len(nic.MACAddresses) == 0 {
@@ -1184,7 +1265,8 @@ func (r redfishSnapshotReader) collectPSUs(chassisPaths []string) []models.PSU {
for _, chassisPath := range chassisPaths {
if memberDocs, err := r.getCollectionMembers(joinPath(chassisPath, "/PowerSubsystem/PowerSupplies")); err == nil && len(memberDocs) > 0 {
for _, doc := range memberDocs {
idx = appendPSU(&out, seen, parsePSU(doc, idx), idx)
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx)
}
continue
}
@@ -1195,7 +1277,8 @@ func (r redfishSnapshotReader) collectPSUs(chassisPaths []string) []models.PSU {
if !ok {
continue
}
idx = appendPSU(&out, seen, parsePSU(doc, idx), idx)
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
idx = appendPSU(&out, seen, parsePSUWithSupplementalDocs(doc, idx, supplementalDocs...), idx)
}
}
}
@@ -1227,7 +1310,11 @@ func (r redfishSnapshotReader) collectGPUs(systemPaths, chassisPaths []string) [
if !looksLikeGPU(doc, functionDocs) {
continue
}
gpu := parseGPU(doc, functionDocs, idx)
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
}
gpu := parseGPUWithSupplementalDocs(doc, functionDocs, supplementalDocs, idx)
idx++
if shouldSkipGenericGPUDuplicate(out, gpu) {
continue
@@ -1265,7 +1352,11 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
if looksLikeGPU(doc, functionDocs) {
continue
}
dev := parsePCIeDevice(doc, functionDocs)
supplementalDocs := r.getLinkedSupplementalDocs(doc, "EnvironmentMetrics", "Metrics")
for _, fn := range functionDocs {
supplementalDocs = append(supplementalDocs, r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")...)
}
dev := parsePCIeDeviceWithSupplementalDocs(doc, functionDocs, supplementalDocs)
if isUnidentifiablePCIeDevice(dev) {
continue
}
@@ -1278,7 +1369,8 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
continue
}
for idx, fn := range functionDocs {
dev := parsePCIeFunction(fn, idx+1)
supplementalDocs := r.getLinkedSupplementalDocs(fn, "EnvironmentMetrics", "Metrics")
dev := parsePCIeFunctionWithSupplementalDocs(fn, supplementalDocs, idx+1)
out = append(out, dev)
}
}

View File

@@ -584,18 +584,32 @@ func TestEnrichNICFromPCIeFunctions(t *testing.T) {
}
functionDocs := []map[string]interface{}{
{
"VendorId": "0x15b3",
"DeviceId": "0x1021",
"FunctionId": "0000:17:00.0",
"VendorId": "0x15b3",
"DeviceId": "0x1021",
"CurrentLinkWidth": 16,
"CurrentLinkSpeedGTs": "32 GT/s",
"MaxLinkWidth": 16,
"MaxLinkSpeedGTs": "32 GT/s",
},
}
enrichNICFromPCIe(&nic, pcieDoc, functionDocs)
enrichNICFromPCIe(&nic, pcieDoc, functionDocs, nil)
if nic.VendorID != 0x15b3 || nic.DeviceID != 0x1021 {
t.Fatalf("unexpected NIC IDs: vendor=%#x device=%#x", nic.VendorID, nic.DeviceID)
}
if nic.Location != "PCIe Slot 1 (1)" {
t.Fatalf("unexpected NIC location: %q", nic.Location)
}
if nic.BDF != "0000:17:00.0" {
t.Fatalf("unexpected NIC BDF: %q", nic.BDF)
}
if nic.LinkWidth != 16 || nic.MaxLinkWidth != 16 {
t.Fatalf("unexpected NIC link width state: current=%d max=%d", nic.LinkWidth, nic.MaxLinkWidth)
}
if nic.LinkSpeed != "32 GT/s" || nic.MaxLinkSpeed != "32 GT/s" {
t.Fatalf("unexpected NIC link speed state: current=%q max=%q", nic.LinkSpeed, nic.MaxLinkSpeed)
}
}
func TestParseNIC_PortCountFromControllerCapabilities(t *testing.T) {
@@ -704,6 +718,208 @@ func TestParseComponents_UseNestedSerialNumberFallback(t *testing.T) {
}
}
func TestParseCPUAndMemory_CollectOemDetails(t *testing.T) {
cpus := parseCPUs([]map[string]interface{}{
{
"Id": "CPU0",
"Model": "Intel Xeon",
"CorrectableErrors": 7,
"TemperatureCelsius": 63,
"Oem": map[string]interface{}{
"VendorX": map[string]interface{}{
"MicrocodeVersion": "0x2b000643",
"UncorrectableErrors": 1,
"ThermalThrottled": true,
},
},
},
})
if len(cpus) != 1 || cpus[0].Details == nil {
t.Fatalf("expected CPU details, got %+v", cpus)
}
if cpus[0].Details["microcode"] != "0x2b000643" {
t.Fatalf("expected CPU microcode detail, got %#v", cpus[0].Details)
}
if cpus[0].Details["correctable_error_count"] != int64(7) || cpus[0].Details["uncorrectable_error_count"] != int64(1) {
t.Fatalf("expected CPU error counters, got %#v", cpus[0].Details)
}
if cpus[0].Details["throttled"] != true || cpus[0].Details["temperature_c"] != 63.0 {
t.Fatalf("expected CPU thermal details, got %#v", cpus[0].Details)
}
dimms := parseMemory([]map[string]interface{}{
{
"Id": "DIMM0",
"DeviceLocator": "CPU0_C0D0",
"CapacityMiB": 32768,
"SerialNumber": "DIMM-001",
"Oem": map[string]interface{}{
"VendorX": map[string]interface{}{
"CorrectableECCErrorCount": 12,
"UncorrectableECCErrorCount": 2,
"TemperatureC": 41.5,
"SpareBlocksRemainingPercent": 88,
"PerformanceDegraded": true,
"DataLossDetected": false,
},
},
},
})
if len(dimms) != 1 || dimms[0].Details == nil {
t.Fatalf("expected DIMM details, got %+v", dimms)
}
if dimms[0].Details["correctable_ecc_error_count"] != int64(12) || dimms[0].Details["uncorrectable_ecc_error_count"] != int64(2) {
t.Fatalf("expected DIMM ECC counters, got %#v", dimms[0].Details)
}
if dimms[0].Details["temperature_c"] != 41.5 || dimms[0].Details["spare_blocks_remaining_pct"] != 88.0 {
t.Fatalf("expected DIMM telemetry details, got %#v", dimms[0].Details)
}
if dimms[0].Details["performance_degraded"] != true || dimms[0].Details["data_loss_detected"] != false {
t.Fatalf("expected DIMM boolean health details, got %#v", dimms[0].Details)
}
}
func TestReplayRedfishFromRawPayloads_UsesProcessorAndMemoryMetrics(t *testing.T) {
rawPayloads := map[string]any{
"redfish_tree": map[string]interface{}{
"/redfish/v1": map[string]interface{}{},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
},
},
"/redfish/v1/Systems/1": map[string]interface{}{
"Id": "1",
"Processors": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Processors",
},
"Memory": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Memory",
},
},
"/redfish/v1/Systems/1/Processors": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Processors/CPU0"},
},
},
"/redfish/v1/Systems/1/Processors/CPU0": map[string]interface{}{
"Id": "CPU0",
"ProcessorType": "CPU",
"Model": "Intel Xeon",
"ProcessorMetrics": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Processors/CPU0/ProcessorMetrics",
},
},
"/redfish/v1/Systems/1/Processors/CPU0/ProcessorMetrics": map[string]interface{}{
"CorrectableErrors": 10,
"ThermalThrottled": true,
"MicrocodeVersion": "0x2b000643",
"TemperatureCelsius": 66,
},
"/redfish/v1/Systems/1/Memory": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Memory/DIMM0"},
},
},
"/redfish/v1/Systems/1/Memory/DIMM0": map[string]interface{}{
"Id": "DIMM0",
"DeviceLocator": "CPU0_C0D0",
"CapacityMiB": 32768,
"SerialNumber": "DIMM-001",
"MemoryMetrics": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Memory/DIMM0/MemoryMetrics",
},
},
"/redfish/v1/Systems/1/Memory/DIMM0/MemoryMetrics": map[string]interface{}{
"CorrectableECCErrorCount": 14,
"TemperatureCelsius": 42,
"PerformanceDegraded": true,
"SpareBlocksRemainingPercent": 91,
},
},
}
result, err := ReplayRedfishFromRawPayloads(rawPayloads, nil)
if err != nil {
t.Fatalf("ReplayRedfishFromRawPayloads() failed: %v", err)
}
if len(result.Hardware.CPUs) != 1 || result.Hardware.CPUs[0].Details == nil {
t.Fatalf("expected CPU details from replay metrics, got %+v", result.Hardware.CPUs)
}
if result.Hardware.CPUs[0].Details["correctable_error_count"] != int64(10) || result.Hardware.CPUs[0].Details["microcode"] != "0x2b000643" {
t.Fatalf("expected CPU replay metrics details, got %#v", result.Hardware.CPUs[0].Details)
}
if len(result.Hardware.Memory) != 1 || result.Hardware.Memory[0].Details == nil {
t.Fatalf("expected memory details from replay metrics, got %+v", result.Hardware.Memory)
}
if result.Hardware.Memory[0].Details["correctable_ecc_error_count"] != int64(14) || result.Hardware.Memory[0].Details["performance_degraded"] != true {
t.Fatalf("expected DIMM replay metrics details, got %#v", result.Hardware.Memory[0].Details)
}
}
func TestReplayRedfishFromRawPayloads_UsesDriveMetrics(t *testing.T) {
rawPayloads := map[string]any{
"redfish_tree": map[string]interface{}{
"/redfish/v1": map[string]interface{}{},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
},
},
"/redfish/v1/Systems/1": map[string]interface{}{
"Id": "1",
"Storage": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Storage",
},
},
"/redfish/v1/Systems/1/Storage": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Storage/RAID1"},
},
},
"/redfish/v1/Systems/1/Storage/RAID1": map[string]interface{}{
"Id": "RAID1",
"Drives": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Storage/RAID1/Drives",
},
},
"/redfish/v1/Systems/1/Storage/RAID1/Drives": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1/Storage/RAID1/Drives/Drive0"},
},
},
"/redfish/v1/Systems/1/Storage/RAID1/Drives/Drive0": map[string]interface{}{
"Id": "Drive0",
"Model": "NVMe SSD",
"SerialNumber": "SSD-001",
"DriveMetrics": map[string]interface{}{
"@odata.id": "/redfish/v1/Systems/1/Storage/RAID1/Drives/Drive0/DriveMetrics",
},
},
"/redfish/v1/Systems/1/Storage/RAID1/Drives/Drive0/DriveMetrics": map[string]interface{}{
"PowerOnHours": 1001,
"MediaErrors": 3,
"AvailableSparePercent": 92,
"TemperatureCelsius": 37,
},
},
}
result, err := ReplayRedfishFromRawPayloads(rawPayloads, nil)
if err != nil {
t.Fatalf("ReplayRedfishFromRawPayloads() failed: %v", err)
}
if len(result.Hardware.Storage) != 1 || result.Hardware.Storage[0].Details == nil {
t.Fatalf("expected storage details from replay drive metrics, got %+v", result.Hardware.Storage)
}
if result.Hardware.Storage[0].Details["power_on_hours"] != int64(1001) || result.Hardware.Storage[0].Details["media_errors"] != int64(3) {
t.Fatalf("expected drive metrics counters, got %#v", result.Hardware.Storage[0].Details)
}
if result.Hardware.Storage[0].Details["available_spare_pct"] != 92.0 || result.Hardware.Storage[0].Details["temperature_c"] != 37.0 {
t.Fatalf("expected drive metrics telemetry, got %#v", result.Hardware.Storage[0].Details)
}
}
func TestRedfishCollectionMemberRefs_IncludesOemPublicMembers(t *testing.T) {
collection := map[string]interface{}{
"Members": []interface{}{
@@ -725,6 +941,186 @@ func TestRedfishCollectionMemberRefs_IncludesOemPublicMembers(t *testing.T) {
}
}
func TestParseDriveAndPSU_CollectComponentMetricsIntoDetails(t *testing.T) {
drive := parseDrive(map[string]interface{}{
"Id": "Drive0",
"Model": "NVMe SSD",
"SerialNumber": "SSD-001",
"TemperatureCelsius": 38.5,
"PowerOnHours": 12450,
"UnsafeShutdowns": 3,
"PredictedMediaLifeLeftPercent": 91,
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"AvailableSparePercent": 87,
},
},
})
if drive.Details == nil {
t.Fatalf("expected drive details to be populated")
}
if got := drive.Details["temperature_c"]; got != 38.5 {
t.Fatalf("expected drive temperature detail 38.5, got %#v", got)
}
if got := drive.Details["power_on_hours"]; got != int64(12450) {
t.Fatalf("expected drive power_on_hours detail, got %#v", got)
}
if got := drive.Details["life_remaining_pct"]; got != 91.0 {
t.Fatalf("expected drive life_remaining_pct detail, got %#v", got)
}
if got := drive.Details["available_spare_pct"]; got != 87.0 {
t.Fatalf("expected drive available_spare_pct detail from Oem/Public, got %#v", got)
}
driveOEM := parseDrive(map[string]interface{}{
"Id": "Drive1",
"Model": "NVMe SSD 2",
"SerialNumber": "SSD-002",
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"temperature": 19,
"PercentAvailableSpare": 93,
"PercentageUsed": 7,
},
},
})
if driveOEM.Details == nil {
t.Fatalf("expected oem drive details to be populated")
}
if got := driveOEM.Details["temperature_c"]; got != 19.0 {
t.Fatalf("expected lowercase OEM drive temperature 19, got %#v", got)
}
if got := driveOEM.Details["available_spare_pct"]; got != 93.0 {
t.Fatalf("expected OEM available_spare_pct 93, got %#v", got)
}
if got := driveOEM.Details["life_used_pct"]; got != 7.0 {
t.Fatalf("expected OEM life_used_pct 7, got %#v", got)
}
psu := parsePSU(map[string]interface{}{
"MemberId": "PSU0",
"SerialNumber": "PSU-001",
"TemperatureCelsius": 41,
"Oem": map[string]interface{}{
"Public": map[string]interface{}{
"LifeRemainingPercent": 96,
},
},
}, 1)
if psu.Details == nil {
t.Fatalf("expected psu details to be populated")
}
if got := psu.Details["temperature_c"]; got != 41.0 {
t.Fatalf("expected psu temperature detail 41, got %#v", got)
}
if got := psu.Details["life_remaining_pct"]; got != 96.0 {
t.Fatalf("expected psu life_remaining_pct detail, got %#v", got)
}
}
func TestParseGPUPCIeAndNIC_CollectComponentMetricsIntoDetails(t *testing.T) {
functionDocs := []map[string]interface{}{
{
"FunctionId": "0000:17:00.0",
"VendorId": "0x10de",
"DeviceId": "0x2331",
"TemperatureCelsius": 48.5,
"PowerConsumedWatts": 315.0,
"ECCCorrectedTotal": 12,
"BatteryHealthPercent": 87,
"SFPTemperatureCelsius": 36.2,
},
}
gpu := parseGPU(map[string]interface{}{
"Id": "GPU0",
"Model": "NVIDIA H100",
"Manufacturer": "NVIDIA",
}, functionDocs, 1)
if gpu.Details == nil || gpu.Details["temperature_c"] != 48.5 || gpu.Details["power_w"] != 315.0 {
t.Fatalf("expected gpu details from function docs, got %#v", gpu.Details)
}
pcie := parsePCIeDevice(map[string]interface{}{
"Id": "NIC1",
}, []map[string]interface{}{
{
"FunctionId": "0000:18:00.0",
"VendorId": "0x15b3",
"DeviceId": "0x1021",
"SFPTXPowerDBm": -1.8,
"SFPRXPowerDBm": -2.1,
"SFPBiasMA": 5.5,
"BatteryReplaceRequired": true,
},
})
if pcie.Details == nil || pcie.Details["sfp_tx_power_dbm"] != -1.8 || pcie.Details["battery_replace_required"] != true {
t.Fatalf("expected pcie details from function docs, got %#v", pcie.Details)
}
nic := parseNIC(map[string]interface{}{"Id": "1"})
enrichNICFromPCIe(&nic, map[string]interface{}{}, []map[string]interface{}{
{
"FunctionId": "0000:19:00.0",
"SFPTemperatureCelsius": 34.0,
},
}, nil)
if nic.Details == nil || nic.Details["sfp_temperature_c"] != 34.0 {
t.Fatalf("expected nic details from linked pcie function, got %#v", nic.Details)
}
}
func TestParseComponentDetails_UseLinkedSupplementalMetrics(t *testing.T) {
drive := parseDriveWithSupplementalDocs(
map[string]interface{}{
"Id": "Drive0",
"SerialNumber": "SSD-001",
},
map[string]interface{}{
"PowerOnHours": 5001,
"MediaErrors": 2,
"TemperatureC": 39.5,
"LifeUsedPercent": 9,
},
)
if drive.Details == nil || drive.Details["power_on_hours"] != int64(5001) || drive.Details["temperature_c"] != 39.5 {
t.Fatalf("expected drive details from supplemental metrics, got %#v", drive.Details)
}
psu := parsePSUWithSupplementalDocs(
map[string]interface{}{
"MemberId": "PSU0",
"SerialNumber": "PSU-001",
},
1,
map[string]interface{}{
"Temperature": 44,
"LifeRemainingPercent": 97,
},
)
if psu.Details == nil || psu.Details["temperature_c"] != 44.0 || psu.Details["life_remaining_pct"] != 97.0 {
t.Fatalf("expected psu details from supplemental metrics, got %#v", psu.Details)
}
gpu := parseGPUWithSupplementalDocs(
map[string]interface{}{
"Id": "GPU0",
"Model": "NVIDIA H100",
"Manufacturer": "NVIDIA",
},
nil,
[]map[string]interface{}{
{
"PowerConsumptionWatts": 305.0,
"HWSlowdown": true,
},
},
1,
)
if gpu.Details == nil || gpu.Details["power_w"] != 305.0 || gpu.Details["hw_slowdown"] != true {
t.Fatalf("expected gpu details from supplemental metrics, got %#v", gpu.Details)
}
}
func TestRecoverCriticalRedfishDocsPlanB_RetriesMembersFromExistingCollection(t *testing.T) {
t.Setenv("LOGPILE_REDFISH_CRITICAL_COOLDOWN", "0s")
t.Setenv("LOGPILE_REDFISH_CRITICAL_SLOW_GAP", "0s")
@@ -1621,11 +2017,11 @@ func TestCollectGPUsFromProcessors_SupermicroHGX(t *testing.T) {
},
},
"/redfish/v1/Chassis/1/PCIeDevices/GPU1": map[string]interface{}{
"Id": "GPU1",
"Name": "GPU1",
"Model": "NVIDIA H200",
"Manufacturer": "NVIDIA",
"SerialNumber": "SN001",
"Id": "GPU1",
"Name": "GPU1",
"Model": "NVIDIA H200",
"Manufacturer": "NVIDIA",
"SerialNumber": "SN001",
"FirmwareVersion": "96.00.D9.00.02",
"PCIeFunctions": map[string]interface{}{
"@odata.id": "/redfish/v1/Chassis/1/PCIeDevices/GPU1/PCIeFunctions",
@@ -1810,15 +2206,18 @@ func TestLooksLikeGPU_NVSwitchExcluded(t *testing.T) {
}
}
func TestShouldCrawlPath_MemorySubresourcesAreSkipped(t *testing.T) {
func TestShouldCrawlPath_MemoryAndProcessorMetricsAreAllowed(t *testing.T) {
if !shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0") {
t.Fatalf("expected direct DIMM resource to be crawlable")
}
if shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0/Assembly") {
t.Fatalf("expected DIMM assembly subresource to be skipped")
}
if shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0/MemoryMetrics") {
t.Fatalf("expected DIMM metrics subresource to be skipped")
if !shouldCrawlPath("/redfish/v1/Systems/1/Memory/CPU0_C0D0/MemoryMetrics") {
t.Fatalf("expected DIMM metrics subresource to be crawlable")
}
if !shouldCrawlPath("/redfish/v1/Systems/1/Processors/CPU0/ProcessorMetrics") {
t.Fatalf("expected CPU metrics subresource to be crawlable")
}
if shouldCrawlPath("/redfish/v1/Chassis/1/PCIeDevices/0/PCIeFunctions/1") {
t.Fatalf("expected noisy chassis pciefunctions branch to be skipped")

View File

@@ -66,104 +66,15 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
}
}
// CPUs
for _, cpu := range e.result.Hardware.CPUs {
if !hasUsableSerial(cpu.SerialNumber) {
seenCanonical := make(map[string]struct{})
for _, dev := range canonicalDevicesForExport(e.result.Hardware) {
if !hasUsableSerial(dev.SerialNumber) {
continue
}
if err := writer.Write([]string{
cpu.Model,
strings.TrimSpace(cpu.SerialNumber),
"",
"CPU",
}); err != nil {
return err
}
}
// Memory
for _, mem := range e.result.Hardware.Memory {
if !hasUsableSerial(mem.SerialNumber) {
continue
}
location := mem.Location
if location == "" {
location = mem.Slot
}
if err := writer.Write([]string{
mem.PartNumber,
strings.TrimSpace(mem.SerialNumber),
mem.Manufacturer,
location,
}); err != nil {
return err
}
}
// Storage
for _, stor := range e.result.Hardware.Storage {
if !hasUsableSerial(stor.SerialNumber) {
continue
}
if err := writer.Write([]string{
stor.Model,
strings.TrimSpace(stor.SerialNumber),
stor.Manufacturer,
stor.Slot,
}); err != nil {
return err
}
}
// GPUs
for _, gpu := range e.result.Hardware.GPUs {
if !hasUsableSerial(gpu.SerialNumber) {
continue
}
component := gpu.Model
if component == "" {
component = "GPU"
}
if err := writer.Write([]string{
component,
strings.TrimSpace(gpu.SerialNumber),
gpu.Manufacturer,
gpu.Slot,
}); err != nil {
return err
}
}
// PCIe devices
for _, pcie := range e.result.Hardware.PCIeDevices {
if !hasUsableSerial(pcie.SerialNumber) {
continue
}
if err := writer.Write([]string{
pcie.DeviceClass,
strings.TrimSpace(pcie.SerialNumber),
pcie.Manufacturer,
pcie.Slot,
}); err != nil {
return err
}
}
// Network adapters
for _, nic := range e.result.Hardware.NetworkAdapters {
if !hasUsableSerial(nic.SerialNumber) {
continue
}
location := nic.Location
if location == "" {
location = nic.Slot
}
if err := writer.Write([]string{
nic.Model,
strings.TrimSpace(nic.SerialNumber),
nic.Vendor,
location,
}); err != nil {
serial := strings.TrimSpace(dev.SerialNumber)
seenCanonical[serial] = struct{}{}
component, manufacturer, location := csvFieldsFromCanonicalDevice(dev)
if err := writer.Write([]string{component, serial, manufacturer, location}); err != nil {
return err
}
}
@@ -173,26 +84,15 @@ func (e *Exporter) ExportCSV(w io.Writer) error {
if !hasUsableSerial(nic.SerialNumber) {
continue
}
if err := writer.Write([]string{
nic.Model,
strings.TrimSpace(nic.SerialNumber),
"",
"Network",
}); err != nil {
return err
}
}
// Power supplies
for _, psu := range e.result.Hardware.PowerSupply {
if !hasUsableSerial(psu.SerialNumber) {
serial := strings.TrimSpace(nic.SerialNumber)
if _, ok := seenCanonical[serial]; ok {
continue
}
if err := writer.Write([]string{
psu.Model,
strings.TrimSpace(psu.SerialNumber),
psu.Vendor,
psu.Slot,
nic.Model,
serial,
"",
"Network",
}); err != nil {
return err
}
@@ -221,3 +121,52 @@ func hasUsableSerial(serial string) bool {
return true
}
}
func csvFieldsFromCanonicalDevice(dev models.HardwareDevice) (component, manufacturer, location string) {
component = firstNonEmptyString(
dev.Model,
dev.PartNumber,
dev.DeviceClass,
dev.Kind,
)
manufacturer = firstNonEmptyString(dev.Manufacturer, inferCSVVendor(dev))
location = firstNonEmptyString(dev.Location, dev.Slot, dev.BDF, dev.Kind)
switch dev.Kind {
case models.DeviceKindCPU:
if component == "" {
component = "CPU"
}
if location == "" {
location = "CPU"
}
case models.DeviceKindMemory:
component = firstNonEmptyString(dev.PartNumber, dev.Model, "Memory")
case models.DeviceKindPCIe, models.DeviceKindGPU, models.DeviceKindNetwork:
if location == "" {
location = firstNonEmptyString(dev.Slot, dev.BDF, "PCIe")
}
case models.DeviceKindPSU:
component = firstNonEmptyString(dev.Model, "Power Supply")
}
return component, manufacturer, location
}
func inferCSVVendor(dev models.HardwareDevice) string {
switch dev.Kind {
case models.DeviceKindCPU:
return ""
default:
return ""
}
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

File diff suppressed because it is too large Load Diff

View File

@@ -210,7 +210,7 @@ func TestConvertCPUs(t *testing.T) {
},
}
result := convertCPUs(cpus, "2026-02-10T15:30:00Z")
result := convertCPUs(cpus, "2026-02-10T15:30:00Z", "BOARD-001")
if len(result) != 2 {
t.Fatalf("expected 2 CPUs, got %d", len(result))
@@ -227,6 +227,9 @@ func TestConvertCPUs(t *testing.T) {
if result[0].Status != "Unknown" {
t.Errorf("expected Unknown status, got %q", result[0].Status)
}
if result[0].SerialNumber != "BOARD-001-CPU-0" {
t.Errorf("expected generated CPU serial, got %q", result[0].SerialNumber)
}
}
func TestConvertMemory(t *testing.T) {
@@ -247,17 +250,13 @@ func TestConvertMemory(t *testing.T) {
result := convertMemory(memory, "2026-02-10T15:30:00Z")
if len(result) != 2 {
t.Fatalf("expected 2 memory modules, got %d", len(result))
if len(result) != 1 {
t.Fatalf("expected 1 populated memory module, got %d", len(result))
}
if result[0].Status != "OK" {
t.Errorf("expected OK status for first module, got %q", result[0].Status)
}
if result[1].Status != "Empty" {
t.Errorf("expected Empty status for second module, got %q", result[1].Status)
}
}
func TestConvertStorage(t *testing.T) {
@@ -289,6 +288,48 @@ func TestConvertStorage(t *testing.T) {
}
}
func TestConvertStorage_RemainingEndurance(t *testing.T) {
pct100 := 100
pct3 := 3
storage := []models.Storage{
{
Slot: "0",
Model: "HFS480G3H2X069N",
SerialNumber: "ESEAN5254I030B26B",
Present: true,
RemainingEndurancePct: &pct100,
},
{
Slot: "1",
Model: "HFS480G3H2X069N",
SerialNumber: "ESEAN5254I030B26C",
Present: true,
// no endurance data
},
{
Slot: "2",
Model: "HFS480G3H2X069N",
SerialNumber: "ESEAN5254I030B26D",
Present: true,
RemainingEndurancePct: &pct3,
},
}
result := convertStorage(storage, "2026-03-15T00:00:00Z")
if len(result) != 3 {
t.Fatalf("expected 3 results, got %d", len(result))
}
if result[0].RemainingEndurancePct == nil || *result[0].RemainingEndurancePct != 100 {
t.Errorf("slot 0: expected remaining_endurance_pct=100, got %v", result[0].RemainingEndurancePct)
}
if result[1].RemainingEndurancePct != nil {
t.Errorf("slot 1: expected remaining_endurance_pct absent, got %v", *result[1].RemainingEndurancePct)
}
if result[2].RemainingEndurancePct == nil || *result[2].RemainingEndurancePct != 3 {
t.Errorf("slot 2: expected remaining_endurance_pct=3, got %v", result[2].RemainingEndurancePct)
}
}
func TestConvertPCIeDevices(t *testing.T) {
hw := &models.HardwareConfig{
PCIeDevices: []models.PCIeDevice{
@@ -329,16 +370,16 @@ func TestConvertPCIeDevices(t *testing.T) {
},
}
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
// Should have: 2 PCIe devices + 1 GPU + 1 NIC = 4 total
if len(result) != 4 {
t.Fatalf("expected 4 PCIe devices total, got %d", len(result))
}
// Check that serial is empty for second PCIe device (no auto-generation)
if result[1].SerialNumber != "" {
t.Errorf("expected empty serial for missing device serial, got %q", result[1].SerialNumber)
// Check that serial is generated for second PCIe device
if result[1].SerialNumber != "BOARD-001-PCIE-PCIeCard2" {
t.Errorf("expected generated serial for missing device serial, got %q", result[1].SerialNumber)
}
// Check GPU was included
@@ -346,8 +387,8 @@ func TestConvertPCIeDevices(t *testing.T) {
for _, dev := range result {
if dev.SerialNumber == "GPU-001" {
foundGPU = true
if dev.DeviceClass != "DisplayController" {
t.Errorf("expected GPU device_class DisplayController, got %q", dev.DeviceClass)
if dev.DeviceClass != "VideoController" {
t.Errorf("expected GPU device_class VideoController, got %q", dev.DeviceClass)
}
break
}
@@ -375,14 +416,14 @@ func TestConvertPCIeDevices_NVSwitchWithoutSerialRemainsEmpty(t *testing.T) {
},
}
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
if len(result) != 1 {
t.Fatalf("expected 1 PCIe device, got %d", len(result))
}
if result[0].SerialNumber != "" {
t.Fatalf("expected empty NVSwitch serial, got %q", result[0].SerialNumber)
if result[0].SerialNumber != "BOARD-001-PCIE-NVSWITCH1" {
t.Fatalf("expected generated NVSwitch serial, got %q", result[0].SerialNumber)
}
if result[0].Firmware != "96.10.6D.00.01" {
t.Fatalf("expected NVSwitch firmware 96.10.6D.00.01, got %q", result[0].Firmware)
@@ -408,12 +449,12 @@ func TestConvertPCIeDevices_SkipsDisplayControllerDuplicates(t *testing.T) {
},
}
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
if len(result) != 1 {
t.Fatalf("expected only dedicated GPU record without duplicate display PCIe, got %d", len(result))
}
if result[0].DeviceClass != "DisplayController" {
t.Fatalf("expected GPU record with DisplayController class, got %q", result[0].DeviceClass)
if result[0].DeviceClass != "VideoController" {
t.Fatalf("expected GPU record with VideoController class, got %q", result[0].DeviceClass)
}
if result[0].Status != "OK" {
t.Fatalf("expected GPU status OK, got %q", result[0].Status)
@@ -441,7 +482,7 @@ func TestConvertPCIeDevices_MapsGPUStatusHistory(t *testing.T) {
},
}
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z")
result := convertPCIeDevices(hw, "2026-02-10T15:30:00Z", "BOARD-001")
if len(result) != 1 {
t.Fatalf("expected 1 converted GPU, got %d", len(result))
}
@@ -452,9 +493,6 @@ func TestConvertPCIeDevices_MapsGPUStatusHistory(t *testing.T) {
if result[0].StatusHistory[0].ChangedAt != "2026-01-12T15:05:18Z" {
t.Fatalf("unexpected history changed_at: %q", result[0].StatusHistory[0].ChangedAt)
}
if result[0].StatusAtCollect == nil || result[0].StatusAtCollect.At != "2026-02-10T15:30:00Z" {
t.Fatalf("expected status_at_collection to be populated from collected_at")
}
}
func TestConvertPowerSupplies(t *testing.T) {
@@ -518,8 +556,8 @@ func TestSourceTypeOmittedWhenInvalidOrEmpty(t *testing.T) {
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
if strings.Contains(string(payload), `"source_type"`) {
t.Fatalf("expected source_type to be omitted for invalid value, got %s", string(payload))
if !strings.Contains(string(payload), `"source_type":"logfile"`) {
t.Fatalf("expected archive source_type to map to logfile, got %s", string(payload))
}
}
@@ -688,9 +726,6 @@ func TestConvertToReanimator_StatusFallbackUsesCollectedAt(t *testing.T) {
if got.StatusCheckedAt != wantTs {
t.Fatalf("expected status_checked_at=%q, got %q", wantTs, got.StatusCheckedAt)
}
if got.StatusAtCollect == nil || got.StatusAtCollect.At != wantTs {
t.Fatalf("expected status_at_collection.at=%q, got %#v", wantTs, got.StatusAtCollect)
}
}
func TestConvertToReanimator_FirmwareExcludesDeviceBoundEntries(t *testing.T) {
@@ -698,6 +733,9 @@ func TestConvertToReanimator_FirmwareExcludesDeviceBoundEntries(t *testing.T) {
Filename: "fw-filter-test.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
CPUs: []models.CPU{
{Socket: 0, Model: "Intel Xeon Gold"},
},
Firmware: []models.FirmwareInfo{
{DeviceName: "BIOS", Version: "1.0.0"},
{DeviceName: "BMC", Version: "2.0.0"},
@@ -735,6 +773,58 @@ func TestConvertToReanimator_FirmwareExcludesDeviceBoundEntries(t *testing.T) {
if _, exists := got["NVSwitch NVSWITCH0 (965-25612-0002-000)"]; exists {
t.Fatalf("expected NVSwitch firmware to be excluded from hardware.firmware")
}
if len(out.Hardware.CPUs) != 1 {
t.Fatalf("expected 1 CPU entry, got %d", len(out.Hardware.CPUs))
}
if out.Hardware.CPUs[0].Firmware != "0x2b000643" {
t.Fatalf("expected CPU firmware field to carry microcode, got %q", out.Hardware.CPUs[0].Firmware)
}
}
// TestConvertToReanimator_FirmwareExcludesDellFQDDEntries verifies that Dell TSR
// SoftwareIdentity firmware entries whose Description contains a device-bound FQDD
// (InfiniBand.Slot.*, RAID.SL.*, etc.) are filtered from hardware.firmware.
//
// Regression guard: PowerEdge R6625 (8VS2LG4) — "Mellanox Network Adapter" (FQDD
// InfiniBand.Slot.1-1) and "PERC H755 Front" (FQDD RAID.SL.3-1) leaked into
// hardware.firmware. (2026-03-15)
func TestConvertToReanimator_FirmwareExcludesDellFQDDEntries(t *testing.T) {
input := &models.AnalysisResult{
Filename: "dell-fw-filter-test.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "8VS2LG4"},
Firmware: []models.FirmwareInfo{
// system-level — must be kept
{DeviceName: "BIOS", Version: "1.15.3", Description: "system bios"},
{DeviceName: "iDRAC", Version: "7.20.80.50", Description: "idrac card"},
{DeviceName: "Lifecycle Controller", Version: "7.20.80.50", Description: "idrac lifecycle"},
// device-bound via FQDD — must be filtered
{DeviceName: "Mellanox Network Adapter", Version: "20.39.35.60", Description: "InfiniBand.Slot.1-1"},
{DeviceName: "PERC H755 Front", Version: "52.30.0-6115", Description: "RAID.SL.3-1"},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
got := make(map[string]string, len(out.Hardware.Firmware))
for _, fw := range out.Hardware.Firmware {
got[fw.DeviceName] = fw.Version
}
for _, keep := range []string{"BIOS", "iDRAC", "Lifecycle Controller"} {
if _, ok := got[keep]; !ok {
t.Errorf("expected %q in hardware.firmware, but it was missing", keep)
}
}
for _, drop := range []string{"Mellanox Network Adapter", "PERC H755 Front"} {
if _, ok := got[drop]; ok {
t.Errorf("%q must not appear in hardware.firmware (device-bound FQDD)", drop)
}
}
}
func TestConvertToReanimator_UsesCanonicalDevices(t *testing.T) {
@@ -774,9 +864,65 @@ func TestConvertToReanimator_UsesCanonicalDevices(t *testing.T) {
}
}
func TestConvertToReanimator_BindsDeviceVitals(t *testing.T) {
func TestConvertToReanimator_MergesCanonicalAndLegacyDevices(t *testing.T) {
input := &models.AnalysisResult{
Filename: "merged.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
Devices: []models.HardwareDevice{
{
Kind: models.DeviceKindPCIe,
Slot: "PCIe 3",
Model: "RAID Controller",
DeviceClass: "raid_controller",
Status: "ok",
},
},
CPUs: []models.CPU{
{Socket: 0, Model: "Xeon Platinum", SerialNumber: "CPU-001"},
},
Memory: []models.MemoryDIMM{
{Slot: "DIMM0", Location: "DIMM0", Present: true, SizeMB: 32768, Type: "DDR5", SerialNumber: "MEM-001"},
},
Storage: []models.Storage{
{Slot: "U.2-1", Type: "NVMe", Model: "Drive1", SerialNumber: "SSD-001", Present: true},
},
PowerSupply: []models.PSU{
{Slot: "PSU0", Model: "PSU", SerialNumber: "PSU-001", Present: true},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.CPUs) != 1 {
t.Fatalf("expected cpu from legacy inventory to survive canonical merge, got %d", len(out.Hardware.CPUs))
}
if len(out.Hardware.Memory) != 1 {
t.Fatalf("expected memory from legacy inventory to survive canonical merge, got %d", len(out.Hardware.Memory))
}
if len(out.Hardware.Storage) != 1 {
t.Fatalf("expected storage from legacy inventory to survive canonical merge, got %d", len(out.Hardware.Storage))
}
if len(out.Hardware.PowerSupplies) != 1 {
t.Fatalf("expected psu from legacy inventory to survive canonical merge, got %d", len(out.Hardware.PowerSupplies))
}
if len(out.Hardware.PCIeDevices) != 1 {
t.Fatalf("expected supplemental canonical pcie device to remain present, got %d", len(out.Hardware.PCIeDevices))
}
}
func TestConvertToReanimator_ExportsSensorsAndPSUTelemetry(t *testing.T) {
input := &models.AnalysisResult{
Filename: "vitals.json",
Sensors: []models.SensorReading{
{Name: "FAN1", Type: "fan", Value: 4200, Unit: "RPM", Status: "OK"},
{Name: "12V Rail", Type: "voltage", Value: 12.1, Unit: "V", Status: "OK"},
{Name: "CPU0 Temp", Type: "temperature", Value: 71, Unit: "C", Status: "Warning"},
{Name: "Humidity", Type: "humidity", Value: 38.5, Unit: "%", Status: "OK"},
},
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
Devices: []models.HardwareDevice{
@@ -786,11 +932,6 @@ func TestConvertToReanimator_BindsDeviceVitals(t *testing.T) {
Model: "B200 180GB HBM3e",
SerialNumber: "GPU-001",
BDF: "0000:17:00.0",
Details: map[string]any{
"temperature": 71,
"power": 350,
"voltage": 12.2,
},
},
{
Kind: models.DeviceKindPSU,
@@ -815,26 +956,38 @@ func TestConvertToReanimator_BindsDeviceVitals(t *testing.T) {
t.Fatalf("expected one pcie device, got %d", len(out.Hardware.PCIeDevices))
}
pcie := out.Hardware.PCIeDevices[0]
if pcie.TemperatureC != 71 {
t.Fatalf("expected GPU temperature 71C, got %d", pcie.TemperatureC)
}
if pcie.PowerW != 350 {
t.Fatalf("expected GPU power 350W, got %d", pcie.PowerW)
}
if pcie.VoltageV != 12.2 {
t.Fatalf("expected device voltage 12.2V, got %.2f", pcie.VoltageV)
if pcie.TemperatureC != 0 {
t.Fatalf("expected canonical GPU telemetry to stay off the component unless sourced from details/gpu path, got %.2f", pcie.TemperatureC)
}
if len(out.Hardware.PowerSupplies) != 1 {
t.Fatalf("expected one PSU, got %d", len(out.Hardware.PowerSupplies))
}
psu := out.Hardware.PowerSupplies[0]
if psu.InputPowerW != 1400 {
t.Fatalf("expected PSU input power 1400W, got %.2f", psu.InputPowerW)
}
if psu.TemperatureC != 44 {
t.Fatalf("expected PSU temperature 44C, got %d", psu.TemperatureC)
t.Fatalf("expected PSU temperature 44C, got %.2f", psu.TemperatureC)
}
if out.Hardware.Sensors == nil {
t.Fatalf("expected sensors section")
}
if len(out.Hardware.Sensors.Fans) != 1 || out.Hardware.Sensors.Fans[0].RPM != 4200 {
t.Fatalf("expected fan sensor export, got %#v", out.Hardware.Sensors.Fans)
}
if len(out.Hardware.Sensors.Power) != 1 || out.Hardware.Sensors.Power[0].VoltageV != 12.1 {
t.Fatalf("expected power sensor export, got %#v", out.Hardware.Sensors.Power)
}
if len(out.Hardware.Sensors.Temperatures) != 1 || out.Hardware.Sensors.Temperatures[0].Celsius != 71 {
t.Fatalf("expected temperature sensor export, got %#v", out.Hardware.Sensors.Temperatures)
}
if len(out.Hardware.Sensors.Other) != 1 || out.Hardware.Sensors.Other[0].Unit != "%" {
t.Fatalf("expected other sensor export, got %#v", out.Hardware.Sensors.Other)
}
}
func TestConvertToReanimator_PreservesVitalsAcrossCanonicalDedup(t *testing.T) {
func TestConvertToReanimator_PreservesCanonicalDedupWithoutDeviceVitals(t *testing.T) {
input := &models.AnalysisResult{
Filename: "dedup-vitals.json",
Hardware: &models.HardwareConfig{
@@ -872,11 +1025,283 @@ func TestConvertToReanimator_PreservesVitalsAcrossCanonicalDedup(t *testing.T) {
t.Fatalf("expected deduped one pcie entry, got %d", len(out.Hardware.PCIeDevices))
}
got := out.Hardware.PCIeDevices[0]
if got.DeviceClass != "VideoController" {
t.Fatalf("expected GPU to export as VideoController, got %q", got.DeviceClass)
}
if got.TemperatureC != 67 {
t.Fatalf("expected deduped GPU temperature 67C, got %d", got.TemperatureC)
t.Fatalf("expected deduped GPU temperature 67C, got %.2f", got.TemperatureC)
}
if got.PowerW != 330 {
t.Fatalf("expected deduped GPU power 330W, got %d", got.PowerW)
t.Fatalf("expected deduped GPU power 330W, got %.2f", got.PowerW)
}
}
func TestConvertToReanimator_DedupesLooseCanonicalNICAndPCIeEntries(t *testing.T) {
input := &models.AnalysisResult{
Filename: "loose-dedup.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
PCIeDevices: []models.PCIeDevice{
{
Slot: "Slot 4",
DeviceClass: "NetworkController",
VendorID: 0x15b3,
DeviceID: 0x1021,
Manufacturer: "Mellanox",
PartNumber: "MCX623106AC-CDAT",
},
},
NetworkAdapters: []models.NetworkAdapter{
{
Slot: "Slot 4",
Model: "ConnectX-6",
VendorID: 0x15b3,
DeviceID: 0x1021,
Vendor: "Mellanox",
MACAddresses: []string{"00:11:22:33:44:55"},
PortCount: 2,
Present: true,
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.PCIeDevices) != 1 {
t.Fatalf("expected one merged loose-key pcie entry, got %d", len(out.Hardware.PCIeDevices))
}
got := out.Hardware.PCIeDevices[0]
if got.Model == "" {
t.Fatalf("expected merged pcie entry to retain a model, got empty")
}
if len(got.MACAddresses) != 1 || got.MACAddresses[0] != "00:11:22:33:44:55" {
t.Fatalf("expected MACs from NIC side after loose merge, got %#v", got.MACAddresses)
}
}
func TestConvertToReanimator_ExportsContractV24Telemetry(t *testing.T) {
input := &models.AnalysisResult{
Filename: "contract-v24.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
Devices: []models.HardwareDevice{
{
Kind: models.DeviceKindCPU,
Slot: "CPU0",
Model: "INTEL(R) XEON(R) GOLD 6530",
Details: map[string]any{
"socket": 0,
"temperature_c": 61.5,
"power_w": 182.0,
"throttled": false,
"correctable_error_count": int64(4),
"uncorrectable_error_count": int64(1),
"life_remaining_pct": 98.5,
"life_used_pct": 1.5,
},
},
{
Kind: models.DeviceKindMemory,
Slot: "DIMM_A1",
SerialNumber: "MEM-001",
Present: boolPtr(true),
SizeMB: 32768,
Type: "DDR5",
Details: map[string]any{
"temperature_c": 43.0,
"correctable_ecc_error_count": int64(2),
"uncorrectable_ecc_error_count": int64(0),
"life_remaining_pct": 99.0,
"spare_blocks_remaining_pct": 97.0,
"performance_degraded": false,
},
},
{
Kind: models.DeviceKindStorage,
Slot: "U.2-1",
SerialNumber: "SSD-001",
Model: "PM9A3",
Present: boolPtr(true),
Details: map[string]any{
"temperature_c": 38.5,
"power_on_hours": int64(12450),
"unsafe_shutdowns": int64(3),
"written_bytes": int64(9876543210),
"life_remaining_pct": 91.0,
"available_spare_pct": 88.0,
"offline_uncorrectable": int64(0),
},
},
{
Kind: models.DeviceKindPCIe,
Slot: "PCIeCard2",
SerialNumber: "NIC-001",
DeviceClass: "EthernetController",
NUMANode: 1,
Details: map[string]any{
"temperature_c": 48.5,
"power_w": 18.2,
"life_remaining_pct": 95.0,
"ecc_corrected_total": int64(12),
"battery_health_pct": 87.0,
"sfp_temperature_c": 36.2,
"sfp_tx_power_dbm": -1.8,
"sfp_rx_power_dbm": -2.1,
"sfp_bias_ma": 5.5,
},
},
{
Kind: models.DeviceKindPSU,
Slot: "PSU0",
SerialNumber: "PSU-001",
Present: boolPtr(true),
Details: map[string]any{
"life_remaining_pct": 97.0,
"life_used_pct": 3.0,
},
TemperatureC: 39,
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if got := out.Hardware.CPUs[0]; got.TemperatureC != 61.5 || got.PowerW != 182.0 || got.Throttled == nil || *got.Throttled {
t.Fatalf("unexpected CPU telemetry: %#v", got)
}
if got := out.Hardware.Memory[0]; got.TemperatureC != 43.0 || got.CorrectableECCErrorCount != 2 || got.PerformanceDegraded == nil || *got.PerformanceDegraded {
t.Fatalf("unexpected memory telemetry: %#v", got)
}
if got := out.Hardware.Storage[0]; got.TemperatureC != 38.5 || got.PowerOnHours != 12450 || got.LifeRemainingPct != 91.0 {
t.Fatalf("unexpected storage telemetry: %#v", got)
}
if got := out.Hardware.PCIeDevices[0]; got.NUMANode != 1 || got.TemperatureC != 48.5 || got.PowerW != 18.2 || got.SFPTemperatureC != 36.2 {
t.Fatalf("unexpected PCIe telemetry: %#v", got)
}
if got := out.Hardware.PowerSupplies[0]; got.TemperatureC != 39 || got.LifeRemainingPct != 97.0 || got.LifeUsedPct != 3.0 {
t.Fatalf("unexpected PSU telemetry: %#v", got)
}
}
func TestConvertToReanimator_PreservesLegacyStorageAndPSUDetails(t *testing.T) {
input := &models.AnalysisResult{
Filename: "legacy-details.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
Storage: []models.Storage{
{
Slot: "Drive0",
Type: "NVMe",
Model: "NVMe SSD",
SerialNumber: "SSD-001",
Present: true,
Details: map[string]any{
"temperature_c": 38.5,
"power_on_hours": int64(12450),
"life_remaining_pct": 91.0,
},
},
},
PowerSupply: []models.PSU{
{
Slot: "PSU0",
Model: "PSU",
SerialNumber: "PSU-001",
Present: true,
Details: map[string]any{
"temperature_c": 41.0,
"life_remaining_pct": 96.0,
},
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if got := out.Hardware.Storage[0]; got.TemperatureC != 38.5 || got.PowerOnHours != 12450 || got.LifeRemainingPct != 91.0 {
t.Fatalf("expected storage details from legacy model to survive canonical conversion, got %+v", got)
}
if got := out.Hardware.PowerSupplies[0]; got.TemperatureC != 41.0 || got.LifeRemainingPct != 96.0 {
t.Fatalf("expected psu details from legacy model to survive canonical conversion, got %+v", got)
}
}
func TestConvertToReanimator_PreservesLegacyPCIeAndNICDetails(t *testing.T) {
input := &models.AnalysisResult{
Filename: "legacy-pcie-details.json",
Hardware: &models.HardwareConfig{
BoardInfo: models.BoardInfo{SerialNumber: "BOARD-001"},
PCIeDevices: []models.PCIeDevice{
{
Slot: "PCIe 1",
BDF: "0000:17:00.0",
VendorID: 0x10de,
DeviceID: 0x2331,
PartNumber: "H100",
Manufacturer: "NVIDIA",
SerialNumber: "GPU-001",
Details: map[string]any{
"temperature_c": 48.5,
"power_w": 315.0,
"ecc_corrected_total": int64(12),
"battery_health_pct": 87.0,
},
},
},
NetworkAdapters: []models.NetworkAdapter{
{
Slot: "Slot 4",
BDF: "0000:18:00.0",
VendorID: 0x15b3,
DeviceID: 0x1021,
Model: "ConnectX-6",
SerialNumber: "NIC-001",
Present: true,
Details: map[string]any{
"sfp_temperature_c": 34.0,
"sfp_tx_power_dbm": -1.8,
"sfp_rx_power_dbm": -2.1,
},
},
},
},
}
out, err := ConvertToReanimator(input)
if err != nil {
t.Fatalf("ConvertToReanimator() failed: %v", err)
}
if len(out.Hardware.PCIeDevices) != 2 {
t.Fatalf("expected two pcie-class devices, got %d", len(out.Hardware.PCIeDevices))
}
foundGPU := false
foundNIC := false
for _, dev := range out.Hardware.PCIeDevices {
switch dev.SerialNumber {
case "GPU-001":
foundGPU = true
if dev.TemperatureC != 48.5 || dev.PowerW != 315.0 || dev.ECCCorrectedTotal != 12 || dev.BatteryHealthPct != 87.0 {
t.Fatalf("expected GPU telemetry preserved, got %+v", dev)
}
case "NIC-001":
foundNIC = true
if dev.SFPTemperatureC != 34.0 || dev.SFPTXPowerDBm != -1.8 || dev.SFPRXPowerDBm != -2.1 {
t.Fatalf("expected NIC sfp telemetry preserved, got %+v", dev)
}
}
}
if !foundGPU || !foundNIC {
t.Fatalf("expected both gpu and nic pcie-class exports, got %+v", out.Hardware.PCIeDevices)
}
}
@@ -932,3 +1357,42 @@ func TestIsDeviceBoundFirmwareName(t *testing.T) {
}
}
}
// TestIsDeviceBoundFirmwareFQDD verifies that Dell TSR SoftwareIdentity FQDD strings
// (stored in FirmwareInfo.Description) correctly identify device-bound entries.
//
// Regression guard: "InfiniBand.Slot.1-1" (Mellanox ConnectX-6) and "RAID.SL.3-1"
// (PERC H755 Front) were not filtered because only "raid.backplane." was listed and
// "infiniband." was absent. Both firmware entries leaked into hardware.firmware on
// PowerEdge R6625 (8VS2LG4). (2026-03-15)
func TestIsDeviceBoundFirmwareFQDD(t *testing.T) {
cases := []struct {
desc string
want bool
}{
// Dell TSR SoftwareIdentity FQDDs — device-bound, must be excluded
{"InfiniBand.Slot.1-1", true}, // Mellanox ConnectX-6
{"InfiniBand.Slot.2-1", true}, // any InfiniBand slot
{"RAID.SL.3-1", true}, // PERC H755 Front
{"RAID.Integrated.1-1", true}, // embedded RAID controller
{"RAID.Backplane.Firmware.0", true}, // backplane (previously covered)
{"NIC.Integrated.1-1-1", true}, // embedded NIC
{"NIC.Slot.1-1-1", true}, // slotted NIC
{"PSU.Slot.1", true}, // PSU
{"Disk.Bay.0:Enclosure.Internal.0-1:RAID.SL.3-1", true},
{"GPU.Slot.1-1", true},
{"FC.Slot.1-1", true}, // Fibre Channel HBA
// System-level descriptions — must NOT be excluded
{"system bios", false},
{"idrac lifecycle", false},
{"idrac card", false},
{"storage controller", false}, // legacy description before fqdd fix
{"", false},
}
for _, tc := range cases {
got := isDeviceBoundFirmwareFQDD(tc.desc)
if got != tc.want {
t.Errorf("isDeviceBoundFirmwareFQDD(%q) = %v, want %v", tc.desc, got, tc.want)
}
}
}

View File

@@ -19,6 +19,7 @@ type ReanimatorHardware struct {
Storage []ReanimatorStorage `json:"storage,omitempty"`
PCIeDevices []ReanimatorPCIe `json:"pcie_devices,omitempty"`
PowerSupplies []ReanimatorPSU `json:"power_supplies,omitempty"`
Sensors *ReanimatorSensors `json:"sensors,omitempty"`
}
// ReanimatorBoard represents motherboard/server information
@@ -36,11 +37,6 @@ type ReanimatorFirmware struct {
Version string `json:"version"`
}
type ReanimatorStatusAtCollection struct {
Status string `json:"status"`
At string `json:"at"`
}
type ReanimatorStatusHistoryEntry struct {
Status string `json:"status"`
ChangedAt string `json:"changed_at"`
@@ -49,90 +45,136 @@ type ReanimatorStatusHistoryEntry struct {
// ReanimatorCPU represents processor information
type ReanimatorCPU struct {
Socket int `json:"socket"`
Model string `json:"model"`
Cores int `json:"cores,omitempty"`
Threads int `json:"threads,omitempty"`
FrequencyMHz int `json:"frequency_mhz,omitempty"`
MaxFrequencyMHz int `json:"max_frequency_mhz,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
Socket int `json:"socket"`
Model string `json:"model,omitempty"`
Cores int `json:"cores,omitempty"`
Threads int `json:"threads,omitempty"`
FrequencyMHz int `json:"frequency_mhz,omitempty"`
MaxFrequencyMHz int `json:"max_frequency_mhz,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"`
PowerW float64 `json:"power_w,omitempty"`
Throttled *bool `json:"throttled,omitempty"`
CorrectableErrorCount int64 `json:"correctable_error_count,omitempty"`
UncorrectableErrorCount int64 `json:"uncorrectable_error_count,omitempty"`
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Present *bool `json:"present,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// ReanimatorMemory represents a memory module (DIMM)
type ReanimatorMemory struct {
Slot string `json:"slot"`
Location string `json:"location,omitempty"`
Present bool `json:"present"`
SizeMB int `json:"size_mb,omitempty"`
Type string `json:"type,omitempty"`
MaxSpeedMHz int `json:"max_speed_mhz,omitempty"`
CurrentSpeedMHz int `json:"current_speed_mhz,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
Slot string `json:"slot"`
Location string `json:"location,omitempty"`
Present *bool `json:"present,omitempty"`
SizeMB int `json:"size_mb,omitempty"`
Type string `json:"type,omitempty"`
MaxSpeedMHz int `json:"max_speed_mhz,omitempty"`
CurrentSpeedMHz int `json:"current_speed_mhz,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"`
CorrectableECCErrorCount int64 `json:"correctable_ecc_error_count,omitempty"`
UncorrectableECCErrorCount int64 `json:"uncorrectable_ecc_error_count,omitempty"`
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
SpareBlocksRemainingPct float64 `json:"spare_blocks_remaining_pct,omitempty"`
PerformanceDegraded *bool `json:"performance_degraded,omitempty"`
DataLossDetected *bool `json:"data_loss_detected,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// ReanimatorStorage represents a storage device
type ReanimatorStorage struct {
Slot string `json:"slot"`
Type string `json:"type,omitempty"`
Model string `json:"model"`
SizeGB int `json:"size_gb,omitempty"`
SerialNumber string `json:"serial_number"`
Manufacturer string `json:"manufacturer,omitempty"`
Firmware string `json:"firmware,omitempty"`
Interface string `json:"interface,omitempty"`
Present bool `json:"present"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
Slot string `json:"slot"`
Type string `json:"type,omitempty"`
Model string `json:"model"`
SizeGB int `json:"size_gb,omitempty"`
SerialNumber string `json:"serial_number"`
Manufacturer string `json:"manufacturer,omitempty"`
Firmware string `json:"firmware,omitempty"`
Interface string `json:"interface,omitempty"`
Present *bool `json:"present,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"`
PowerOnHours int64 `json:"power_on_hours,omitempty"`
PowerCycles int64 `json:"power_cycles,omitempty"`
UnsafeShutdowns int64 `json:"unsafe_shutdowns,omitempty"`
MediaErrors int64 `json:"media_errors,omitempty"`
ErrorLogEntries int64 `json:"error_log_entries,omitempty"`
WrittenBytes int64 `json:"written_bytes,omitempty"`
ReadBytes int64 `json:"read_bytes,omitempty"`
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"`
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
AvailableSparePct float64 `json:"available_spare_pct,omitempty"`
ReallocatedSectors int64 `json:"reallocated_sectors,omitempty"`
CurrentPendingSectors int64 `json:"current_pending_sectors,omitempty"`
OfflineUncorrectable int64 `json:"offline_uncorrectable,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// ReanimatorPCIe represents a PCIe device
type ReanimatorPCIe struct {
Slot string `json:"slot"`
VendorID int `json:"vendor_id,omitempty"`
DeviceID int `json:"device_id,omitempty"`
BDF string `json:"bdf,omitempty"`
DeviceClass string `json:"device_class,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
Model string `json:"model,omitempty"`
LinkWidth int `json:"link_width,omitempty"`
LinkSpeed string `json:"link_speed,omitempty"`
MaxLinkWidth int `json:"max_link_width,omitempty"`
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
TemperatureC int `json:"temperature_c,omitempty"`
PowerW int `json:"power_w,omitempty"`
VoltageV float64 `json:"voltage_v,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
Slot string `json:"slot"`
VendorID int `json:"vendor_id,omitempty"`
DeviceID int `json:"device_id,omitempty"`
NUMANode int `json:"numa_node,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"`
PowerW float64 `json:"power_w,omitempty"`
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
ECCCorrectedTotal int64 `json:"ecc_corrected_total,omitempty"`
ECCUncorrectedTotal int64 `json:"ecc_uncorrected_total,omitempty"`
HWSlowdown *bool `json:"hw_slowdown,omitempty"`
BatteryChargePct float64 `json:"battery_charge_pct,omitempty"`
BatteryHealthPct float64 `json:"battery_health_pct,omitempty"`
BatteryTemperatureC float64 `json:"battery_temperature_c,omitempty"`
BatteryVoltageV float64 `json:"battery_voltage_v,omitempty"`
BatteryReplaceRequired *bool `json:"battery_replace_required,omitempty"`
SFPTemperatureC float64 `json:"sfp_temperature_c,omitempty"`
SFPTXPowerDBm float64 `json:"sfp_tx_power_dbm,omitempty"`
SFPRXPowerDBm float64 `json:"sfp_rx_power_dbm,omitempty"`
SFPVoltageV float64 `json:"sfp_voltage_v,omitempty"`
SFPBiasMA float64 `json:"sfp_bias_ma,omitempty"`
BDF string `json:"bdf,omitempty"`
DeviceClass string `json:"device_class,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
Model string `json:"model,omitempty"`
LinkWidth int `json:"link_width,omitempty"`
LinkSpeed string `json:"link_speed,omitempty"`
MaxLinkWidth int `json:"max_link_width,omitempty"`
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
MACAddresses []string `json:"mac_addresses,omitempty"`
Present *bool `json:"present,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
// ReanimatorPSU represents a power supply unit
type ReanimatorPSU struct {
Slot string `json:"slot"`
Present bool `json:"present"`
Present *bool `json:"present,omitempty"`
Model string `json:"model,omitempty"`
Vendor string `json:"vendor,omitempty"`
WattageW int `json:"wattage_w,omitempty"`
@@ -141,13 +183,54 @@ type ReanimatorPSU struct {
Firmware string `json:"firmware,omitempty"`
Status string `json:"status,omitempty"`
InputType string `json:"input_type,omitempty"`
InputPowerW int `json:"input_power_w,omitempty"`
OutputPowerW int `json:"output_power_w,omitempty"`
InputPowerW float64 `json:"input_power_w,omitempty"`
OutputPowerW float64 `json:"output_power_w,omitempty"`
InputVoltage float64 `json:"input_voltage,omitempty"`
TemperatureC int `json:"temperature_c,omitempty"`
TemperatureC float64 `json:"temperature_c,omitempty"`
LifeRemainingPct float64 `json:"life_remaining_pct,omitempty"`
LifeUsedPct float64 `json:"life_used_pct,omitempty"`
StatusCheckedAt string `json:"status_checked_at,omitempty"`
StatusChangedAt string `json:"status_changed_at,omitempty"`
StatusAtCollect *ReanimatorStatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []ReanimatorStatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
}
type ReanimatorSensors struct {
Fans []ReanimatorFanSensor `json:"fans,omitempty"`
Power []ReanimatorPowerSensor `json:"power,omitempty"`
Temperatures []ReanimatorTemperatureSensor `json:"temperatures,omitempty"`
Other []ReanimatorOtherSensor `json:"other,omitempty"`
}
type ReanimatorFanSensor struct {
Name string `json:"name"`
Location string `json:"location,omitempty"`
RPM int `json:"rpm,omitempty"`
Status string `json:"status,omitempty"`
}
type ReanimatorPowerSensor struct {
Name string `json:"name"`
Location string `json:"location,omitempty"`
VoltageV float64 `json:"voltage_v,omitempty"`
CurrentA float64 `json:"current_a,omitempty"`
PowerW float64 `json:"power_w,omitempty"`
Status string `json:"status,omitempty"`
}
type ReanimatorTemperatureSensor struct {
Name string `json:"name"`
Location string `json:"location,omitempty"`
Celsius float64 `json:"celsius,omitempty"`
ThresholdWarningCelsius float64 `json:"threshold_warning_celsius,omitempty"`
ThresholdCriticalCelsius float64 `json:"threshold_critical_celsius,omitempty"`
Status string `json:"status,omitempty"`
}
type ReanimatorOtherSensor struct {
Name string `json:"name"`
Location string `json:"location,omitempty"`
Value float64 `json:"value,omitempty"`
Unit string `json:"unit,omitempty"`
Status string `json:"status,omitempty"`
}

View File

@@ -9,17 +9,17 @@ const (
// AnalysisResult contains all parsed data from an archive
type AnalysisResult struct {
Filename string `json:"filename"`
SourceType string `json:"source_type,omitempty"` // archive | api
Protocol string `json:"protocol,omitempty"` // redfish | ipmi
TargetHost string `json:"target_host,omitempty"` // BMC host for live collect
SourceTimezone string `json:"source_timezone,omitempty"` // Source timezone/offset used during collection (e.g. +08:00)
CollectedAt time.Time `json:"collected_at,omitempty"` // Collection/upload timestamp
RawPayloads map[string]any `json:"raw_payloads,omitempty"` // Additional source payloads (e.g. Redfish tree)
Events []Event `json:"events"`
FRU []FRUInfo `json:"fru"`
Sensors []SensorReading `json:"sensors"`
Hardware *HardwareConfig `json:"hardware"`
Filename string `json:"filename"`
SourceType string `json:"source_type,omitempty"` // archive | api
Protocol string `json:"protocol,omitempty"` // redfish | ipmi
TargetHost string `json:"target_host,omitempty"` // BMC host for live collect
SourceTimezone string `json:"source_timezone,omitempty"` // Source timezone/offset used during collection (e.g. +08:00)
CollectedAt time.Time `json:"collected_at,omitempty"` // Collection/upload timestamp
RawPayloads map[string]any `json:"raw_payloads,omitempty"` // Additional source payloads (e.g. Redfish tree)
Events []Event `json:"events"`
FRU []FRUInfo `json:"fru"`
Sensors []SensorReading `json:"sensors"`
Hardware *HardwareConfig `json:"hardware"`
}
// Event represents a single log event
@@ -110,43 +110,45 @@ const (
// HardwareDevice is canonical device inventory used across UI and exports.
type HardwareDevice struct {
ID string `json:"id"`
Kind string `json:"kind"`
Source string `json:"source,omitempty"`
Slot string `json:"slot,omitempty"`
Location string `json:"location,omitempty"`
BDF string `json:"bdf,omitempty"`
DeviceClass string `json:"device_class,omitempty"`
VendorID int `json:"vendor_id,omitempty"`
DeviceID int `json:"device_id,omitempty"`
Model string `json:"model,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Type string `json:"type,omitempty"`
Interface string `json:"interface,omitempty"`
Present *bool `json:"present,omitempty"`
SizeMB int `json:"size_mb,omitempty"`
SizeGB int `json:"size_gb,omitempty"`
Cores int `json:"cores,omitempty"`
Threads int `json:"threads,omitempty"`
FrequencyMHz int `json:"frequency_mhz,omitempty"`
MaxFreqMHz int `json:"max_frequency_mhz,omitempty"`
PortCount int `json:"port_count,omitempty"`
PortType string `json:"port_type,omitempty"`
MACAddresses []string `json:"mac_addresses,omitempty"`
LinkWidth int `json:"link_width,omitempty"`
LinkSpeed string `json:"link_speed,omitempty"`
MaxLinkWidth int `json:"max_link_width,omitempty"`
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
WattageW int `json:"wattage_w,omitempty"`
InputType string `json:"input_type,omitempty"`
InputPowerW int `json:"input_power_w,omitempty"`
OutputPowerW int `json:"output_power_w,omitempty"`
InputVoltage float64 `json:"input_voltage,omitempty"`
TemperatureC int `json:"temperature_c,omitempty"`
Status string `json:"status,omitempty"`
ID string `json:"id"`
Kind string `json:"kind"`
Source string `json:"source,omitempty"`
Slot string `json:"slot,omitempty"`
Location string `json:"location,omitempty"`
BDF string `json:"bdf,omitempty"`
DeviceClass string `json:"device_class,omitempty"`
VendorID int `json:"vendor_id,omitempty"`
DeviceID int `json:"device_id,omitempty"`
Model string `json:"model,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Type string `json:"type,omitempty"`
Interface string `json:"interface,omitempty"`
Present *bool `json:"present,omitempty"`
SizeMB int `json:"size_mb,omitempty"`
SizeGB int `json:"size_gb,omitempty"`
Cores int `json:"cores,omitempty"`
Threads int `json:"threads,omitempty"`
FrequencyMHz int `json:"frequency_mhz,omitempty"`
MaxFreqMHz int `json:"max_frequency_mhz,omitempty"`
PortCount int `json:"port_count,omitempty"`
PortType string `json:"port_type,omitempty"`
MACAddresses []string `json:"mac_addresses,omitempty"`
LinkWidth int `json:"link_width,omitempty"`
LinkSpeed string `json:"link_speed,omitempty"`
MaxLinkWidth int `json:"max_link_width,omitempty"`
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
WattageW int `json:"wattage_w,omitempty"`
InputType string `json:"input_type,omitempty"`
InputPowerW int `json:"input_power_w,omitempty"`
OutputPowerW int `json:"output_power_w,omitempty"`
InputVoltage float64 `json:"input_voltage,omitempty"`
TemperatureC int `json:"temperature_c,omitempty"`
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"` // 0-100 %; nil = not reported
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
Status string `json:"status,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
@@ -167,14 +169,14 @@ type FirmwareInfo struct {
// BoardInfo represents motherboard/system information
type BoardInfo struct {
Manufacturer string `json:"manufacturer,omitempty"`
ProductName string `json:"product_name,omitempty"`
Description string `json:"description,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Version string `json:"version,omitempty"`
UUID string `json:"uuid,omitempty"`
BMCMACAddress string `json:"bmc_mac_address,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
ProductName string `json:"product_name,omitempty"`
Description string `json:"description,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Version string `json:"version,omitempty"`
UUID string `json:"uuid,omitempty"`
BMCMACAddress string `json:"bmc_mac_address,omitempty"`
}
// CPU represents processor information
@@ -194,11 +196,12 @@ type CPU struct {
SerialNumber string `json:"serial_number,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
Details map[string]any `json:"details,omitempty"`
}
// MemoryDIMM represents a memory module
@@ -218,31 +221,34 @@ type MemoryDIMM struct {
Status string `json:"status,omitempty"`
Ranks int `json:"ranks,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
Details map[string]any `json:"details,omitempty"`
}
// Storage represents a storage device
type Storage struct {
Slot string `json:"slot"`
Type string `json:"type"`
Model string `json:"model"`
Description string `json:"description,omitempty"`
SizeGB int `json:"size_gb"`
SerialNumber string `json:"serial_number,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
Firmware string `json:"firmware,omitempty"`
Interface string `json:"interface,omitempty"`
Present bool `json:"present"`
Location string `json:"location,omitempty"` // Front/Rear
BackplaneID int `json:"backplane_id,omitempty"`
Status string `json:"status,omitempty"`
Slot string `json:"slot"`
Type string `json:"type"`
Model string `json:"model"`
Description string `json:"description,omitempty"`
SizeGB int `json:"size_gb"`
SerialNumber string `json:"serial_number,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
Firmware string `json:"firmware,omitempty"`
Interface string `json:"interface,omitempty"`
Present bool `json:"present"`
Location string `json:"location,omitempty"` // Front/Rear
BackplaneID int `json:"backplane_id,omitempty"`
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"` // 0-100 %; nil = not reported
Status string `json:"status,omitempty"`
Details map[string]any `json:"details,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
@@ -250,15 +256,15 @@ type Storage struct {
// StorageVolume represents a logical storage volume (RAID/VROC/etc.).
type StorageVolume struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Controller string `json:"controller,omitempty"`
RAIDLevel string `json:"raid_level,omitempty"`
SizeGB int `json:"size_gb,omitempty"`
CapacityBytes int64 `json:"capacity_bytes,omitempty"`
Status string `json:"status,omitempty"`
Bootable bool `json:"bootable,omitempty"`
Encrypted bool `json:"encrypted,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Controller string `json:"controller,omitempty"`
RAIDLevel string `json:"raid_level,omitempty"`
SizeGB int `json:"size_gb,omitempty"`
CapacityBytes int64 `json:"capacity_bytes,omitempty"`
Status string `json:"status,omitempty"`
Bootable bool `json:"bootable,omitempty"`
Encrypted bool `json:"encrypted,omitempty"`
}
// PCIeDevice represents a PCIe device
@@ -277,13 +283,15 @@ type PCIeDevice struct {
PartNumber string `json:"part_number,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
MACAddresses []string `json:"mac_addresses,omitempty"`
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
Status string `json:"status,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
Details map[string]any `json:"details,omitempty"`
}
// NIC represents a network interface card
@@ -298,25 +306,26 @@ type NIC struct {
// PSU represents a power supply unit
type PSU struct {
Slot string `json:"slot"`
Present bool `json:"present"`
Model string `json:"model"`
Description string `json:"description,omitempty"`
Vendor string `json:"vendor,omitempty"`
WattageW int `json:"wattage_w,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Status string `json:"status,omitempty"`
InputType string `json:"input_type,omitempty"`
InputPowerW int `json:"input_power_w,omitempty"`
OutputPowerW int `json:"output_power_w,omitempty"`
InputVoltage float64 `json:"input_voltage,omitempty"`
OutputVoltage float64 `json:"output_voltage,omitempty"`
TemperatureC int `json:"temperature_c,omitempty"`
Slot string `json:"slot"`
Present bool `json:"present"`
Model string `json:"model"`
Description string `json:"description,omitempty"`
Vendor string `json:"vendor,omitempty"`
WattageW int `json:"wattage_w,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Firmware string `json:"firmware,omitempty"`
Status string `json:"status,omitempty"`
InputType string `json:"input_type,omitempty"`
InputPowerW int `json:"input_power_w,omitempty"`
OutputPowerW int `json:"output_power_w,omitempty"`
InputVoltage float64 `json:"input_voltage,omitempty"`
OutputVoltage float64 `json:"output_voltage,omitempty"`
TemperatureC int `json:"temperature_c,omitempty"`
Details map[string]any `json:"details,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
@@ -353,11 +362,12 @@ type GPU struct {
CurrentLinkSpeed string `json:"current_link_speed,omitempty"`
Status string `json:"status,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
Details map[string]any `json:"details,omitempty"`
}
// NetworkAdapter represents a network adapter with detailed info
@@ -365,6 +375,7 @@ type NetworkAdapter struct {
Slot string `json:"slot"`
Location string `json:"location"`
Present bool `json:"present"`
BDF string `json:"bdf,omitempty"`
Model string `json:"model"`
Description string `json:"description,omitempty"`
Vendor string `json:"vendor,omitempty"`
@@ -376,11 +387,17 @@ type NetworkAdapter struct {
PortCount int `json:"port_count,omitempty"`
PortType string `json:"port_type,omitempty"`
MACAddresses []string `json:"mac_addresses,omitempty"`
LinkWidth int `json:"link_width,omitempty"`
LinkSpeed string `json:"link_speed,omitempty"`
MaxLinkWidth int `json:"max_link_width,omitempty"`
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
Status string `json:"status,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
StatusChangedAt *time.Time `json:"status_changed_at,omitempty"`
StatusAtCollect *StatusAtCollection `json:"status_at_collection,omitempty"`
StatusHistory []StatusHistoryEntry `json:"status_history,omitempty"`
ErrorDescription string `json:"error_description,omitempty"`
Details map[string]any `json:"details,omitempty"`
}

View File

@@ -16,6 +16,7 @@ import (
"git.mchus.pro/mchus/logpile/internal/models"
"git.mchus.pro/mchus/logpile/internal/parser"
"git.mchus.pro/mchus/logpile/internal/parser/vendors/pciids"
)
const parserVersion = "3.0"
@@ -199,7 +200,7 @@ func parseDCIMViewXML(content []byte, result *models.AnalysisResult) {
parsePowerSupplyView(props, result)
case "DCIM_PCIDeviceView":
parsePCIeDeviceView(props, result)
case "DCIM_NICView":
case "DCIM_NICView", "DCIM_InfiniBandView":
parseNICView(props, result)
case "DCIM_VideoView":
parseVideoView(props, result)
@@ -374,6 +375,10 @@ func parsePhysicalDiskView(props map[string]string, result *models.AnalysisResul
Location: strings.TrimSpace(props["devicedescription"]),
Status: normalizeStatus(firstNonEmpty(props["raidstatus"], props["primarystatus"])),
}
if v := strings.TrimSpace(props["remainingratedwriteendurance"]); v != "" {
n := parseIntLoose(v)
st.RemainingEndurancePct = &n
}
result.Hardware.Storage = append(result.Hardware.Storage, st)
}
@@ -437,11 +442,18 @@ var pcieFQDDNoisePrefix = []string{
"SMBus.Embedded.",
"AHCI.Embedded.",
"Video.Embedded.",
"NIC.Embedded.",
// All NIC FQDD classes are parsed from DCIM_NICView / DCIM_InfiniBandView into
// NetworkAdapters with model, MAC, firmware, and VendorID/DeviceID. The
// DCIM_PCIDeviceView duplicate carries only DataBusWidth ("Unknown", "16x or x16")
// and no useful extra data, so suppress it here.
"NIC.",
"InfiniBand.",
}
func parsePCIeDeviceView(props map[string]string, result *models.AnalysisResult) {
desc := strings.TrimSpace(firstNonEmpty(props["devicedescription"], props["description"]))
// "description" is the chip/device model (e.g. "MT28908 Family [ConnectX-6]"); prefer
// it over "devicedescription" which is the location string ("InfiniBand in Slot 1 Port 1").
desc := strings.TrimSpace(firstNonEmpty(props["description"], props["devicedescription"]))
fqdd := strings.TrimSpace(firstNonEmpty(props["fqdd"], props["instanceid"]))
if desc == "" && fqdd == "" {
return
@@ -451,14 +463,26 @@ func parsePCIeDeviceView(props map[string]string, result *models.AnalysisResult)
return
}
}
vendorID := parseHexOrDec(firstNonEmpty(props["pcivendorid"], props["vendorid"]))
deviceID := parseHexOrDec(firstNonEmpty(props["pcideviceid"], props["deviceid"]))
manufacturer := strings.TrimSpace(props["manufacturer"])
// General rule: if chip model not found in logs but PCI IDs are known, resolve from pci.ids
if desc == "" && vendorID != 0 && deviceID != 0 {
desc = pciids.DeviceName(vendorID, deviceID)
}
if manufacturer == "" && vendorID != 0 {
manufacturer = pciids.VendorName(vendorID)
}
p := models.PCIeDevice{
Slot: fqdd,
Description: desc,
VendorID: parseHexOrDec(firstNonEmpty(props["pcivendorid"], props["vendorid"])),
DeviceID: parseHexOrDec(firstNonEmpty(props["pcideviceid"], props["deviceid"])),
VendorID: vendorID,
DeviceID: deviceID,
BDF: formatBDF(props["busnumber"], props["devicenumber"], props["functionnumber"]),
DeviceClass: strings.TrimSpace(props["databuswidth"]),
Manufacturer: strings.TrimSpace(props["manufacturer"]),
Manufacturer: manufacturer,
NUMANode: parseIntLoose(props["cpuaffinity"]),
Status: normalizeStatus(props["primarystatus"]),
}
result.Hardware.PCIeDevices = append(result.Hardware.PCIeDevices, p)
@@ -471,15 +495,31 @@ func parseNICView(props map[string]string, result *models.AnalysisResult) {
return
}
mac := strings.TrimSpace(firstNonEmpty(props["currentmacaddress"], props["permanentmacaddress"]))
vendorID := parseHexOrDec(firstNonEmpty(props["pcivendorid"], props["vendorid"]))
deviceID := parseHexOrDec(firstNonEmpty(props["pcideviceid"], props["deviceid"]))
vendor := strings.TrimSpace(firstNonEmpty(props["vendorname"], props["manufacturer"]))
// Prefer pci.ids chip model over generic ProductName when PCI IDs are available.
// Dell TSR often reports a marketing name (e.g. "Mellanox Network Adapter") while
// pci.ids has the precise chip identifier (e.g. "MT28908 Family [ConnectX-6]").
if vendorID != 0 && deviceID != 0 {
if chipModel := pciids.DeviceName(vendorID, deviceID); chipModel != "" {
model = chipModel
}
if vendor == "" {
vendor = pciids.VendorName(vendorID)
}
}
n := models.NetworkAdapter{
Slot: fqdd,
Location: strings.TrimSpace(firstNonEmpty(props["devicedescription"], fqdd)),
Present: true,
Model: model,
Description: strings.TrimSpace(props["protocol"]),
Vendor: strings.TrimSpace(firstNonEmpty(props["vendorname"], props["manufacturer"])),
VendorID: parseHexOrDec(firstNonEmpty(props["pcivendorid"], props["vendorid"])),
DeviceID: parseHexOrDec(firstNonEmpty(props["pcideviceid"], props["deviceid"])),
Vendor: vendor,
VendorID: vendorID,
DeviceID: deviceID,
SerialNumber: strings.TrimSpace(props["serialnumber"]),
PartNumber: strings.TrimSpace(props["partnumber"]),
Firmware: strings.TrimSpace(firstNonEmpty(
@@ -489,6 +529,7 @@ func parseNICView(props map[string]string, result *models.AnalysisResult) {
props["controllerbiosversion"],
)),
PortCount: inferPortCountFromFQDD(fqdd),
NUMANode: parseIntLoose(props["cpuaffinity"]),
Status: normalizeStatus(props["primarystatus"]),
}
if mac != "" {
@@ -542,10 +583,11 @@ func parseControllerView(props map[string]string, result *models.AnalysisResult)
DeviceClass: "storage-controller",
Manufacturer: strings.TrimSpace(firstNonEmpty(props["devicecardmanufacturer"], props["manufacturer"])),
PartNumber: strings.TrimSpace(firstNonEmpty(props["ppid"], props["boardpartnumber"])),
NUMANode: parseIntLoose(props["cpuaffinity"]),
Status: normalizeStatus(props["primarystatus"]),
})
addFirmware(result, firstNonEmpty(name, fqdd), props["controllerfirmwareversion"], "storage controller")
addFirmware(result, firstNonEmpty(name, fqdd), props["controllerfirmwareversion"], firstNonEmpty(fqdd, "storage controller"))
}
func parseControllerBatteryView(props map[string]string, result *models.AnalysisResult) {
@@ -1131,6 +1173,7 @@ func mergeStorage(dst *models.Storage, src models.Storage) {
}
setIfEmpty(&dst.Location, src.Location)
setIfEmpty(&dst.Status, src.Status)
dst.Details = mergeDellDetails(dst.Details, src.Details)
}
func dedupeVolumes(items []models.StorageVolume) []models.StorageVolume {
@@ -1202,6 +1245,22 @@ func mergePSU(dst *models.PSU, src models.PSU) {
dst.InputVoltage = src.InputVoltage
}
setIfEmpty(&dst.InputType, src.InputType)
dst.Details = mergeDellDetails(dst.Details, src.Details)
}
func mergeDellDetails(primary, secondary map[string]any) map[string]any {
if len(secondary) == 0 {
return primary
}
if primary == nil {
primary = make(map[string]any, len(secondary))
}
for key, value := range secondary {
if _, ok := primary[key]; !ok {
primary[key] = value
}
}
return primary
}
func dedupeNetworkAdapters(items []models.NetworkAdapter) []models.NetworkAdapter {

View File

@@ -204,6 +204,262 @@ func TestParseNestedTSRZip(t *testing.T) {
}
}
// TestParseDellPhysicalDiskEndurance verifies that RemainingRatedWriteEndurance from
// DCIM_PhysicalDiskView is parsed into Storage.RemainingEndurancePct.
func TestParseDellPhysicalDiskEndurance(t *testing.T) {
const viewXML = `<CIM><MESSAGE><SIMPLEREQ>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SystemView">
<PROPERTY NAME="Manufacturer"><VALUE>Dell Inc.</VALUE></PROPERTY>
<PROPERTY NAME="Model"><VALUE>PowerEdge R6625</VALUE></PROPERTY>
<PROPERTY NAME="ServiceTag"><VALUE>8VS2LG4</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_PhysicalDiskView">
<PROPERTY NAME="FQDD"><VALUE>Disk.Bay.0:Enclosure.Internal.0-1:RAID.SL.3-1</VALUE></PROPERTY>
<PROPERTY NAME="Slot"><VALUE>0</VALUE></PROPERTY>
<PROPERTY NAME="Model"><VALUE>HFS480G3H2X069N</VALUE></PROPERTY>
<PROPERTY NAME="SerialNumber"><VALUE>ESEAN5254I030B26B</VALUE></PROPERTY>
<PROPERTY NAME="SizeInBytes"><VALUE>479559942144</VALUE></PROPERTY>
<PROPERTY NAME="MediaType"><VALUE>Solid State Drive</VALUE></PROPERTY>
<PROPERTY NAME="BusProtocol"><VALUE>SATA</VALUE></PROPERTY>
<PROPERTY NAME="Revision"><VALUE>DZ03</VALUE></PROPERTY>
<PROPERTY NAME="RemainingRatedWriteEndurance"><VALUE>100</VALUE><DisplayValue>100 %</DisplayValue></PROPERTY>
<PROPERTY NAME="PrimaryStatus"><VALUE>1</VALUE><DisplayValue>OK</DisplayValue></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_PhysicalDiskView">
<PROPERTY NAME="FQDD"><VALUE>Disk.Bay.1:Enclosure.Internal.0-1:RAID.SL.3-1</VALUE></PROPERTY>
<PROPERTY NAME="Slot"><VALUE>1</VALUE></PROPERTY>
<PROPERTY NAME="Model"><VALUE>TOSHIBA MG08ADA800E</VALUE></PROPERTY>
<PROPERTY NAME="SerialNumber"><VALUE>X1G0A0YXFVVG</VALUE></PROPERTY>
<PROPERTY NAME="SizeInBytes"><VALUE>8001563222016</VALUE></PROPERTY>
<PROPERTY NAME="MediaType"><VALUE>Hard Disk Drive</VALUE></PROPERTY>
<PROPERTY NAME="BusProtocol"><VALUE>SAS</VALUE></PROPERTY>
<PROPERTY NAME="Revision"><VALUE>0104</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
</SIMPLEREQ></MESSAGE></CIM>`
inner := makeZipArchive(t, map[string][]byte{
"tsr/metadata.json": []byte(`{"Make":"Dell Inc.","Model":"PowerEdge R6625","ServiceTag":"8VS2LG4"}`),
"tsr/hardware/sysinfo/inventory/sysinfo_DCIM_View.xml": []byte(viewXML),
})
p := &Parser{}
result, err := p.Parse([]parser.ExtractedFile{
{Path: "signature", Content: []byte("ok")},
{Path: "TSR20260306141852_8VS2LG4.pl.zip", Content: inner},
})
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if len(result.Hardware.Storage) != 2 {
t.Fatalf("expected 2 storage devices, got %d", len(result.Hardware.Storage))
}
ssd := result.Hardware.Storage[0]
if ssd.RemainingEndurancePct == nil {
t.Fatalf("SSD slot 0: expected RemainingEndurancePct to be set")
}
if *ssd.RemainingEndurancePct != 100 {
t.Errorf("SSD slot 0: expected RemainingEndurancePct=100, got %d", *ssd.RemainingEndurancePct)
}
hdd := result.Hardware.Storage[1]
if hdd.RemainingEndurancePct != nil {
t.Errorf("HDD slot 1: expected RemainingEndurancePct absent, got %d", *hdd.RemainingEndurancePct)
}
}
// TestParseDellInfiniBandView verifies that DCIM_InfiniBandView entries are parsed as
// NetworkAdapters (not PCIe devices) and that the corresponding SoftwareIdentity firmware
// entry with FQDD "InfiniBand.Slot.*" does not leak into hardware.firmware.
//
// Regression guard: PowerEdge R6625 (8VS2LG4) — "Mellanox Network Adapter" version
// "20.39.35.60" appeared in hardware.firmware because DCIM_InfiniBandView was ignored
// (device ended up only in PCIeDevices with model "16x or x16") and SoftwareIdentity
// FQDD "InfiniBand.Slot.1-1" was not filtered. (2026-03-15)
func TestParseDellInfiniBandView(t *testing.T) {
const viewXML = `<CIM><MESSAGE><SIMPLEREQ>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SystemView">
<PROPERTY NAME="Manufacturer"><VALUE>Dell Inc.</VALUE></PROPERTY>
<PROPERTY NAME="Model"><VALUE>PowerEdge R6625</VALUE></PROPERTY>
<PROPERTY NAME="ServiceTag"><VALUE>8VS2LG4</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_InfiniBandView">
<PROPERTY NAME="FQDD"><VALUE>InfiniBand.Slot.1-1</VALUE></PROPERTY>
<PROPERTY NAME="DeviceDescription"><VALUE>InfiniBand in Slot 1 Port 1</VALUE></PROPERTY>
<PROPERTY NAME="CurrentMACAddress"><VALUE>00:1C:FD:D7:5A:E6</VALUE></PROPERTY>
<PROPERTY NAME="FamilyVersion"><VALUE>20.39.35.60</VALUE></PROPERTY>
<PROPERTY NAME="EFIVersion"><VALUE>14.32.17</VALUE></PROPERTY>
<PROPERTY NAME="PCIVendorID"><VALUE>15B3</VALUE></PROPERTY>
<PROPERTY NAME="PCIDeviceID"><VALUE>101B</VALUE></PROPERTY>
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_PCIDeviceView">
<PROPERTY NAME="FQDD"><VALUE>InfiniBand.Slot.1-1</VALUE></PROPERTY>
<PROPERTY NAME="Description"><VALUE>MT28908 Family [ConnectX-6]</VALUE></PROPERTY>
<PROPERTY NAME="DeviceDescription"><VALUE>InfiniBand in Slot 1 Port 1</VALUE></PROPERTY>
<PROPERTY NAME="Manufacturer"><VALUE>Mellanox Technologies</VALUE></PROPERTY>
<PROPERTY NAME="PCIVendorID"><VALUE>15B3</VALUE></PROPERTY>
<PROPERTY NAME="PCIDeviceID"><VALUE>101B</VALUE></PROPERTY>
<PROPERTY NAME="DataBusWidth"><DisplayValue>16x or x16</DisplayValue></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_ControllerView">
<PROPERTY NAME="FQDD"><VALUE>RAID.SL.3-1</VALUE></PROPERTY>
<PROPERTY NAME="ProductName"><VALUE>PERC H755 Front</VALUE></PROPERTY>
<PROPERTY NAME="ControllerFirmwareVersion"><VALUE>52.30.0-6115</VALUE></PROPERTY>
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
</SIMPLEREQ></MESSAGE></CIM>`
const swXML = `<CIM><MESSAGE><SIMPLEREQ>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SoftwareIdentity">
<PROPERTY NAME="ElementName"><VALUE>Mellanox Network Adapter - 00:1C:FD:D7:5A:E6</VALUE></PROPERTY>
<PROPERTY NAME="FQDD"><VALUE>InfiniBand.Slot.1-1</VALUE></PROPERTY>
<PROPERTY NAME="VersionString"><VALUE>20.39.35.60</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SoftwareIdentity">
<PROPERTY NAME="ElementName"><VALUE>PERC H755 Front</VALUE></PROPERTY>
<PROPERTY NAME="FQDD"><VALUE>RAID.SL.3-1</VALUE></PROPERTY>
<PROPERTY NAME="VersionString"><VALUE>52.30.0-6115</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SoftwareIdentity">
<PROPERTY NAME="ElementName"><VALUE>BIOS</VALUE></PROPERTY>
<PROPERTY NAME="FQDD"><VALUE>BIOS.Setup.1-1</VALUE></PROPERTY>
<PROPERTY NAME="VersionString"><VALUE>1.15.3</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
</SIMPLEREQ></MESSAGE></CIM>`
inner := makeZipArchive(t, map[string][]byte{
"tsr/metadata.json": []byte(`{"Make":"Dell Inc.","Model":"PowerEdge R6625","ServiceTag":"8VS2LG4"}`),
"tsr/hardware/sysinfo/inventory/sysinfo_DCIM_View.xml": []byte(viewXML),
"tsr/hardware/sysinfo/inventory/sysinfo_DCIM_SoftwareIdentity.xml": []byte(swXML),
})
p := &Parser{}
result, err := p.Parse([]parser.ExtractedFile{
{Path: "signature", Content: []byte("ok")},
{Path: "TSR20260306141852_8VS2LG4.pl.zip", Content: inner},
})
if err != nil {
t.Fatalf("parse failed: %v", err)
}
// InfiniBand adapter must appear as a NetworkAdapter, not a PCIe device.
if len(result.Hardware.NetworkAdapters) != 1 {
t.Fatalf("expected 1 network adapter, got %d", len(result.Hardware.NetworkAdapters))
}
nic := result.Hardware.NetworkAdapters[0]
if nic.Slot != "InfiniBand.Slot.1-1" {
t.Errorf("unexpected NIC slot: %q", nic.Slot)
}
if nic.Firmware != "20.39.35.60" {
t.Errorf("unexpected NIC firmware: %q", nic.Firmware)
}
if len(nic.MACAddresses) == 0 || nic.MACAddresses[0] != "00:1C:FD:D7:5A:E6" {
t.Errorf("unexpected NIC MAC: %v", nic.MACAddresses)
}
// pci.ids enrichment: VendorID=0x15B3, DeviceID=0x101B → chip model + vendor name.
if nic.Model != "MT28908 Family [ConnectX-6]" {
t.Errorf("NIC model = %q, want MT28908 Family [ConnectX-6] (from pci.ids)", nic.Model)
}
if nic.Vendor != "Mellanox Technologies" {
t.Errorf("NIC vendor = %q, want Mellanox Technologies (from pci.ids)", nic.Vendor)
}
// InfiniBand FQDD must NOT appear in PCIe devices.
for _, pcie := range result.Hardware.PCIeDevices {
if pcie.Slot == "InfiniBand.Slot.1-1" {
t.Errorf("InfiniBand.Slot.1-1 must not appear in PCIeDevices")
}
}
// Firmware entries from SoftwareIdentity and parseControllerView must carry the FQDD
// as their Description so the exporter's isDeviceBoundFirmwareFQDD filter can remove them.
fqddByName := make(map[string]string)
for _, fw := range result.Hardware.Firmware {
fqddByName[fw.DeviceName] = fw.Description
}
if desc := fqddByName["Mellanox Network Adapter"]; desc != "InfiniBand.Slot.1-1" {
t.Errorf("Mellanox firmware Description = %q, want InfiniBand.Slot.1-1 for FQDD filter", desc)
}
if desc := fqddByName["PERC H755 Front"]; desc != "RAID.SL.3-1" {
t.Errorf("PERC H755 Front firmware Description = %q, want RAID.SL.3-1 for FQDD filter", desc)
}
}
// TestParseDellCPUAffinity verifies that CPUAffinity is parsed into NUMANode for
// NIC, PCIe, and controller views. "Not Applicable" must result in NUMANode=0.
func TestParseDellCPUAffinity(t *testing.T) {
const viewXML = `<CIM><MESSAGE><SIMPLEREQ>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SystemView">
<PROPERTY NAME="Manufacturer"><VALUE>Dell Inc.</VALUE></PROPERTY>
<PROPERTY NAME="Model"><VALUE>PowerEdge R750</VALUE></PROPERTY>
<PROPERTY NAME="ServiceTag"><VALUE>TESTST1</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_NICView">
<PROPERTY NAME="FQDD"><VALUE>NIC.Slot.2-1-1</VALUE></PROPERTY>
<PROPERTY NAME="ProductName"><VALUE>Some NIC</VALUE></PROPERTY>
<PROPERTY NAME="CPUAffinity"><VALUE>1</VALUE></PROPERTY>
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_InfiniBandView">
<PROPERTY NAME="FQDD"><VALUE>InfiniBand.Slot.1-1</VALUE></PROPERTY>
<PROPERTY NAME="DeviceDescription"><VALUE>InfiniBand in Slot 1</VALUE></PROPERTY>
<PROPERTY NAME="CPUAffinity"><VALUE>2</VALUE></PROPERTY>
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_ControllerView">
<PROPERTY NAME="FQDD"><VALUE>RAID.Slot.1-1</VALUE></PROPERTY>
<PROPERTY NAME="ProductName"><VALUE>PERC H755</VALUE></PROPERTY>
<PROPERTY NAME="CPUAffinity"><VALUE>Not Applicable</VALUE></PROPERTY>
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_PCIDeviceView">
<PROPERTY NAME="FQDD"><VALUE>Slot.7-1</VALUE></PROPERTY>
<PROPERTY NAME="Description"><VALUE>Some PCIe Card</VALUE></PROPERTY>
<PROPERTY NAME="CPUAffinity"><VALUE>2</VALUE></PROPERTY>
<PROPERTY NAME="PrimaryStatus"><VALUE>0</VALUE></PROPERTY>
</INSTANCE></VALUE.NAMEDINSTANCE>
</SIMPLEREQ></MESSAGE></CIM>`
inner := makeZipArchive(t, map[string][]byte{
"tsr/metadata.json": []byte(`{"Make":"Dell Inc.","Model":"PowerEdge R750","ServiceTag":"TESTST1"}`),
"tsr/hardware/sysinfo/inventory/sysinfo_DCIM_View.xml": []byte(viewXML),
})
p := &Parser{}
result, err := p.Parse([]parser.ExtractedFile{
{Path: "signature", Content: []byte("ok")},
{Path: "TSR_TESTST1.pl.zip", Content: inner},
})
if err != nil {
t.Fatalf("parse failed: %v", err)
}
// NIC CPUAffinity=1 → NUMANode=1
nicBySlot := make(map[string]int)
for _, nic := range result.Hardware.NetworkAdapters {
nicBySlot[nic.Slot] = nic.NUMANode
}
if nicBySlot["NIC.Slot.2-1-1"] != 1 {
t.Errorf("NIC.Slot.2-1-1 NUMANode = %d, want 1", nicBySlot["NIC.Slot.2-1-1"])
}
if nicBySlot["InfiniBand.Slot.1-1"] != 2 {
t.Errorf("InfiniBand.Slot.1-1 NUMANode = %d, want 2", nicBySlot["InfiniBand.Slot.1-1"])
}
// PCIe device CPUAffinity=2 → NUMANode=2; controller CPUAffinity="Not Applicable" → NUMANode=0
pcieBySlot := make(map[string]int)
for _, pcie := range result.Hardware.PCIeDevices {
pcieBySlot[pcie.Slot] = pcie.NUMANode
}
if pcieBySlot["Slot.7-1"] != 2 {
t.Errorf("Slot.7-1 NUMANode = %d, want 2", pcieBySlot["Slot.7-1"])
}
if pcieBySlot["RAID.Slot.1-1"] != 0 {
t.Errorf("RAID.Slot.1-1 NUMANode = %d, want 0 (Not Applicable)", pcieBySlot["RAID.Slot.1-1"])
}
}
func makeZipArchive(t *testing.T, files map[string][]byte) []byte {
t.Helper()
var buf bytes.Buffer

View File

@@ -3024,6 +3024,7 @@ func mergeStorage(dst *models.Storage, src models.Storage) {
}
setStorageString(&dst.Location, src.Location)
setStorageString(&dst.Status, normalizeStorageStatus(src.Status, src.Present || dst.Present))
dst.Details = mergeH3CDetails(dst.Details, src.Details)
}
func setStorageString(dst *string, value string) {
@@ -3275,6 +3276,22 @@ func mergePSU(dst *models.PSU, src models.PSU) {
setStorageString(&dst.PartNumber, src.PartNumber)
setStorageString(&dst.Firmware, src.Firmware)
setStorageString(&dst.Status, src.Status)
dst.Details = mergeH3CDetails(dst.Details, src.Details)
}
func mergeH3CDetails(primary, secondary map[string]any) map[string]any {
if len(secondary) == 0 {
return primary
}
if primary == nil {
primary = make(map[string]any, len(secondary))
}
for key, value := range secondary {
if _, ok := primary[key]; !ok {
primary[key] = value
}
}
return primary
}
func dedupeVolumes(items []models.StorageVolume) []models.StorageVolume {

View File

@@ -100,10 +100,18 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
return
}
// Replace memory data with detailed info from component.log
hw.Memory = nil
var merged []models.MemoryDIMM
seen := make(map[string]int)
for _, existing := range hw.Memory {
key := inspurMemoryKey(existing)
if key == "" {
continue
}
seen[key] = len(merged)
merged = append(merged, existing)
}
for _, mem := range memInfo.MemModules {
hw.Memory = append(hw.Memory, models.MemoryDIMM{
item := models.MemoryDIMM{
Slot: mem.MemModSlot,
Location: mem.MemModSlot,
Present: mem.MemModStatus == 1 && mem.MemModSize > 0,
@@ -117,8 +125,18 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
PartNumber: strings.TrimSpace(mem.MemModPartNum),
Status: mem.Status,
Ranks: mem.MemModRanks,
})
}
key := inspurMemoryKey(item)
if idx, ok := seen[key]; ok {
mergeInspurMemoryDIMM(&merged[idx], item)
continue
}
if key != "" {
seen[key] = len(merged)
}
merged = append(merged, item)
}
hw.Memory = merged
}
// PSURESTInfo represents the RESTful PSU info structure
@@ -159,10 +177,18 @@ func parsePSUInfo(text string, hw *models.HardwareConfig) {
return
}
// Clear existing PSU data and populate with RESTful data
hw.PowerSupply = nil
var merged []models.PSU
seen := make(map[string]int)
for _, existing := range hw.PowerSupply {
key := inspurPSUKey(existing)
if key == "" {
continue
}
seen[key] = len(merged)
merged = append(merged, existing)
}
for _, psu := range psuInfo.PowerSupplies {
hw.PowerSupply = append(hw.PowerSupply, models.PSU{
item := models.PSU{
Slot: fmt.Sprintf("PSU%d", psu.ID),
Present: psu.Present == 1,
Model: strings.TrimSpace(psu.Model),
@@ -178,8 +204,18 @@ func parsePSUInfo(text string, hw *models.HardwareConfig) {
InputVoltage: psu.PSInVolt,
OutputVoltage: psu.PSOutVolt,
TemperatureC: psu.PSUMaxTemp,
})
}
key := inspurPSUKey(item)
if idx, ok := seen[key]; ok {
mergeInspurPSU(&merged[idx], item)
continue
}
if key != "" {
seen[key] = len(merged)
}
merged = append(merged, item)
}
hw.PowerSupply = merged
}
// HDDRESTInfo represents the RESTful HDD info structure
@@ -357,7 +393,16 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) {
return
}
hw.NetworkAdapters = nil
var merged []models.NetworkAdapter
seen := make(map[string]int)
for _, existing := range hw.NetworkAdapters {
key := inspurNICKey(existing)
if key == "" {
continue
}
seen[key] = len(merged)
merged = append(merged, existing)
}
for _, adapter := range netInfo.SysAdapters {
var macs []string
for _, port := range adapter.Ports {
@@ -377,7 +422,7 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) {
vendor = normalizeModelLabel(pciids.VendorName(adapter.VendorID))
}
hw.NetworkAdapters = append(hw.NetworkAdapters, models.NetworkAdapter{
item := models.NetworkAdapter{
Slot: fmt.Sprintf("Slot %d", adapter.Slot),
Location: adapter.Location,
Present: adapter.Present == 1,
@@ -392,8 +437,231 @@ func parseNetworkAdapterInfo(text string, hw *models.HardwareConfig) {
PortType: adapter.PortType,
MACAddresses: macs,
Status: adapter.Status,
})
}
key := inspurNICKey(item)
if idx, ok := seen[key]; ok {
mergeInspurNIC(&merged[idx], item)
continue
}
if slotIdx := inspurFindNICBySlot(merged, item.Slot); slotIdx >= 0 {
mergeInspurNIC(&merged[slotIdx], item)
if key != "" {
seen[key] = slotIdx
}
continue
}
if key != "" {
seen[key] = len(merged)
}
merged = append(merged, item)
}
hw.NetworkAdapters = merged
}
func inspurMemoryKey(item models.MemoryDIMM) string {
return strings.ToLower(strings.TrimSpace(inspurFirstNonEmpty(item.SerialNumber, item.Slot, item.Location)))
}
func mergeInspurMemoryDIMM(dst *models.MemoryDIMM, src models.MemoryDIMM) {
if dst == nil {
return
}
if strings.TrimSpace(dst.Slot) == "" {
dst.Slot = src.Slot
}
if strings.TrimSpace(dst.Location) == "" {
dst.Location = src.Location
}
dst.Present = dst.Present || src.Present
if dst.SizeMB == 0 {
dst.SizeMB = src.SizeMB
}
if strings.TrimSpace(dst.Type) == "" {
dst.Type = src.Type
}
if strings.TrimSpace(dst.Technology) == "" {
dst.Technology = src.Technology
}
if dst.MaxSpeedMHz == 0 {
dst.MaxSpeedMHz = src.MaxSpeedMHz
}
if dst.CurrentSpeedMHz == 0 {
dst.CurrentSpeedMHz = src.CurrentSpeedMHz
}
if strings.TrimSpace(dst.Manufacturer) == "" {
dst.Manufacturer = src.Manufacturer
}
if strings.TrimSpace(dst.SerialNumber) == "" {
dst.SerialNumber = src.SerialNumber
}
if strings.TrimSpace(dst.PartNumber) == "" {
dst.PartNumber = src.PartNumber
}
if strings.TrimSpace(dst.Status) == "" {
dst.Status = src.Status
}
if dst.Ranks == 0 {
dst.Ranks = src.Ranks
}
}
func inspurPSUKey(item models.PSU) string {
return strings.ToLower(strings.TrimSpace(inspurFirstNonEmpty(item.SerialNumber, item.Slot, item.Model)))
}
func mergeInspurPSU(dst *models.PSU, src models.PSU) {
if dst == nil {
return
}
if strings.TrimSpace(dst.Slot) == "" {
dst.Slot = src.Slot
}
dst.Present = dst.Present || src.Present
if strings.TrimSpace(dst.Model) == "" {
dst.Model = src.Model
}
if strings.TrimSpace(dst.Vendor) == "" {
dst.Vendor = src.Vendor
}
if dst.WattageW == 0 {
dst.WattageW = src.WattageW
}
if strings.TrimSpace(dst.SerialNumber) == "" {
dst.SerialNumber = src.SerialNumber
}
if strings.TrimSpace(dst.PartNumber) == "" {
dst.PartNumber = src.PartNumber
}
if strings.TrimSpace(dst.Firmware) == "" {
dst.Firmware = src.Firmware
}
if strings.TrimSpace(dst.Status) == "" {
dst.Status = src.Status
}
if strings.TrimSpace(dst.InputType) == "" {
dst.InputType = src.InputType
}
if dst.InputPowerW == 0 {
dst.InputPowerW = src.InputPowerW
}
if dst.OutputPowerW == 0 {
dst.OutputPowerW = src.OutputPowerW
}
if dst.InputVoltage == 0 {
dst.InputVoltage = src.InputVoltage
}
if dst.OutputVoltage == 0 {
dst.OutputVoltage = src.OutputVoltage
}
if dst.TemperatureC == 0 {
dst.TemperatureC = src.TemperatureC
}
}
func inspurNICKey(item models.NetworkAdapter) string {
return strings.ToLower(strings.TrimSpace(inspurFirstNonEmpty(item.SerialNumber, strings.Join(item.MACAddresses, ","), item.Slot, item.Location)))
}
func mergeInspurNIC(dst *models.NetworkAdapter, src models.NetworkAdapter) {
if dst == nil {
return
}
if strings.TrimSpace(dst.Slot) == "" {
dst.Slot = src.Slot
}
if strings.TrimSpace(dst.Location) == "" {
dst.Location = src.Location
}
dst.Present = dst.Present || src.Present
if strings.TrimSpace(dst.BDF) == "" {
dst.BDF = src.BDF
}
if strings.TrimSpace(dst.Model) == "" {
dst.Model = src.Model
}
if strings.TrimSpace(dst.Description) == "" {
dst.Description = src.Description
}
if strings.TrimSpace(dst.Vendor) == "" {
dst.Vendor = src.Vendor
}
if dst.VendorID == 0 {
dst.VendorID = src.VendorID
}
if dst.DeviceID == 0 {
dst.DeviceID = src.DeviceID
}
if strings.TrimSpace(dst.SerialNumber) == "" {
dst.SerialNumber = src.SerialNumber
}
if strings.TrimSpace(dst.PartNumber) == "" {
dst.PartNumber = src.PartNumber
}
if strings.TrimSpace(dst.Firmware) == "" {
dst.Firmware = src.Firmware
}
if dst.PortCount == 0 {
dst.PortCount = src.PortCount
}
if strings.TrimSpace(dst.PortType) == "" {
dst.PortType = src.PortType
}
if dst.LinkWidth == 0 {
dst.LinkWidth = src.LinkWidth
}
if strings.TrimSpace(dst.LinkSpeed) == "" {
dst.LinkSpeed = src.LinkSpeed
}
if dst.MaxLinkWidth == 0 {
dst.MaxLinkWidth = src.MaxLinkWidth
}
if strings.TrimSpace(dst.MaxLinkSpeed) == "" {
dst.MaxLinkSpeed = src.MaxLinkSpeed
}
if dst.NUMANode == 0 {
dst.NUMANode = src.NUMANode
}
if strings.TrimSpace(dst.Status) == "" {
dst.Status = src.Status
}
for _, mac := range src.MACAddresses {
mac = strings.TrimSpace(mac)
if mac == "" {
continue
}
found := false
for _, existing := range dst.MACAddresses {
if strings.EqualFold(strings.TrimSpace(existing), mac) {
found = true
break
}
}
if !found {
dst.MACAddresses = append(dst.MACAddresses, mac)
}
}
}
func inspurFindNICBySlot(items []models.NetworkAdapter, slot string) int {
slot = strings.ToLower(strings.TrimSpace(slot))
if slot == "" {
return -1
}
for i := range items {
if strings.ToLower(strings.TrimSpace(items[i].Slot)) == slot {
return i
}
}
return -1
}
func inspurFirstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func parseFanSensors(text string) []models.SensorReading {

View File

@@ -51,6 +51,64 @@ RESTful fan`
}
}
func TestParseNetworkAdapterInfo_MergesIntoExistingInventory(t *testing.T) {
text := `RESTful Network Adapter info:
{
"sys_adapters": [
{
"id": 1,
"name": "NIC1",
"Location": "#CPU0_PCIE4",
"present": 1,
"slot": 4,
"vendor_id": 32902,
"device_id": 5409,
"vendor": "Mellanox",
"model": "ConnectX-6",
"fw_ver": "22.1.0",
"status": "OK",
"sn": "",
"pn": "",
"port_num": 2,
"port_type": "QSFP",
"ports": [
{ "id": 1, "mac_addr": "00:11:22:33:44:55" }
]
}
]
}
RESTful fan`
hw := &models.HardwareConfig{
NetworkAdapters: []models.NetworkAdapter{
{
Slot: "Slot 4",
BDF: "0000:17:00.0",
SerialNumber: "NIC-SN-1",
Present: true,
},
},
}
parseNetworkAdapterInfo(text, hw)
if len(hw.NetworkAdapters) != 1 {
t.Fatalf("expected merged single adapter, got %d", len(hw.NetworkAdapters))
}
got := hw.NetworkAdapters[0]
if got.BDF != "0000:17:00.0" {
t.Fatalf("expected existing BDF to survive merge, got %q", got.BDF)
}
if got.Model != "ConnectX-6" {
t.Fatalf("expected model from component log, got %q", got.Model)
}
if got.SerialNumber != "NIC-SN-1" {
t.Fatalf("expected serial from existing inventory to survive merge, got %q", got.SerialNumber)
}
if len(got.MACAddresses) != 1 || got.MACAddresses[0] != "00:11:22:33:44:55" {
t.Fatalf("expected MAC addresses from component log, got %#v", got.MACAddresses)
}
}
func TestParseComponentLogSensors_ExtractsFanBackplaneAndPSUSummary(t *testing.T) {
text := `RESTful PSU info:
{